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

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
+4 -10
View File
@@ -169,7 +169,7 @@ func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) {
return &u, nil
}
// 会话令牌 = expiry|uid|hmac(uid|expiry|口令摘要前缀),口令变更即自动失效。
// 会话令牌 = uid|expiry|hmac(uid|expiry|口令摘要),口令变更即自动失效。
func (s *Store) SignToken(userID int64, hash string) string {
exp := time.Now().AddDate(0, 0, s.Days).Unix()
payload := fmt.Sprintf("%d|%d", userID, exp)
@@ -195,14 +195,8 @@ func (s *Store) ParseToken(token, hash string) (int64, bool) {
func (s *Store) mac(payload, hash string) string {
m := hmac.New(sha256.New, s.Secret)
m.Write([]byte(payload))
// 混入口令摘要,改密后旧 cookie 立即作废
m.Write([]byte("|" + hash[:min(len(hash), 16)]))
// 绑完整口令摘要:摘要里带随机 salt,改密后必然不同,旧 cookie 当场作废。
// 别只取摘要前几位——PBKDF2 编码的前缀是固定格式,等于绑了个常量。
m.Write([]byte("\x00" + hash))
return b64(m.Sum(nil))
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+24
View File
@@ -12,6 +12,30 @@ import (
_ "github.com/jackc/pgx/v5/stdlib"
)
// TestSessionTokenBindsPasswordHash 钉住「改密后旧会话立即作废」这条契约。
// 令牌的 MAC 必须真的绑上口令摘要:曾经绑的是摘要的前 16 个字符,而 PBKDF2
// 编码形如 `pbkdf2$sha256$210000$<salt>$<dk>`,前 16 字节恒为 `pbkdf2$sha256$21`
// ——等于绑了个常量,改密后别的设备上的旧 cookie 照样能用。
// 不碰数据库:SignToken/ParseToken 只用到 Secret 与 Days。
func TestSessionTokenBindsPasswordHash(t *testing.T) {
st := &Store{Secret: []byte("test-secret"), Days: 7}
before, err := HashPassword("old-password-123456")
if err != nil {
t.Fatal(err)
}
after, err := HashPassword("new-password-123456")
if err != nil {
t.Fatal(err)
}
tok := st.SignToken(1, before)
if _, ok := st.ParseToken(tok, before); !ok {
t.Fatal("用签名时的口令摘要应能验签")
}
if _, ok := st.ParseToken(tok, after); ok {
t.Error("改密后旧会话仍验签通过:令牌 MAC 没有真的绑上口令摘要")
}
}
// TestChangeUsername 覆盖改名的正向路径、口令校验与占用冲突。
// 会临时建一个用户并在结束时删掉,所以需要一个可连的库:用 ROND_DSN 指过来,
// 未设置时跳过(与项目里其它按环境变量门控的测试一致)。
+47
View File
@@ -0,0 +1,47 @@
package auth
import (
"testing"
"time"
)
// TestCodeReplayRejectsReuseInWindow 同一个码在窗口内只能用一次。
func TestCodeReplayRejectsReuseInWindow(t *testing.T) {
r := NewCodeReplay()
base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
r.now = func() time.Time { return base }
if !r.Consume(7, "123456") {
t.Fatal("首次使用应当通过")
}
if r.Consume(7, "123456") {
t.Error("同一个码被重放了")
}
// 换个用户号的同一个数字码不受影响
if !r.Consume(8, "123456") {
t.Error("不同用户的同一个码不该互相影响")
}
}
// TestCodeReplayExpiresAfterWindow 条目只活过一个接受窗口(±1 步 = 90 秒),
// 过期后同一个码可以再被收下——否则条目会越攒越多,且数字复用会误伤。
func TestCodeReplayExpiresAfterWindow(t *testing.T) {
r := NewCodeReplay()
now := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
r.now = func() time.Time { return now }
if !r.Consume(7, "123456") {
t.Fatal("首次使用应当通过")
}
now = now.Add(replayTTL - time.Second)
if r.Consume(7, "123456") {
t.Error("窗口内不该放行")
}
now = now.Add(2 * time.Second) // 越过 replayTTL
if !r.Consume(7, "123456") {
t.Error("过期后应当放行")
}
if n := len(r.used); n != 1 {
t.Errorf("过期条目没被清掉,还剩 %d 条", n)
}
}
+128
View File
@@ -0,0 +1,128 @@
// 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
}
+147
View File
@@ -0,0 +1,147 @@
package auth
import (
"strings"
"testing"
"time"
)
// TestGuardLocksAfterThreshold 前几次手滑不该被罚,到阈值才开始锁。
func TestGuardLocksAfterThreshold(t *testing.T) {
g := NewGuard()
base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
g.now = func() time.Time { return base }
for i := 1; i < guardFreeTries; i++ {
if wait := g.Fail("admin"); wait != 0 {
t.Fatalf("第 %d 次失败就被锁了 %v,阈值应当是 %d", i, wait, guardFreeTries)
}
if d := g.RetryAfter("admin"); d != 0 {
t.Fatalf("第 %d 次失败后不该有等待时间,实际 %v", i, d)
}
}
wait := g.Fail("admin")
if wait <= 0 {
t.Fatalf("第 %d 次失败应当上锁", guardFreeTries)
}
if wait > guardBaseLock {
t.Errorf("首次锁定时长 %v 超过了基准 %v", wait, guardBaseLock)
}
if d := g.RetryAfter("admin"); d != wait {
t.Errorf("RetryAfter=%v 与 Fail 返回的 %v 不一致", d, wait)
}
t.Logf("第 %d 次失败锁 %v", guardFreeTries, wait)
}
// TestGuardBackoffGrowsAndCaps 继续失败要退避得更久,但封顶。
func TestGuardBackoffGrowsAndCaps(t *testing.T) {
g := NewGuard()
base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
g.now = func() time.Time { return base }
var prev time.Duration
for i := 0; i < guardFreeTries+12; i++ {
wait := g.Fail("admin")
if wait < prev {
t.Fatalf("第 %d 次失败的等待时间 %v 比上一次 %v 还短", i+1, wait, prev)
}
if wait > guardMaxLock {
t.Fatalf("第 %d 次失败锁了 %v,超过上限 %v", i+1, wait, guardMaxLock)
}
prev = wait
}
if prev != guardMaxLock {
t.Errorf("连续失败后应当封顶在 %v,实际 %v", guardMaxLock, prev)
}
}
// TestGuardReleasesAfterWait 时间走够之后要放行,否则就是永久锁死自己。
func TestGuardReleasesAfterWait(t *testing.T) {
g := NewGuard()
base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
now := base
g.now = func() time.Time { return now }
var wait time.Duration
for i := 0; i < guardFreeTries; i++ {
wait = g.Fail("admin")
}
if wait <= 0 {
t.Fatal("到阈值却没上锁")
}
now = base.Add(wait - time.Millisecond)
if d := g.RetryAfter("admin"); d <= 0 {
t.Errorf("还差 1ms 到期,应当仍在锁内,实际 %v", d)
}
now = base.Add(wait)
if d := g.RetryAfter("admin"); d != 0 {
t.Errorf("到期后应当放行,实际还要等 %v", d)
}
}
// TestGuardSucceedClears 登录成功要把计数清零,别让几天前的失败把下次正常登录挡住。
func TestGuardSucceedClears(t *testing.T) {
g := NewGuard()
base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
g.now = func() time.Time { return base }
for i := 0; i < guardFreeTries; i++ {
g.Fail("admin")
}
if g.RetryAfter("admin") <= 0 {
t.Fatal("应当处于锁定状态")
}
g.Succeed("admin")
if d := g.RetryAfter("admin"); d != 0 {
t.Errorf("成功后不该还有等待时间,实际 %v", d)
}
// 清零之后又要重新攒满阈值才会锁
for i := 1; i < guardFreeTries; i++ {
if wait := g.Fail("admin"); wait != 0 {
t.Errorf("清零后第 %d 次失败不该上锁,实际 %v", i, wait)
}
}
}
// TestGuardSeparatesKeys 不同用户名各算各的。
func TestGuardSeparatesKeys(t *testing.T) {
g := NewGuard()
base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
g.now = func() time.Time { return base }
for i := 0; i < guardFreeTries; i++ {
g.Fail("admin")
}
if g.RetryAfter("admin") <= 0 {
t.Fatal("admin 应当被锁")
}
if d := g.RetryAfter("someone-else"); d != 0 {
t.Errorf("另一个用户名不该受影响,实际 %v", d)
}
}
// TestRandomPassword 确认初始口令有足够熵、字符集不含易混字符。
func TestRandomPassword(t *testing.T) {
const n = 16
a, err := RandomPassword(n)
if err != nil {
t.Fatal(err)
}
b, err := RandomPassword(n)
if err != nil {
t.Fatal(err)
}
if len(a) != n || len(b) != n {
t.Fatalf("长度不对: %d / %d(应为 %d)", len(a), len(b), n)
}
if a == b {
t.Error("两次生成的随机口令相同")
}
if strings.ContainsAny(a, "0O1Il") {
t.Errorf("口令含易混字符: %s", a)
}
if _, err := RandomPassword(0); err != nil {
t.Errorf("长度 0 不该报错: %v", err)
}
}
+39
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"strings"
"sync"
"time"
)
@@ -98,6 +99,44 @@ func urlEscape(s string) string {
return b.String()
}
// ---------- 验证码重放 ----------
// replayTTL 要盖过 VerifyTOTP 的整个接受窗口(前后各一步 = 90 秒),
// 否则同一步长的码会在条目过期后又被收下。
const replayTTL = 3 * totpStep
// CodeReplay 记住最近用过的验证码,拦住「同一个码在窗口内被用第二次」。
// VerifyTOTP 只判断「这个码此刻对不对」,不含一次性语义;被偷看到或被日志
// 截获的码,在它过期前都能再换一个会话。
type CodeReplay struct {
mu sync.Mutex
used map[string]time.Time
now func() time.Time
}
func NewCodeReplay() *CodeReplay {
return &CodeReplay{used: map[string]time.Time{}, now: time.Now}
}
// Consume 在验证码**已经校验通过**之后登记一次使用;已被用过则返回 false。
// 键里带用户号:不同账号的同一个数字码互不影响。
func (r *CodeReplay) Consume(userID int64, code string) bool {
now := r.now()
key := fmt.Sprintf("%d|%s", userID, code)
r.mu.Lock()
defer r.mu.Unlock()
for k, t := range r.used {
if now.Sub(t) > replayTTL {
delete(r.used, k)
}
}
if _, ok := r.used[key]; ok {
return false
}
r.used[key] = now
return true
}
// ---------- 用户秘钥存取 ----------
// TOTPState 返回用户的两步验证状态。