路径里有 %2F 的 URL,Gin 默认匹配不了 /files/:name。打开 UseRawPath 能解决,但会带来什么?这篇把三个开关的组合、哪些字符受影响、取到的值长什么样都列出来,并验证打开它之后的路径穿越风险。
gin.go 的 handleHTTPRequest 开头(Gin v1.12.0):
690 | func (engine *Engine) handleHTTPRequest(c *Context) {
691 | httpMethod := c.Request.Method
692 | rPath := c.Request.URL.Path
693 | unescape := false
694 |
695 | if engine.UseEscapedPath {
696 | rPath = c.Request.URL.EscapedPath()
697 | unescape = engine.UnescapePathValues
698 | } else if engine.UseRawPath && len(c.Request.URL.RawPath) > 0 {
699 | rPath = c.Request.URL.RawPath
700 | unescape = engine.UnescapePathValues
701 | }
702 |
703 | if engine.RemoveExtraSlash {
704 | rPath = cleanPath(rPath)
705 | }
706 |
默认:UseRawPath=false UseEscapedPath=false UnescapePathValues=true RemoveExtraSlash=false
/files/a%2Fb URL.Path="/files/a/b" URL.RawPath="/files/a%2Fb" EscapedPath()="/files/a%2Fb"
/files/a%20b URL.Path="/files/a b" URL.RawPath="" EscapedPath()="/files/a%20b"
/files/%E4%B8%AD URL.Path="/files/中" URL.RawPath="" EscapedPath()="/files/%E4%B8%AD"
/files/a+b URL.Path="/files/a+b" URL.RawPath="" EscapedPath()="/files/a+b"
/files/100%25 URL.Path="/files/100%" URL.RawPath="" EscapedPath()="/files/100%25"
/files/a%2fb URL.Path="/files/a/b" URL.RawPath="/files/a%2fb" EscapedPath()="/files/a%2fb"
/files/%7Euser URL.Path="/files/~user" URL.RawPath="/files/%7Euser" EscapedPath()="/files/%7Euser"
/files/a%3Fb URL.Path="/files/a?b" URL.RawPath="" EscapedPath()="/files/a%3Fb"Gin 默认用已经解码的 URL.Path 去匹配路由,所以 %2F 会变成真的斜杠。URL.RawPath 只有在“默认的编码方式与原始写法不同”时才被 net/url 填上:%2F、%7E 会有,%20、中文、%25、+、%3F 都是空串。所以 UseRawPath 对后面这些写法没有任何作用。
a%2Fb a%20b %E4%B8%AD a+b 100%25 a%2Fb/c
默认 "404" "a b" "中" "a+b" "100%" "404"
Raw=true Unescape=true "a/b" "a b" "中" "a+b" "100%" "a/b|c"
Raw=true Unescape=false "a%2Fb" "a b" "中" "a+b" "100%" "a%2Fb|c"
Escaped=true Unescape=true "a/b" "a b" "中" "a b" "100%" "a/b|c"
Escaped=true Unescape=false "a%2Fb" "a%20b" "%E4%B8%AD" "a+b" "100%25" "a%2Fb|c"只有 UseRawPath 或 UseEscapedPath 才能让 %2F 留在同一段里。UnescapePathValues=true 时取出的值再被解码(a%2Fb → a/b),关掉则保持转义形式。一个容易忽略的差别:UseEscapedPath=true 加 Unescape 时用的是 url.QueryUnescape,所以 a+b 会变成 a b——这是查询串的规则,不是路径的规则。
UseRawPath=false UnescapePathValues=false:
/s/a%20b → 200 "/a b"
/s/a%2Fb/c → 200 "/a/b/c"
/s/%E4%B8%AD → 200 "/中"
/s/a%25b → 200 "/a%b"
UseRawPath=true UnescapePathValues=false:
/s/a%20b → 200 "/a b"
/s/a%2Fb/c → 200 "/a%2Fb/c"
/s/%E4%B8%AD → 200 "/中"
/s/a%25b → 200 "/a%b"*p 也一样:默认情况 /s/a%2Fb/c 的 p 是 /a/b/c(已经分不清原来有没有编码);只有 UseRawPath=true 且不解码时才能保留 /a%2Fb/c,后面的代码可以自己区分两种斜杠。
[默认]
/dl/report.pdf → 200 name="report.pdf" Join(/srv/files, name)="/srv/files/report.pdf"
/dl/..%2F..%2Fetc%2Fpasswd → 404 404 page not found
/dl/%2e%2e%2fsecret → 404 404 page not found
[UseRawPath=true]
/dl/report.pdf → 200 name="report.pdf" Join(/srv/files, name)="/srv/files/report.pdf"
/dl/..%2F..%2Fetc%2Fpasswd → 200 name="../../etc/passwd" Join(/srv/files, name)="/etc/passwd"
/dl/%2e%2e%2fsecret → 200 name="../secret" Join(/srv/files, name)="/srv/secret"默认配置里 ..%2F 因为被拆成多段,碰巧进不了 :name。打开 UseRawPath 之后,:name 的值是 ../../etc/passwd,直接拼进文件路径就是路径穿越。打开这个开关的同时,必须对参数做白名单或 filepath.Base 之类的清理。