审计修复:后端(安全 / 数据一致性 / 性能)
audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证 (注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。 安全 - CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板 - 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性) - 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段 - GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition - 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品 数据一致性 - 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐) - Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error - 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致 - 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows - 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全 性能 - placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛 - 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍 另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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?f<g>h|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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user