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

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
+138
View File
@@ -0,0 +1,138 @@
package web
import (
"fmt"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
)
// sliceFunc 取出 src 里某个函数的函数体(从签名到下一个顶层 func 之前)。
// 找不到直接 Fatal——否则静态守卫会因为「切片切空了」而假绿。
func sliceFunc(t *testing.T, src, sig string) string {
t.Helper()
i := strings.Index(src, sig)
if i < 0 {
t.Fatalf("源码里找不到 %s,静态守卫需要同步更新", sig)
}
body := src[i:]
if j := strings.Index(body[len(sig):], "\nfunc "); j >= 0 {
body = body[:len(sig)+j]
}
if len(body) < 200 {
t.Fatalf("%s 的函数体只切到 %d 字节,切片判据失效", sig, len(body))
}
return body
}
// D5 回归(静态):收藏切换必须是「一条语句翻转并回读」。
//
// 以前是 UPDATE 之后再单独 SELECT 回读,那次回读一旦失败就落进 else 报「已取消收藏」——
// 状态根本没读出来,这句话有一半是错的。换成 UPDATE ... RETURNING 后没有第二次读,
// 也就没有「读失败只能猜」这个分支。
//
// 为什么只能静态守:要让那条独立回读失败得先有故障注入(本项目还没有这套设施),
// 而正常路径下新旧写法的输出完全一样,行为测试分辨不出来。
func TestVisitBookmarkTogglesInOneStatement(t *testing.T) {
src, err := os.ReadFile(pkgFile(t, "handlers_edit.go"))
if err != nil {
t.Fatalf("读 handlers_edit.go 失败: %v", err)
}
body := sliceFunc(t, string(src), "func (s *Server) adminVisitBookmark(")
if !strings.Contains(body, "RETURNING bookmarked") {
t.Error("收藏切换应当用 UPDATE ... RETURNING 一条语句翻转并回读")
}
if strings.Contains(body, "SELECT bookmarked") {
t.Error("不该再有独立的回读语句:它失败时无法知道状态,只能猜")
}
if strings.Contains(body, "err == nil && on") {
t.Error("不许把「读失败」和「读到 false」合成一个分支——那正是误报「已取消收藏」的写法")
}
if !strings.Contains(body, "s.serverError(w, r, err)") {
t.Error("语句失败必须按错误上报,不能伪装成成功")
}
}
// D5 回归(行为):连点两次星标要真的来回翻,且提示必须与库里的状态一致。
// 「翻过来但提示说反了」这种错,只有把提示和库内实况对起来才看得出来。
func TestVisitBookmarkFlashMatchesRow(t *testing.T) {
f := newMovementFixture(t)
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM visits
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到到访记录: %v", err)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `UPDATE visits SET bookmarked=FALSE WHERE dataset_id=$1 AND src_pk=$2`,
f.dsID, src)
})
click := func() (*httptest.ResponseRecorder, bool) {
t.Helper()
form := url.Values{"src_pk": {fmt.Sprint(src)}}
r := httptest.NewRequest("POST", "/admin/edit/visit/bookmark", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(f.ctx)
rec := httptest.NewRecorder()
f.srv.adminVisitBookmark(rec, r)
var on bool
if err := f.pg.QueryRowContext(f.ctx, `SELECT bookmarked FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, src).Scan(&on); err != nil {
t.Fatalf("读库内收藏状态失败: %v", err)
}
return rec, on
}
rec, on := click()
if !on {
t.Fatalf("点一次应当把收藏打开,库里仍是 %v", on)
}
if msg := flashMsg(t, rec); msg != "已标记为收藏" {
t.Errorf("提示 %q 与库内状态(已收藏)不一致", msg)
}
rec, on = click()
if on {
t.Fatalf("再点一次应当取消收藏,库里仍是 %v", on)
}
if msg := flashMsg(t, rec); msg != "已取消收藏" {
t.Errorf("提示 %q 与库内状态(未收藏)不一致", msg)
}
// 不存在的记录要报错,不能假装成功
form := url.Values{"src_pk": {"2147483000"}}
r := httptest.NewRequest("POST", "/admin/edit/visit/bookmark", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(f.ctx)
rec = httptest.NewRecorder()
f.srv.adminVisitBookmark(rec, r)
if msg := flashMsg(t, rec); strings.Contains(msg, "已") {
t.Errorf("对不存在的记录不该回一句成功提示: %q", msg)
}
}
// flashMsg 从重定向的 Location 里取出 flash 文案(redirectEdit 把提示挂在查询串上)。
func flashMsg(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
loc := rec.Header().Get("Location")
if loc == "" {
t.Fatalf("没有重定向,实际状态 %d:%s", rec.Code, firstLine(rec.Body.String()))
}
u, err := url.Parse(loc)
if err != nil {
t.Fatalf("解析 Location 失败: %v", err)
}
msg := u.Query().Get("ok")
if msg == "" {
msg = u.Query().Get("err")
}
if msg == "" {
t.Fatalf("Location 里没有提示文案: %s", loc)
}
return msg
}