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

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
+129
View File
@@ -0,0 +1,129 @@
package web
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// mkFile 造一个指定字节数的候选文件。
func mkFile(t *testing.T, dir, name string, n int) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, bytes.Repeat([]byte("x"), n), 0o644); err != nil {
t.Fatalf("造 %s 失败: %v", name, err)
}
return p
}
// P5 回归:按摘要找回备份包时,必须先按字节数筛一遍再算摘要。
//
// 只看「最终返回哪个文件」新旧写法完全一样——真正的差别是**算了几次 sha256**,
// 而 uploads 里每份备份动辄几百 MB,多算一次就是几百 MB 的读盘。所以这里注入一个
// 会计数的 hash,把「有没有白算」直接钉住。
func TestPickArchiveSkipsWrongSizedFilesWithoutHashing(t *testing.T) {
dir := t.TempDir()
// 两个诱饵都比正主新(会排在候选前面),大小都和正主不同
d1 := mkFile(t, dir, "decoy-newest.rondbackup", 10)
d2 := mkFile(t, dir, "decoy-mid.rondbackup", 20)
right := mkFile(t, dir, "right.rondbackup", 30)
hashed := 0
got := pickArchiveBySha([]string{d1, d2, right}, "sha-right", 30, func(f string) (string, error) {
hashed++
// 按文件名给摘要:这样「选中谁」和「算了几次」互相独立——
// 去掉预筛时选中的仍然是 right,但白读了两个诱饵(hashed=3)。
return "sha-" + strings.TrimSuffix(filepath.Base(f), ".rondbackup"), nil
})
if got != right {
t.Errorf("应当选中 %s,实际 %s", filepath.Base(right), filepath.Base(got))
}
if hashed != 1 {
t.Errorf("只该对大小相符的那份算摘要,实际算了 %d 次(白读了 %d 份整包)", hashed, hashed-1)
}
}
// 反向约束:筛选只许按大小筛,不许把大小恰好相符的候选也一起跳过。
// 三份大小相同、只有最后一份摘要对得上 ⇒ 必须老老实实算三次。
func TestPickArchiveHashesEverySameSizedCandidate(t *testing.T) {
dir := t.TempDir()
a := mkFile(t, dir, "a.rondbackup", 30)
b := mkFile(t, dir, "b.rondbackup", 30)
c := mkFile(t, dir, "c.rondbackup", 30)
hashed := 0
got := pickArchiveBySha([]string{a, b, c}, "sha-c", 30, func(f string) (string, error) {
hashed++
return "sha-" + strings.TrimSuffix(filepath.Base(f), ".rondbackup"), nil
})
if got != c {
t.Errorf("应当选中 c.rondbackup,实际 %s", filepath.Base(got))
}
if hashed != 3 {
t.Errorf("大小都相符时不能跳过任何一份,实际只算了 %d 次", hashed)
}
}
// 退化路径:库里没记 sha(老数据)时不按大小筛,保持「取最新的那份」的旧行为——
// 否则会因为记不到 size 而突然一份都选不中,导出直接失败。
func TestPickArchiveKeepsNewestWhenNoSha(t *testing.T) {
dir := t.TempDir()
newest := mkFile(t, dir, "newest.rondbackup", 10)
older := mkFile(t, dir, "older.rondbackup", 999)
hashed := 0
got := pickArchiveBySha([]string{newest, older}, "", 0, func(f string) (string, error) {
hashed++
return "whatever", nil
})
if got != newest {
t.Errorf("没有 sha 时应当取第一份可读的,实际 %s", filepath.Base(got))
}
if hashed != 1 {
t.Errorf("没有 sha 时不该多算,实际算了 %d 次", hashed)
}
}
// 目录与读不到的文件要跳过,不能因为一份坏候选就整段失败。
func TestPickArchiveSkipsDirsAndUnreadable(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
t.Fatal(err)
}
missing := filepath.Join(dir, "does-not-exist.rondbackup")
ok := mkFile(t, dir, "ok.rondbackup", 5)
got := pickArchiveBySha([]string{missing, filepath.Join(dir, "subdir"), ok}, "sha-ok", 5, func(f string) (string, error) {
if f == ok {
return "sha-ok", nil
}
t.Errorf("不该对 %s 算摘要", f)
return "nope", nil
})
if got != ok {
t.Errorf("应当跳过缺失与目录后选中 ok.rondbackup,实际 %q", got)
}
// 找不到时返回空串(调用方据此报「请重新上传」)
if s := pickArchiveBySha([]string{ok}, "sha-none", 5, func(string) (string, error) { return "other", nil }); s != "" {
t.Errorf("没有命中时应返回空串,实际 %q", s)
}
}
// findSourceArchive 必须把 size_bytes 一起查出来——只查 sha 的话上面那道筛选就永远不生效,
// 而且编译器不会拦(筛挂掉、越筛越慢,行为测试看不出来)。
func TestFindSourceArchiveConsultsSizeBytes(t *testing.T) {
src, err := os.ReadFile(pkgFile(t, "handlers_export.go"))
if err != nil {
t.Fatalf("读 handlers_export.go 失败: %v", err)
}
body := sliceFunc(t, string(src), "func (s *Server) findSourceArchive(")
if !strings.Contains(body, "size_bytes") {
t.Error("findSourceArchive 没有读取 size_bytes:按大小预筛会永远拿不到目标大小")
}
if !strings.Contains(body, "pickArchiveBySha(") {
t.Error("findSourceArchive 没有走 pickArchiveBySha:按大小预筛没有生效")
}
}