bigywater.tech
Gin · 43

Static 与 StaticFS:目录列表与路径穿越

r.Static("/static", "./public") 一行就能发布目录,但它到底提供什么、不提供什么?点文件会不会泄漏?路径穿越拦不拦得住?符号链接呢?这篇用原始 TCP 请求把 Static 的行为逐项试出来。

200
/static/.env(点文件)的状态码:内容被直接返回
200
指向根目录之外的符号链接 /static/link.txt 的状态码
10 / 10
10 种路径穿越写法全部是 404

Static 做了什么

实测 · 本机执行输出
  routergroup.go 的 createStaticHandler(Gin v1.12.0):
     216 | func (group *RouterGroup) createStaticHandler(relativePath string, fs http.FileSystem) HandlerFunc {
     217 |     absolutePath := group.calculateAbsolutePath(relativePath)
     218 |     fileServer := http.StripPrefix(absolutePath, http.FileServer(fs))
     219 | 
     220 |     return func(c *Context) {
     221 |         if _, noListing := fs.(*OnlyFilesFS); noListing {
     222 |             c.Writer.WriteHeader(http.StatusNotFound)
     223 |         }
     224 | 
     225 |         file := c.Param("filepath")
     226 |         // Check if file exists and/or if we have permission to access it
     227 |         f, err := fs.Open(file)
     228 |         if err != nil {
     229 |             c.Writer.WriteHeader(http.StatusNotFound)
     230 |             c.handlers = group.engine.noRoute
     231 |             // Reset index
     232 |             c.index = -1
     233 |             return
     234 |         }
     235 |         f.Close()
     236 | 
     237 |         fileServer.ServeHTTP(c.Writer, c.Request)
     238 |     }
     239 | }
  fs.go 的 Dir(Gin v1.12.0):
      42 | func Dir(root string, listDirectory bool) http.FileSystem {
      43 |     fs := http.Dir(root)
      44 | 
      45 |     if listDirectory {
      46 |         return fs
      47 |     }
      48 | 
      49 |     return &OnlyFilesFS{FileSystem: fs}
      50 | }

Static 是对标准库 http.FileServer 的薄封装:用 Dir(root, false) 禁止列目录,去掉 URL 前缀后交给 FileServer,打不开的文件走 Gin 的 NoRoute。所以缓存头、Range、If-Modified-Since、Content-Type 嗅探都是标准库的行为。

不会替你过滤什么

实测 · 本机执行输出
    GET /static/a.txt                → HTTP/1.1 200 OK | "file a"
    GET /static/sub/b.txt            → HTTP/1.1 200 OK | "file b"
    GET /static/nested/deep/c.txt    → HTTP/1.1 200 OK | "file c"
    GET /static/                     → HTTP/1.1 200 OK | "<h1>首页</h1>"
    GET /static/index.html           → HTTP/1.1 301 Moved Permanently Location: ./ | ""
    GET /static/sub/                 → HTTP/1.1 404 Not Found | "<!doctype html>\n<meta name=\"viewport\" content=\"width=device-…"
    GET /static/sub                  → HTTP/1.1 301 Moved Permanently Location: sub/ | ""
    GET /static/nested/              → HTTP/1.1 404 Not Found | "<!doctype html>\n<meta name=\"viewport\" content=\"width=device-…"
    GET /static/.env                 → HTTP/1.1 200 OK | "SECRET_KEY=dotfile-content"
    GET /static/noext                → HTTP/1.1 200 OK | "no extension"
    GET /static/nope.txt             → HTTP/1.1 404 Not Found | ""
    GET /static/link.txt             → HTTP/1.1 200 OK | "OUTSIDE-THE-ROOT"
    GET /static                      → HTTP/1.1 301 Moved Permanently Location: /static/ | "<a href=\"/static/\">Moved Permanently</a>."

目录下的所有文件都会被提供,包括 .env 这样的点文件;符号链接会被跟随,即使它指向根目录之外(http.Dir 文档明确说了这一点)。把 Static 的根目录指到项目根目录是最常见的泄漏源码和配置的方式。只放需要公开的文件,并且别在里面放符号链接。

路径穿越

实测 · 本机执行输出
    GET /static/../secret.txt                    → HTTP/1.1 404 Not Found | ""
    GET /static/..%2fsecret.txt                  → HTTP/1.1 404 Not Found | ""
    GET /static/%2e%2e/secret.txt                → HTTP/1.1 404 Not Found | ""
    GET /static/%2e%2e%2fsecret.txt              → HTTP/1.1 404 Not Found | ""
    GET /static/..%252fsecret.txt                → HTTP/1.1 404 Not Found | ""
    GET /static/sub/../../secret.txt             → HTTP/1.1 404 Not Found | ""
    GET /static/..\secret.txt                    → HTTP/1.1 404 Not Found | ""
    GET /static/%00                              → HTTP/1.1 404 Not Found | ""
    GET /static//etc/passwd                      → HTTP/1.1 404 Not Found | ""
    GET /static/sub/..%2f..%2fsecret.txt         → HTTP/1.1 404 Not Found | ""

