bigywater.tech
Gin · 44

c.File 与 Range 请求

下载大文件、断点续传、中文文件名、c.File 的路径从哪来——这些在 Gin 里都归 c.File 与 FileAttachment 管。这篇用真实的 Range 请求和 200 MiB 文件测它们的行为,并重点验证一个常见的安全坑:把用户给的路径交给 c.File。

416
Range: bytes=200-300 超出 100 字节文件:416,Content-Range: bytes */100
200 + 根外文件
c.File(Join(root, c.Query("f"))) 在 ?f=../secret.txt 时读到了根目录之外的文件
0.7 vs 200.1 MiB
200 MiB 文件用 c.File 与 ReadFile + c.Data 服务一次的累计分配

c.File 就是 ServeFile

实测 · 本机执行输出
  context.go 的 File(Gin v1.12.0):
    1280 | func (c *Context) File(filepath string) {
    1281 |     http.ServeFile(c.Writer, c.Request, filepath)
    1282 | }
  context.go 的 FileAttachment(Gin v1.12.0):
    1303 | func (c *Context) FileAttachment(filepath, filename string) {
    1304 |     if isASCII(filename) {
    1305 |         c.Writer.Header().Set("Content-Disposition", `attachment; filename="`+escapeQuotes(filename)+`"`)
    1306 |     } else {
    1307 |         c.Writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(filename))
    1308 |     }
    1309 |     http.ServeFile(c.Writer, c.Request, filepath)
    1310 | }

c.File 只有一行 http.ServeFile,所以它继承了标准库的全部能力:Range、If-Range、If-Modified-Since、按扩展名与嗅探确定 Content-Type、目录请求的重定向。FileAttachment 只是多设了一个 Content-Disposition。

Range 请求

实测 · 本机执行输出
    无 Range → 200 长度 100 Accept-Ranges="bytes" Content-Length=100 Last-Modified 有=true
    Range: "bytes=0-9"              → 206 Content-Range="bytes 0-9/100"    Content-Type="text/plain; charset=utf-8"                          正文长度 10  "0123456789"
    Range: "bytes=95-"              → 206 Content-Range="bytes 95-99/100"  Content-Type="text/plain; charset=utf-8"                          正文长度 5   "56789"
    Range: "bytes=-5"               → 206 Content-Range="bytes 95-99/100"  Content-Type="text/plain; charset=utf-8"                          正文长度 5   "56789"
    Range: "bytes=0-0"              → 206 Content-Range="bytes 0-0/100"    Content-Type="text/plain; charset=utf-8"                          正文长度 1   "0"
    Range: "bytes=50-1000"          → 206 Content-Range="bytes 50-99/100"  Content-Type="text/plain; charset=utf-8"                          正文长度 50  "012345678901234567890
    Range: "bytes=0-4,10-14"        → 206 Content-Range=""                 Content-Type="multipart/byteranges; boundary=8521331b61e76fcdef9df26172a6e578bc00b2aa0df074666b3c
    Range: "bytes=200-300"          → 416 Content-Range="bytes */100"      Content-Type="text/plain; charset=utf-8"                          正文长度 33  "invalid range: failed
    Range: "bytes=5-2"              → 416 Content-Range=""                 Content-Type="text/plain; charset=utf-8"                          正文长度 14  "invalid range\n"
    Range: "bytes=abc"              → 416 Content-Range=""                 Content-Type="text/plain; charset=utf-8"                          正文长度 14  "invalid range\n"
    Range: "items=0-4"              → 416 Content-Range=""                 Content-Type="text/plain; charset=utf-8"                          正文长度 14  "invalid range\n"
    Range: "bytes=0-9, 10-19, 20-29" → 206 Content-Range=""                 Content-Type="multipart/byteranges; boundary=c81baa55aabe3b4167edd0177fdfb4acde6305619aacad39bba
    对照 c.Data + Range: bytes=0-9 → 200 Accept-Ranges="" 正文长度 100(整个文件)
    HEAD + Range → 206 Content-Range="bytes 0-9/100" Content-Length=10

单段 Range 返回 206 与 Content-Range;bytes=95-、bytes=-5 都表示末尾,超出文件末尾的上界被截断;多段返回 multipart/byteranges;起点超出文件或语法不对返回 416(起点越界时带 Content-Range: bytes */总长度)。HEAD 加 Range 同样返回 206。c.Data / c.String 没有这些,Range 请求总是返回整个内容,也没有 Accept-Ranges。

断点续传

实测 · 本机执行输出
    If-Range=Last-Modified(匹配)→ 206 长度 10
    If-Range=更早的日期(文件“变了”)→ 200 长度 100(返回整个文件)
    If-Range=一个 ETag(服务端没有 ETag)→ 200 长度 100

If-Range 用 Last-Modified 做条件:匹配就返回部分内容,文件变过就返回完整文件,避免拼出损坏的文件。服务端没有 ETag,所以带 ETag 的 If-Range 总是退化成 200 整个文件。

FileAttachment 的文件名

实测 · 本机执行输出
    filename="report.txt"   → Content-Disposition: attachment; filename="report.txt"
    filename="my report.txt" → Content-Disposition: attachment; filename="my report.txt"
    filename="a\"b.txt"     → Content-Disposition: attachment; filename="a\"b.txt"
    filename="a\\b.txt"     → Content-Disposition: attachment; filename="a\\b.txt"
    filename="报告.txt"       → Content-Disposition: attachment; filename*=UTF-8''%E6%8A%A5%E5%91%8A.txt
    filename="中 文.txt"      → Content-Disposition: attachment; filename*=UTF-8''%E4%B8%AD+%E6%96%87.txt
    filename="résumé.txt"   → Content-Disposition: attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.txt

纯 ASCII 文件名用 filename="…"(转义引号与反斜杠);含非 ASCII 用 RFC 5987 的 filename*=UTF-8''…,但编码用的是 url.QueryEscape,空格变成 + 而不是 %20,带空格的中文文件名在浏览器里可能显示成加号。需要时自己拼 Content-Disposition。

用户给的路径

实测 · 本机执行输出
    GET /q?f=a.txt               → 200 "012345678901234567890123…"
    GET /q?f=../secret.txt       → 200 "OUTSIDE-THE-ROOT"
    GET /q?f=..%2fsecret.txt     → 200 "OUTSIDE-THE-ROOT"
    GET /p/a.txt                 → 200 "012345678901234567890123…"
    GET /p/../secret.txt         → 400 "invalid URL path"
    GET /p/..%2fsecret.txt       → 400 "invalid URL path"
    GET /safe?f=a.txt            → 200 "012345678901234567890123…"
    GET /safe?f=../secret.txt    → 404 "404 page not found"

ServeFile 只检查请求 URL 的路径里有没有 ..,并不检查你传给它的文件名。把查询参数拼进路径,../ 就能读到根目录之外的文件。写法是 filepath.Join(root, filepath.Clean("/"+f)),让 Clean 先把 .. 吃掉;更好的做法是用 ID 查文件,不接受路径。

大文件

实测 · 本机执行输出
    /file    → 200 收到 200 MiB Content-Length=209715200 X-Extra="";进程累计分配增加 0.7 MiB
    /data    → 200 收到 200 MiB Content-Length=209715200 X-Extra="";进程累计分配增加 200.1 MiB
    /reader  → 200 收到 200 MiB Content-Length=209715200 X-Extra="yes";进程累计分配增加 0.0 MiB

c.File 和 DataFromReader 都是流式发送,累计分配几乎为零;先 ReadFile 再 c.Data 会把整个文件读进内存,并发下载时内存与文件大小成正比。DataFromReader 要自己提供 Content-Length,也不支持 Range。

分步演示

文件下载与 Range
本步的对象

    本文用到的工具与边界

    • 本机 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,数字每次运行会有小幅抖动。用真实的 httptest.NewServer;Range 请求用 http.Transport.RoundTrip 手工构造,避免客户端自动处理;文件是实验时在程序目录里创建的(200 MiB 的稀疏文件,实验后删除);累计分配用 runtime.ReadMemStats 的 TotalAlloc 差,每种方式各一次
    • 没有验证:浏览器对 filename* 里 + 的显示(只验证了服务端输出);并发 Range 下载的性能;Windows 路径的处理;符号链接与 c.File 的关系;sendfile 零拷贝是否真的被使用(只测了 Go 层面的分配);HTTP/2 下的 Range
    京ICP备2026067502号-1京公网安备11010502063487号