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

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
+78 -26
View File
@@ -62,6 +62,15 @@ func (s *Server) checkCaptcha(r *http.Request) bool {
return s.captcha.Verify(c.Value, r.PostFormValue("captcha"))
}
// tooManyTries 把等待时长写成一句人话。向上取整,免得显示「请 0 秒后再试」。
func tooManyTries(wait time.Duration) string {
sec := int(wait/time.Second) + 1
if sec < 60 {
return fmt.Sprintf("登录尝试过于频繁,请 %d 秒后再试", sec)
}
return fmt.Sprintf("登录尝试过于频繁,请 %d 分钟后再试", (sec+59)/60)
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
sets, err := s.sets.Load(r.Context())
if err != nil {
@@ -99,20 +108,33 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
return
}
// 爆破短锁:验证码之后还有一道,连错到阈值就按用户名指数退避
if wait := s.loginGuard.RetryAfter(username); wait > 0 {
sets, _ := s.sets.Load(ctx)
s.renderStatus(w, http.StatusTooManyRequests, "admin/login", LoginData{
Settings: sets, Err: tooManyTries(wait), Next: next,
})
return
}
u, err := s.auth.Authenticate(ctx, username, password)
if err != nil {
sets, _ := s.sets.Load(ctx)
code := http.StatusOK
if !errors.Is(err, auth.ErrInvalidCredentials) {
code = http.StatusInternalServerError
}
msg := "用户名或密码错误"
if code == http.StatusInternalServerError {
msg = "登录失败:" + err.Error()
if !errors.Is(err, auth.ErrInvalidCredentials) {
// 登录页是公开的,内部错误只进日志,不回显给门外的人
log.Printf("登录失败 %s: %v", username, err)
code = http.StatusInternalServerError
msg = "登录失败,请稍后重试"
} else if wait := s.loginGuard.Fail(username); wait > 0 {
code = http.StatusTooManyRequests
msg = tooManyTries(wait)
}
s.renderStatus(w, code, "admin/login", LoginData{Settings: sets, Err: msg, Next: next})
return
}
s.loginGuard.Succeed(username)
// 已开启两步验证:口令过关只算第一步,签发短时中间令牌进入验证码页
if _, enabled, _ := s.auth.TOTPState(ctx, u.ID); enabled {
sets, _ := s.sets.Load(ctx)
@@ -121,9 +143,10 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
})
return
}
s.login(w, u.ID, u.Hash)
s.login(w, r, u.ID, u.Hash)
target := "/admin"
if strings.HasPrefix(next, "/admin") {
// 同样先过站内路径判据(挡控制字符/引号),再限定必须落在 /admin 下
if next = safeInternalPath(next); strings.HasPrefix(next, "/admin") {
target = next
}
http.Redirect(w, r, target, http.StatusSeeOther)
@@ -202,6 +225,15 @@ func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) {
}
stored := filepath.Join(s.cfg.UploadDir, fmt.Sprintf("%d-%s", time.Now().Unix(), sanitize(name)))
// 落盘之后每条失败路径都要把半成品删掉:否则反复上传坏包会在 uploads 里堆垃圾。
// 只有「新导入成功」那一支会 keepStored = true —— 那份上传件要留给导出当基线源
// (findSourceArchive 就是按 datasets.sha256 在 uploads 里找它)。
keepStored := false
defer func() {
if !keepStored {
os.Remove(stored)
}
}()
out, err := os.Create(stored)
if err != nil {
s.serverError(w, r, err)
@@ -265,6 +297,7 @@ func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) {
// 否则 rond 原生的标志位(ZPINSTYLE_/ZRADIUS/ZVISIT.ZRAW…)会被洗成 NULL,越导越失真。
// 老数据集缺基线的情况,交给 ensureBaseline 按当初上传的原始包重建。
if !st.Identical {
keepStored = true
if err := rond.WriteBaseline(stored, s.baselinePath(datasetID)); err != nil {
log.Printf("写入基线库失败(导出 rondbackup 时需重新上传原始备份): %v", err)
}
@@ -866,19 +899,15 @@ func (s *Server) adminNotesBulk(w http.ResponseWriter, r *http.Request) {
var msg string
switch action {
case "delete":
for _, id := range ids {
if err := s.notes.Delete(r.Context(), u.ID, ds.ID, int(id)); err != nil {
s.serverError(w, r, err)
return
}
if err := s.notes.DeleteBulk(r.Context(), u.ID, ds.ID, ids); err != nil {
s.serverError(w, r, err)
return
}
msg = fmt.Sprintf("已删除 %d 个地点的备注与别名", len(ids))
case "clear":
for _, id := range ids {
if err := s.notes.SetNote(r.Context(), u.ID, ds.ID, int(id), ""); err != nil {
s.serverError(w, r, err)
return
}
if err := s.notes.SetNoteBulk(r.Context(), u.ID, ds.ID, ids); err != nil {
s.serverError(w, r, err)
return
}
msg = fmt.Sprintf("已清空 %d 个地点的备注(保留别名)", len(ids))
case "verdict":
@@ -886,11 +915,9 @@ func (s *Server) adminNotesBulk(w http.ResponseWriter, r *http.Request) {
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0
}
for _, id := range ids {
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, int(id), verdict); err != nil {
s.serverError(w, r, err)
return
}
if err := s.votes.SaveVerdictBulk(r.Context(), u.ID, ds.ID, ids, verdict); err != nil {
s.serverError(w, r, err)
return
}
if verdict == 0 {
msg = fmt.Sprintf("已取消 %d 个地点的结论", len(ids))
@@ -941,7 +968,7 @@ func (s *Server) adminPlaceNote(w http.ResponseWriter, r *http.Request) {
}
// 备注管理页的行内表单带 next,保存后回到原处(保留筛选);
// 没有 next 时按 Referer 判断,都没有就回后台首页。
if next := r.PostFormValue("next"); strings.HasPrefix(next, "/") {
if next := safeInternalPath(r.PostFormValue("next")); next != "" {
sep := "?"
if strings.Contains(next, "?") {
sep = "&"
@@ -1000,13 +1027,31 @@ func (s *Server) login2FA(w http.ResponseWriter, r *http.Request) {
fail("两步验证状态异常,请重新登录")
return
}
// 两步验证也要限速:中间令牌有 5 分钟有效期,不限速的话 6 位码在窗口内
// 足够被打穿。键带前缀,与「口令那一环」分开计数。
guardKey := "2fa:" + u.Username
if wait := s.loginGuard.RetryAfter(guardKey); wait > 0 {
fail(tooManyTries(wait))
return
}
if !auth.VerifyTOTP(secret, code) {
if wait := s.loginGuard.Fail(guardKey); wait > 0 {
fail(tooManyTries(wait))
return
}
fail("验证码不正确")
return
}
s.login(w, u.ID, u.Hash)
// 同一个码在有效窗口内只用一次,否则被偷看的码可以再换一个会话。
// 这一条不计入爆破计数:用户手抖重复提交也会撞上,不该因此被越锁越久。
if !s.totpReplay.Consume(u.ID, code) {
fail("这个验证码刚用过,请等下一个")
return
}
s.loginGuard.Succeed(guardKey)
s.login(w, r, u.ID, u.Hash)
target := "/admin"
if strings.HasPrefix(next, "/admin") {
if next = safeInternalPath(next); strings.HasPrefix(next, "/admin") {
target = next
}
http.Redirect(w, r, target, http.StatusSeeOther)
@@ -1174,15 +1219,22 @@ func (s *Server) adminGpxUpload(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) adminGpxDelete(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64)
if err != nil {
s.adminFlash(w, r, "err", "轨迹编号无效")
return
}
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM gpx_tracks WHERE id=$1`, id); err != nil {
// 带上 user_id:这个 id 是客户端传来的,只按 id 删就能删掉别人的轨迹。
res, err := s.db.ExecContext(r.Context(), `DELETE FROM gpx_tracks WHERE id=$1 AND user_id=$2`, id, u.ID)
if err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
s.adminFlash(w, r, "err", "轨迹不存在")
return
}
s.adminFlash(w, r, "ok", "轨迹已删除")
}