package web import ( "net/url" "os" "path/filepath" "regexp" "strings" "testing" ) // S9 回归:下载文件名不能从引号里「逃出去」。 // // 导出包名里带着 `datasets.original_name`,也就是上传时的文件名。它原样拼进 // `filename="…"` 时,一个 `"` 就能提前闭合引号、把后面的内容变成新的头参数 // (`x"; filename="evil.exe`)。net/http 只把 CR/LF 换成空格,引号它不管。 func TestDownloadDispositionCannotEscapeTheHeader(t *testing.T) { // 结构:attachment; filename="<纯 ASCII 安全串>"; filename*=UTF-8''<百分号编码> // 集合里必须带上 `-`:attr-char 允许它,而日期段 `-export-20260921` 全是它。 shape := regexp.MustCompile(`^attachment; filename="([^"]*)"; filename\*=UTF-8''([-A-Za-z0-9._~%]*)$`) cases := []struct { in string want string // 期望 filename* 解回来的原名;空表示与 in 相同 }{ {`普通名字-export-20260921.rondbackup`, ""}, {`a"b.rondbackup`, ""}, {`a"; filename="evil.exe`, ""}, {"换\n行\r.rondbackup", ""}, {`..%2f..%2fetc%2fpasswd`, ""}, {`C:\Windows\system32\x.rondbackup`, ""}, {`a;b=c:d*e?fh|i.rondbackup`, ""}, {`日本語のバックアップ.rondbackup`, ""}, {"", "download"}, {" ", "download"}, } for _, c := range cases { got := downloadDisposition(c.in) if strings.ContainsAny(got, "\r\n") { t.Errorf("%q → 头部里出现了裸的换行: %q", c.in, got) } if n := strings.Count(got, `"`); n != 2 { t.Errorf("%q → 引号有 %d 个(必须恰好一对,多了就能逃逸): %q", c.in, n, got) } m := shape.FindStringSubmatch(got) if m == nil { t.Errorf("%q → 头部形状不对: %q", c.in, got) continue } if strings.ContainsAny(m[1], `"\\/:;:=*?<>|`) { t.Errorf("%q → filename= 里还有危险字符: %q", c.in, m[1]) } // 非 ASCII 名字才需要 filename* 兜底;这里统一验「能解回原名」 back, err := url.PathUnescape(m[2]) if err != nil { t.Fatalf("%q → filename*= 不是合法的百分号编码: %v", c.in, err) } want := c.want if want == "" { want = c.in } if back != want { t.Errorf("%q → filename*= 解回来是 %q,期望 %q", c.in, back, want) } } } // 纯中文名会把 filename= 整串换成下划线,此时必须保留扩展名, // 否则用户下到手里是个没法双击打开的「download」。 func TestDownloadDispositionKeepsExtensionOnNonASCII(t *testing.T) { got := downloadDisposition("足迹备份.rondbackup") if !strings.Contains(got, `.rondbackup"`) { t.Errorf("非 ASCII 名的 ASCII 兜底丢了扩展名: %q", got) } if !strings.HasPrefix(got, `attachment; filename="download`) { t.Errorf("非 ASCII 名应当退到可读的兜底名: %q", got) } } // S9 静态守卫:所有 Content-Disposition 都必须走 downloadDisposition, // 不许再手拼 f-string——手拼就是这次的成因,而且它只在特定文件名下出问题。 func TestAllContentDispositionGoThroughHelper(t *testing.T) { dir := pkgFile(t, "") entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("读包目录失败: %v", err) } scanned, found := 0, 0 for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") { continue } scanned++ data, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { t.Fatalf("读 %s 失败: %v", e.Name(), err) } src := string(data) for i := 0; ; { j := strings.Index(src[i:], `"Content-Disposition"`) if j < 0 { break } j += i found++ // 取一小段窗口看这次 Set 是不是用的助手 win := src[j:] if len(win) > 200 { win = win[:200] } if !strings.Contains(win, "downloadDisposition(") { line := 1 + strings.Count(src[:j], "\n") t.Errorf("%s:%d 的 Content-Disposition 没有走 downloadDisposition:%s", e.Name(), line, strings.TrimSpace(strings.SplitN(win, "\n", 2)[0])) } i = j + 1 } } if scanned < 5 { t.Fatalf("只扫了 %d 个源文件,守卫形同虚设", scanned) } if found < 3 { t.Fatalf("只找到 %d 处 Content-Disposition(期望至少 3 处),守卫判据需要更新", found) } }