Repository: AlistGo/alist (language: go)

Issue:
解压功能鉴权无效
### Please make sure of the following things

- [x] I have read the [documentation](https://alist.nn.ci).
我已经阅读了[文档](https://alist.nn.ci)。

- [x] I'm sure there are no duplicate issues or discussions.
我确定没有重复的issue或讨论。

- [x] I'm sure it's due to `AList` and not something else(such as [Network](https://alist.nn.ci/faq/howto.html#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host) ,`Dependencies` or `Operational`).
我确定是`AList`的问题，而不是其他原因（例如[网络](https://alist.nn.ci/zh/faq/howto.html#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host)，`依赖`或`操作`）。

- [x] I'm sure this issue is not fixed in the latest version.
我确定这个问题在最新版本中没有被修复。


### AList Version / AList 版本

v3.42.0

### Driver used / 使用的存储驱动

archive

### Describe the bug / 问题描述

Alist v3.42.0版本的解压缩功能发现一个漏洞，所有用户均可以通过  https://github.com/AlistGo/alist/pull/7817 的解压接口消耗服务器流量，在设置中关闭读取压缩文件、解压无效

### Reproduction / 复现链接

下载解压配置中的上传的 `app.zip`，将Alist主程序放入并使用 `alist server` 运行，访问：

http://127.0.0.1:5244/ae/mount/1GB.zip?inner=/1GB.bin&sign=oXVKHW6Xxe1OZXm0ahoPdW1hKfy86VR5StDneeJz4jc=:0

（如果签名过期的话请自行替换，或关闭签名）

这个 1GB.zip 中包含一个填充/dev/zero的 1GB.bin 文件

### Config / 配置

配置文件：[app.zip](https://github.com/user-attachments/files/18749147/app.zip)

读取压缩文件、解压都是关闭的：

![](https://github.com/user-attachments/assets/d46c891b-159b-4d71-9e41-a3bcc897b314)

如果挂载文件的路径不正确的话可以自行修改：

![](https://github.com/user-attachments/assets/2020f9f6-e93f-489f-8502-e106c3aa6be1)

### Logs / 日志

```cmd
C:\Users\Admin>curl "http://127.0.0.1:5244/ae/mount/1GB.zip?inner=/1GB.bin&sign=oXVKHW6Xxe1OZXm0ahoPdW1hKfy86VR5StDneeJz4jc=:0" -v
*   Trying 127.0.0.1:5244...
* Connected to 127.0.0.1 (127.0.0.1) port 5244 (#0)
> GET /ae/mount/1GB.zip?inner=/1GB.bin&sign=oXVKHW6Xxe1OZXm0ahoPdW1hKfy86VR5StDneeJz4jc=:0 HTTP/1.1
> Host: 127.0.0.1:5244
> User-Agent: curl/7.83.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Cache-Control: max-age=0, no-cache, no-store, must-revalidate
< Content-Disposition: attachment; filename="1GB.bin"; filename*=UTF-8''1GB.bin
< Content-Length: 1073741824
< Content-Type: application/octet-stream
< Referrer-Policy: no-referrer
< Date: Tue, 11 Feb 2025 11:36:37 GMT
<
Warning: Binary output can mess up your terminal. Use "--output -" to tell
Warning: curl to output it to your terminal anyway, or consider "--output
Warning: <FILE>" to save to a file.
* Failure writing output to destination
* Closing connection 0
```


Discussion / hints:
在问题被修复前，建议管理员通过 NGINX 或 WAF 封禁以下路径，避免造成服务器流量被恶意消耗：

/ae/

我看 /d 和 /p 是没加鉴权的，如何理解呢
> 我看 /d 和 /p 是没加鉴权的，如何理解呢

/d 和 /p 没加鉴权不影响，实际情况都是挂载网盘然后302跳转，不会造成服务器流量消耗，但解压功能的流量会走服务器中转，因此要对其进行鉴权或限制。

最主要的问题是用户设置里面即使禁用了解压功能也能解压。

> > 我看 /d 和 /p 是没加鉴权的，如何理解呢
> 
> /d 和 /p 没加鉴权不影响，实际情况都是挂载网盘然后302跳转，不会造成服务器流量消耗，但解压功能的流量会走服务器中转，因此要对其进行鉴权或限制。
> 
> 最主要的问题是用户设置里面即使禁用了解压功能也能解压。
> 

/ad /ap 和 /d /p 的行为是一样的，那应该只有 /ae 需要鉴权

Fix patch:
diff --git a/internal/sign/archive.go b/internal/sign/archive.go
new file mode 100644
index 00000000000..26a2c208d56
--- /dev/null
+++ b/internal/sign/archive.go
@@ -0,0 +1,41 @@
+package sign
+
+import (
+	"sync"
+	"time"
+
+	"github.com/alist-org/alist/v3/internal/conf"
+	"github.com/alist-org/alist/v3/internal/setting"
+	"github.com/alist-org/alist/v3/pkg/sign"
+)
+
+var onceArchive sync.Once
+var instanceArchive sign.Sign
+
+func SignArchive(data string) string {
+	expire := setting.GetInt(conf.LinkExpiration, 0)
+	if expire == 0 {
+		return NotExpiredArchive(data)
+	} else {
+		return WithDurationArchive(data, time.Duration(expire)*time.Hour)
+	}
+}
+
+func WithDurationArchive(data string, d time.Duration) string {
+	onceArchive.Do(InstanceArchive)
+	return instanceArchive.Sign(data, time.Now().Add(d).Unix())
+}
+
+func NotExpiredArchive(data string) string {
+	onceArchive.Do(InstanceArchive)
+	return instanceArchive.Sign(data, 0)
+}
+
+func VerifyArchive(data string, sign string) error {
+	onceArchive.Do(InstanceArchive)
+	return instanceArchive.Verify(data, sign)
+}
+
+func InstanceArchive() {
+	instanceArchive = sign.NewHMACSign([]byte(setting.GetStr(conf.Token) + "-archive"))
+}
diff --git a/server/debug.go b/server/debug.go
index 081ef8c3381..a4242abdb2b 100644
--- a/server/debug.go
+++ b/server/debug.go
@@ -5,6 +5,7 @@ import (
 	_ "net/http/pprof"
 	"runtime"
 
+	"github.com/alist-org/alist/v3/internal/sign"
 	"github.com/alist-org/alist/v3/server/common"
 	"github.com/alist-org/alist/v3/server/middlewares"
 	"github.com/gin-gonic/gin"
@@ -15,7 +16,7 @@ func _pprof(g *gin.RouterGroup) {
 }
 
 func debug(g *gin.RouterGroup) {
-	g.GET("/path/*path", middlewares.Down, func(ctx *gin.Context) {
+	g.GET("/path/*path", middlewares.Down(sign.Verify), func(ctx *gin.Context) {
 		rawPath := ctx.MustGet("path").(string)
 		ctx.JSON(200, gin.H{
 			"path": rawPath,
diff --git a/server/handles/archive.go b/server/handles/archive.go
index fab3916e3ca..4ec933e17b0 100644
--- a/server/handles/archive.go
+++ b/server/handles/archive.go
@@ -120,7 +120,7 @@ func FsArchiveMeta(c *gin.Context) {
 	}
 	s := ""
 	if isEncrypt(meta, reqPath) || setting.GetBool(conf.SignAll) {
-		s = sign.Sign(reqPath)
+		s = sign.SignArchive(reqPath)
 	}
 	api := "/ae"
 	if ret.DriverProviding {
diff --git a/server/middlewares/down.go b/server/middlewares/down.go
index 05e9dc856d8..d015672de80 100644
--- a/server/middlewares/down.go
+++ b/server/middlewares/down.go
@@ -9,35 +9,36 @@ import (
 	"github.com/alist-org/alist/v3/internal/errs"
 	"github.com/alist-org/alist/v3/internal/model"
 	"github.com/alist-org/alist/v3/internal/op"
-	"github.com/alist-org/alist/v3/internal/sign"
 	"github.com/alist-org/alist/v3/pkg/utils"
 	"github.com/alist-org/alist/v3/server/common"
 	"github.com/gin-gonic/gin"
 	"github.com/pkg/errors"
 )
 
-func Down(c *gin.Context) {
-	rawPath := parsePath(c.Param("path"))
-	c.Set("path", rawPath)
-	meta, err := op.GetNearestMeta(rawPath)
-	if err != nil {
-		if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
-			common.ErrorResp(c, err, 500, true)
-			return
-		}
-	}
-	c.Set("meta", meta)
-	// verify sign
-	if needSign(meta, rawPath) {
-		s := c.Query("sign")
-		err = sign.Verify(rawPath, strings.TrimSuffix(s, "/"))
+func Down(verifyFunc func(string, string) error) func(c *gin.Context) {
+	return func(c *gin.Context) {
+		rawPath := parsePath(c.Param("path"))
+		c.Set("path", rawPath)
+		meta, err := op.GetNearestMeta(rawPath)
 		if err != nil {
-			common.ErrorResp(c, err, 401)
-			c.Abort()
-			return
+			if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
+				common.ErrorResp(c, err, 500, true)
+				return
+			}
+		}
+		c.Set("meta", meta)
+		// verify sign
+		if needSign(meta, rawPath) {
+			s := c.Query("sign")
+			err = verifyFunc(rawPath, strings.TrimSuffix(s, "/"))
+			if err != nil {
+				common.ErrorResp(c, err, 401)
+				c.Abort()
+				return
+			}
 		}
+		c.Next()
 	}
-	c.Next()
 }
 
 // TODO: implement
diff --git a/server/router.go b/server/router.go
index 830051d8f51..2dd6ee88601 100644
--- a/server/router.go
+++ b/server/router.go
@@ -4,6 +4,7 @@ import (
 	"github.com/alist-org/alist/v3/cmd/flags"
 	"github.com/alist-org/alist/v3/internal/conf"
 	"github.com/alist-org/alist/v3/internal/message"
+	"github.com/alist-org/alist/v3/internal/sign"
 	"github.com/alist-org/alist/v3/internal/stream"
 	"github.com/alist-org/alist/v3/pkg/utils"
 	"github.com/alist-org/alist/v3/server/common"
@@ -40,16 +41,18 @@ func Init(e *gin.Engine) {
 	S3(g.Group("/s3"))
 
 	downloadLimiter := middlewares.DownloadRateLimiter(stream.ClientDownloadLimit)
-	g.GET("/d/*path", middlewares.Down, downloadLimiter, handles.Down)
-	g.GET("/p/*path", middlewares.Down, downloadLimiter, handles.Proxy)
-	g.HEAD("/d/*path", middlewares.Down, handles.Down)
-	g.HEAD("/p/*path", middlewares.Down, handles.Proxy)
-	g.GET("/ad/*path", middlewares.Down, downloadLimiter, handles.ArchiveDown)
-	g.GET("/ap/*path", middlewares.Down, downloadLimiter, handles.ArchiveProxy)
-	g.GET("/ae/*path", middlewares.Down, downloadLimiter, handles.ArchiveInternalExtract)
-	g.HEAD("/ad/*path", middlewares.Down, handles.ArchiveDown)
-	g.HEAD("/ap/*path", middlewares.Down, handles.ArchiveProxy)
-	g.HEAD("/ae/*path", middlewares.Down, handles.ArchiveInternalExtract)
+	signCheck := middlewares.Down(sign.Verify)
+	g.GET("/d/*path", signCheck, downloadLimiter, handles.Down)
+	g.GET("/p/*path", signCheck, downloadLimiter, handles.Proxy)
+	g.HEAD("/d/*path", signCheck, handles.Down)
+	g.HEAD("/p/*path", signCheck, handles.Proxy)
+	archiveSignCheck := middlewares.Down(sign.VerifyArchive)
+	g.GET("/ad/*path", archiveSignCheck, downloadLimiter, handles.ArchiveDown)
+	g.GET("/ap/*path", archiveSignCheck, downloadLimiter, handles.ArchiveProxy)
+	g.GET("/ae/*path", archiveSignCheck, downloadLimiter, handles.ArchiveInternalExtract)
+	g.HEAD("/ad/*path", archiveSignCheck, handles.ArchiveDown)
+	g.HEAD("/ap/*path", archiveSignCheck, handles.ArchiveProxy)
+	g.HEAD("/ae/*path", archiveSignCheck, handles.ArchiveInternalExtract)
 
 	api := g.Group("/api")
 	auth := api.Group("", middlewares.Auth)


Test patch: