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

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
+81 -21
View File
@@ -90,6 +90,11 @@ type Server struct {
masks maskCache
// captcha 存登录验证码的答案(内存、一次性、带过期)
captcha *captcha.Store
// loginGuard 是登录失败的短锁(按用户名指数退避),验证码之外的又一道闸。
// 两步验证那一步也用同一个实例,但键带 "2fa:" 前缀,两边计数互不干扰。
loginGuard *auth.Guard
// totpReplay 记住最近用过的验证码:同一个码在 ±1 步窗口内只能换一次会话
totpReplay *auth.CodeReplay
// media 管地点图片写哪(本地磁盘 / 对象存储)与怎么取址
media *media.Store
}
@@ -106,21 +111,23 @@ func New(cfg *config.Config, db *sql.DB) (*Server, error) {
pages[key] = pageSet{t: t, entry: def.entry}
}
s := &Server{
cfg: cfg,
db: db,
q: stats.DB{DB: db},
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
sets: &setting.Store{DB: db},
notes: &notes.Store{DB: db},
votes: &votes.Store{DB: db},
pages: pages,
mux: http.NewServeMux(),
cookie: "rond_session",
tiles: buildTileSet(cfg),
tileCache: newTileCache(1024),
fogTiles: newTileCache(512),
captcha: captcha.NewStore(10 * time.Minute),
media: media.New(cfg),
cfg: cfg,
db: db,
q: stats.DB{DB: db},
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
sets: &setting.Store{DB: db},
notes: &notes.Store{DB: db},
votes: &votes.Store{DB: db},
pages: pages,
mux: http.NewServeMux(),
cookie: "rond_session",
tiles: buildTileSet(cfg),
tileCache: newTileCache(1024),
fogTiles: newTileCache(512),
captcha: captcha.NewStore(10 * time.Minute),
loginGuard: auth.NewGuard(),
totpReplay: auth.NewCodeReplay(),
media: media.New(cfg),
}
s.routes()
return s, nil
@@ -142,7 +149,7 @@ func (s *Server) guardPage(nav string, next http.HandlerFunc) http.Handler {
}
func (s *Server) Handler() http.Handler {
return s.recover(gzipHandler(s.logRequests(s.withUser(s.mux))))
return s.recover(gzipHandler(s.logRequests(s.withUser(s.csrfOriginGuard(s.mux)))))
}
// cacheStatic 给带了当前版本号的静态资源加长期强缓存。
@@ -314,15 +321,42 @@ func (s *Server) logRequests(next http.Handler) http.Handler {
})
}
// headerSent 记录响应首部是否已经提交,供 recover 判断「还能不能写错误页」。
// 首部一旦提交,http.Error 只能再 WriteHeader 一次(Go 记一条 superfluous
// response.WriteHeader)并把错误体追加到半个页面后面,比不写更糟。
type headerSent struct {
http.ResponseWriter
sent bool
}
func (h *headerSent) WriteHeader(code int) {
h.sent = true
h.ResponseWriter.WriteHeader(code)
}
func (h *headerSent) Write(b []byte) (int, error) {
h.sent = true
return h.ResponseWriter.Write(b)
}
// Unwrap 让 http.NewResponseController 能穿过这层拿到真正的 ResponseWriter
// (SetWriteDeadline / Flush / Hijack 都靠逐层解包)。
func (h *headerSent) Unwrap() http.ResponseWriter { return h.ResponseWriter }
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hw := &headerSent{ResponseWriter: w}
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %s %s: %v", r.Method, r.URL.Path, rec)
http.Error(w, "服务器内部错误", http.StatusInternalServerError)
if hw.sent {
// 已经发出去了,再写错误页只会污染响应;真正的补救是断开连接。
return
}
http.Error(hw, "服务器内部错误", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
next.ServeHTTP(hw, r)
})
}
@@ -371,19 +405,39 @@ func userFrom(ctx context.Context) *auth.User {
return u
}
func (s *Server) login(w http.ResponseWriter, userID int64, hash string) {
// cookieSecure 判断这次响应能不能给会话 cookie 打 Secure。
//
// 不能无条件打:本机 http 调试、以及本仓库的 CDP 验证流程都跑在 http 上,
// 打了 Secure 浏览器就不回传 cookie,等于谁都登不进去。所以按「这次请求是不是
// 走的 https」判断:直连看 r.TLS,在反向代理后面看 X-Forwarded-Proto(只取首段)。
func cookieSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
proto := r.Header.Get("X-Forwarded-Proto")
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = proto[:i]
}
return strings.EqualFold(strings.TrimSpace(proto), "https")
}
func (s *Server) login(w http.ResponseWriter, r *http.Request, userID int64, hash string) {
http.SetCookie(w, &http.Cookie{
Name: s.cookie,
Value: s.auth.SignToken(userID, hash),
Path: "/",
HttpOnly: true,
Secure: cookieSecure(r),
SameSite: http.SameSiteLaxMode,
MaxAge: s.cfg.SessionDays * 86400,
})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: s.cookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.SetCookie(w, &http.Cookie{
Name: s.cookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: cookieSecure(r),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
@@ -444,9 +498,15 @@ func (s *Server) json(w http.ResponseWriter, v any) {
}
}
// serverError 统一处理内部错误。详情只回给已登录的站长:公开访客页把 err.Error()
// 原样回显,等于把 SQL 错误里的表结构、列名、以及磁盘路径直接送到门外。
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("处理 %s 出错: %v", r.URL.Path, err)
http.Error(w, "服务器内部错误: "+err.Error(), http.StatusInternalServerError)
msg := "服务器内部错误,请稍后重试"
if userFrom(r.Context()) != nil {
msg = "服务器内部错误: " + err.Error()
}
http.Error(w, msg, http.StatusInternalServerError)
}
func atoiOr(s string, def int) int {