10 种穿越写法(..、编码的 %2e%2e%2f、双重编码、反斜杠、NUL、多层 ../)全部得到 404:标准库先把路径 Clean 成以根为起点的绝对路径,.. 无法越出根。这只限于路径本身,不包括符号链接,也不包括你自己用 c.File 拼接的路径(见后面的文章)。

目录与列表

实测 · 本机执行输出
    GET /nolist/sub/     → HTTP/1.1 404 Not Found | "<!doctype html>\n<meta name=\"viewport\" content=\"width=device-width\">\n<pre>\n</pre>"
    GET /list/sub/       → HTTP/1.1 200 OK | "<!doctype html>\n<meta name=\"viewport\" content=\"width=device-width\">\n<pre>\n<a href=\"b.txt\">b.txt</a>\n</pre>"
    GET /list2/sub/      → HTTP/1.1 200 OK | "<!doctype html>\n<meta name=\"viewport\" content=\"width=device-width\">\n<pre>\n<a href=\"b.txt\">b.txt</a>\n</pre>"
    GET /nolist/nested/  → HTTP/1.1 404 Not Found | "<!doctype html>\n<meta name=\"viewport\" content=\"width=device-width\">\n<pre>\n</pre>"
    GET /list/nested/    → HTTP/1.1 200 OK | "<!doctype html>\n<meta name=\"viewport\" content=\"width=device-width\">\n<pre>\n<a href=\"deep/\">deep/</a>\n</pre>"

没有 index.html 的目录,Static 返回 404 加一个空的目录页(它先把状态设成 404,目录页再没改);想列目录用 StaticFS 配 http.Dir 或 gin.Dir(root, true),但这几乎总是不该在生产里做的事。

缓存与条件请求

实测 · 本机执行输出
    GET a.txt → 200 Last-Modified="Sun, 11 Oct 2026 11:10:24 GMT" ETag="" Content-Type="text/plain; charset=utf-8" Content-Length=7 Accept-Ranges="bytes" Cache-Control=""
    带 If-Modified-Since=Last-Modified → 304 正文 ""
    带更早的 If-Modified-Since → 200 正文 "file a\n"
    HEAD a.txt → 200 正文长度 0 Content-Length=7
    无扩展名 noext(内容是文本)→ Content-Type="text/plain; charset=utf-8"(靠嗅探前 512 字节)
    page.html → Content-Type="text/html; charset=utf-8"
    POST a.txt → 404(只注册了 GET 与 HEAD)

响应带 Last-Modified 和 Accept-Ranges,支持 If-Modified-Since 返回 304,但没有 ETag 也没有 Cache-Control——浏览器会启发式缓存。需要长缓存(带 hash 的文件名)就要自己用中间件加 Cache-Control。

与其它路由的冲突

实测 · 本机执行输出
    先 GET /ping,再 Static("/")          → panic: catch-all wildcard '*filepath' in new path '/*filepath' conflicts with existing path segment 'ping' in existing prefix '/pin
    先 Static("/"),再 GET /ping          → panic: '/ping' in new path '/ping' conflicts with existing wildcard '/*filepath' in existing prefix '/*filepath'
    Static("/assets") 与 GET /ping      → 注册成功
    Static("/assets") 与 GET /assets/x  → panic: '/x' in new path '/assets/x' conflicts with existing wildcard '/*filepath' in existing prefix '/assets/*filepath'
    StaticFile 路径里有参数                  → panic: URL parameters can not be used when serving a static file
    Static 路径里有通配符                     → panic: URL parameters can not be used when serving a static folder

Static("/", dir) 注册的是根上的 catch-all,与同级的任何路由都冲突(先后顺序都会 panic)。通常用 Static("/assets", …) 这样的前缀,或者把单页应用的入口交给 NoRoute。

分步演示

静态文件
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。请求大多是原始 TCP 手写的请求行(避免客户端提前改写路径),缓存头实验用 http.Transport.RoundTrip;测试目录是实验时在程序目录里创建的小文件,符号链接指向 ../secret.txt;每种情形各一次
    • 没有验证:Windows 路径(反斜杠、保留设备名如 CON);大小写不敏感的文件系统(macOS 默认,是在 macOS 上测的,但没有专门测大小写);http.FileServer 对 ../ 的处理在不同 Go 版本里的差异;embed.FS 与 StaticFS 组合;URL 里的 %00 是否在更底层被拒绝
    京ICP备2026067502号-1京公网安备11010502063487号