Files
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

129 lines
3.3 KiB
Go

// Package auth 的登录失败短锁。
package auth
import (
"crypto/rand"
"math/big"
"sync"
"time"
)
// Guard 拦登录爆破:按用户名累计失败次数,到阈值后按指数退避短暂锁住。
//
// 键只用用户名、**不带 IP**——clientIP 优先信 X-Forwarded-For 的第一段,
// 而那是客户端可伪造的,拿它当键就等于「每换一个请求头换一把新锁」。
// 本站只有一个账号(启动时创建,没有注册入口),用户名是稳定的,
// 所以它才是可靠的爆破计数键。
//
// 状态在内存里:站点是单进程部署,重启即清空,代价可接受。
type Guard struct {
mu sync.Mutex
items map[string]entry
now func() time.Time // 测试注入用
}
type entry struct {
fails int
until time.Time // 锁到什么时候
seen time.Time
}
const (
// guardFreeTries 是阈值:前几次失败不锁,正常手滑不该被罚。
guardFreeTries = 5
// guardBaseLock 是刚过阈值时的锁定时长,之后每次失败翻倍。
guardBaseLock = 2 * time.Second
guardMaxLock = 15 * time.Minute
// guardMaxKeys 与 guardIdleTTL 只管内存:有人拿随机用户名刷接口时,
// 记录不能无限长下去。
guardMaxKeys = 1024
guardIdleTTL = time.Hour
)
// NewGuard 建一个空白的失败计数器。
func NewGuard() *Guard {
return &Guard{items: map[string]entry{}, now: time.Now}
}
// RetryAfter 返回该键还要等多久才能再试;0 表示可以试。
func (g *Guard) RetryAfter(key string) time.Duration {
g.mu.Lock()
defer g.mu.Unlock()
return g.retryAfterLocked(key)
}
func (g *Guard) retryAfterLocked(key string) time.Duration {
e, ok := g.items[key]
if !ok {
return 0
}
if d := e.until.Sub(g.now()); d > 0 {
return d
}
return 0
}
// Fail 记一次失败,返回这次之后需要等待的时长(0 表示还没到阈值)。
func (g *Guard) Fail(key string) time.Duration {
g.mu.Lock()
defer g.mu.Unlock()
g.pruneLocked()
e := g.items[key]
e.fails++
e.seen = g.now()
if e.fails >= guardFreeTries {
// 第 5 次失败锁 2 秒,第 6 次 4 秒……封顶 15 分钟
lock := guardBaseLock << uint(e.fails-guardFreeTries)
if lock > guardMaxLock || lock <= 0 {
lock = guardMaxLock
}
e.until = g.now().Add(lock)
}
g.items[key] = e
return g.retryAfterLocked(key)
}
// Succeed 登录成功就清掉计数:口令是对的,之前的失败不再累积到下次。
func (g *Guard) Succeed(key string) {
g.mu.Lock()
delete(g.items, key)
g.mu.Unlock()
}
// Reset 清空全部计数(测试与本地排查用)。
func (g *Guard) Reset() {
g.mu.Lock()
g.items = map[string]entry{}
g.mu.Unlock()
}
func (g *Guard) pruneLocked() {
now := g.now()
for k, v := range g.items {
if now.Sub(v.seen) > guardIdleTTL {
delete(g.items, k)
}
}
for k := range g.items {
if len(g.items) < guardMaxKeys {
break
}
delete(g.items, k)
}
}
// RandomPassword 生成 n 位随机口令,字符集去掉了容易看错的 0/O/1/I/L。
// 用于「没配 ROND_ADMIN_PASSWORD 时别再写死出厂口令」。
func RandomPassword(n int) (string, error) {
const alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
var b = make([]byte, n)
for i := range b {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
return "", err
}
b[i] = alphabet[idx.Int64()]
}
return string(b), nil
}