r.Static("/static", "./public") 一行就能发布目录,但它到底提供什么、不提供什么?点文件会不会泄漏?路径穿越拦不拦得住?符号链接呢?这篇用原始 TCP 请求把 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 folderStatic("/", dir) 注册的是根上的 catch-all,与同级的任何路由都冲突(先后顺序都会 panic)。通常用 Static("/assets", …) 这样的前缀,或者把单页应用的入口交给 NoRoute。