审计修复:后端(安全 / 数据一致性 / 性能)
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:
+81
-21
@@ -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: ¬es.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: ¬es.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 {
|
||||
|
||||
Reference in New Issue
Block a user