fix: 枚举入参校验(500→400)、5xx 错误串不再外泄、健康检查探库、限流键清理
服务端一批「会误导现场」的缺陷,全部补了测试: - 枚举型入参(events 的 event_type/result_mode/result_type/compare/status、 scoring_profiles 的 tie_mode/record_bonus_mode、entries.status、results.status) 过去完全没有校验,非法值一路落到 SQLite 的 CHECK 约束,被统一错误处理当成 服务端异常报成 500;现在统一在 enums.go 拦成 400 VALIDATION_FAILED, message 带字段名与合法取值、detail 带原始输入值。空值仍走原来的默认值。 - 5xx 响应体不再回显内部错误原文:新增 apiError.logDetail,SQLite / 引擎的 原始错误串只进 ERROR 日志的 detail 字段。此前正好是反的——前端拿得到原文, 日志里只有「服务端异常」。同时校准 newAPIError / internalMsg 的 skip 层数, 拦截点不再退化成 response.go 里的构造函数。 - /api/health 新增 db / dbError:用 SELECT 1 探库(Ping 探不到「文件在、库读不动」), 超时 5s 与 busy_timeout 同量级。状态码仍恒 200 —— Console 拿状态码当存活信号, 数据库故障时回 5xx 只会变成重启循环,两个事实分两个字段表达。 - 修改密码时原密码错误由 401 改为 400 AUTH_FAILED(新增 wrongCredential): 401 的语义是「本请求没有有效会话」,而这里会话是好的。 - 限流 hits map 在键数超过阈值时清理窗口内已无记录的键(窗口内活跃的键不动, 否则堆键数就能冲掉别人的计数)。 - createEntry / updateEntry 拒绝「A 小项的报名挂在 B 小项的组别上」。 数据库层仍无 (event_id, category_id) 复合外键(重建 entries 会牵动 results / score_ledger 的级联,风险高不做),这一层先兜住。 - 补测试:枚举字段逐项正反例、CSRF 反向用例、写限流 429、健康探库(含库故障)、 5xx 不外泄原文、限流键清理不误删。
This commit is contained in:
1 parent
23fee5b818
commit
2c983bcfdf
14 files changed
+1112
-21
No files matched your search
@@ -23,6 +23,13 @@ func NewLimiter(limit int, window time.Duration, now func() time.Time) *Limiter
|
||||
return &Limiter{hits: make(map[string][]time.Time), limit: limit, window: window, now: now}
|
||||
}
|
||||
|
||||
// sweepThreshold 是触发全表清理的键数量。
|
||||
//
|
||||
// hits 只在「这个键被再次访问」时才修剪自己的时间片,所以一个只来一次的
|
||||
// 随机 IP(或伪造 X-Forwarded-For 的扫描器)留下的空键会一直躺在 map 里:
|
||||
// 单机内网虽然打不满,但这个 map 没有上界本身就是隐患,超过阈值就顺手清一遍。
|
||||
const sweepThreshold = 1024
|
||||
|
||||
// Allow 记录一次访问并报告是否放行。
|
||||
func (l *Limiter) Allow(key string) bool {
|
||||
if l == nil || l.limit <= 0 {
|
||||
@@ -45,9 +52,28 @@ func (l *Limiter) Allow(key string) bool {
|
||||
return false
|
||||
}
|
||||
l.hits[key] = append(kept, now)
|
||||
if len(l.hits) > sweepThreshold {
|
||||
l.sweepLocked(cutoff)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sweepLocked 删掉窗口内已经没有任何记录的键。调用方必须已持有 l.mu。
|
||||
func (l *Limiter) sweepLocked(cutoff time.Time) {
|
||||
for key, times := range l.hits {
|
||||
live := false
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
live = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !live {
|
||||
delete(l.hits, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 清空某个键的计数,例如登录成功后不再累计失败次数。
|
||||
func (l *Limiter) Reset(key string) {
|
||||
if l == nil {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newSweepTestLimiter 造一个带可控时钟的限流器:窗口 1 分钟、限 1 次。
|
||||
// 只来一次的键(随机 IP、伪造 X-Forwarded-For 的扫描器)不会自己过期,
|
||||
// 必须靠 Allow 里的阈值清理把它们扫掉。
|
||||
func newSweepTestLimiter(clock *time.Time) *Limiter {
|
||||
return NewLimiter(1, time.Minute, func() time.Time { return *clock })
|
||||
}
|
||||
|
||||
// 键数超过阈值后,窗口内已经没有记录的键要被清掉,内存不能无界增长。
|
||||
func TestLimiterSweepsExpiredKeys(t *testing.T) {
|
||||
clock := time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC)
|
||||
l := newSweepTestLimiter(&clock)
|
||||
|
||||
for i := 0; i < sweepThreshold+1; i++ {
|
||||
l.Allow(fmt.Sprintf("10.0.0.%d", i))
|
||||
}
|
||||
if got := len(l.hits); got <= sweepThreshold {
|
||||
t.Fatalf("阈值内的键不该被清,期望超过 %d,得到 %d", sweepThreshold, got)
|
||||
}
|
||||
|
||||
// 时间推过一个窗口,所有旧键都过期;这一次 Allow 会把键数推过阈值,触发清理。
|
||||
clock = clock.Add(2 * time.Minute)
|
||||
if !l.Allow("fresh") {
|
||||
t.Fatalf("新键应被放行")
|
||||
}
|
||||
|
||||
if got := len(l.hits); got != 1 {
|
||||
t.Fatalf("过期键应被清光,只剩 fresh,得到 %d 个", got)
|
||||
}
|
||||
if _, ok := l.hits["fresh"]; !ok {
|
||||
t.Fatalf("清理不该误删刚写入的键")
|
||||
}
|
||||
}
|
||||
|
||||
// 清理只针对「窗口内没有任何记录」的键:窗口内仍然活跃的键一个都不能丢,
|
||||
// 否则攻击者只要堆够键数就能把别人的限流计数冲掉。
|
||||
func TestLimiterSweepKeepsLiveKeys(t *testing.T) {
|
||||
clock := time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC)
|
||||
l := newSweepTestLimiter(&clock)
|
||||
|
||||
total := sweepThreshold + 1
|
||||
for i := 0; i < total; i++ {
|
||||
l.Allow(fmt.Sprintf("10.0.1.%d", i))
|
||||
}
|
||||
if got := len(l.hits); got != total {
|
||||
t.Fatalf("窗口内的键应全部保留,期望 %d,得到 %d", total, got)
|
||||
}
|
||||
|
||||
// 窗口内的键再访问一次仍然受限,说明清理没有顺手把计数抹掉。
|
||||
if l.Allow("10.0.1.0") {
|
||||
t.Fatalf("同一键在同一窗口内第二次应被拒")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user