审计修复:后端(安全 / 数据一致性 / 性能)
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:
+4
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 指过来,
|
||||
// 未设置时跳过(与项目里其它按环境变量门控的测试一致)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 返回用户的两步验证状态。
|
||||
|
||||
Reference in New Issue
Block a user