审计修复:后端(安全 / 数据一致性 / 性能)

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:
tamakyi
2026-09-24 20:37:16 +08:00
parent 78865fc736
commit b4c2a8452f
70 changed files with 5417 additions and 360 deletions
+127
View File
@@ -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)
}
}