审计修复:后端(安全 / 数据一致性 / 性能)
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:
@@ -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", "轨迹已删除")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user