From 983cce3e8077541f6566b4e94783aaa2c03c72c9 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 25 Aug 2026 22:52:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=85=AC=E5=BC=80=20CAP=20?= =?UTF-8?q?=E5=8F=A3=E7=A1=AC=E7=BC=96=E7=A0=81=E9=BB=98=E8=AE=A4=E5=AF=86?= =?UTF-8?q?=E9=92=A5=EF=BC=9BSessionSecret=20=E4=B8=BA=E7=A9=BA=E6=97=B6?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E7=AD=BE=E5=8F=91/=E6=A0=B8=E9=94=80?= =?UTF-8?q?=EF=BC=8C=E9=98=B2=E6=AD=A2=E6=9C=AA=E6=8E=88=E6=9D=83=E4=BC=AA?= =?UTF-8?q?=E9=80=A0=20PoW=E3=80=82metric=20=E6=8C=81=E5=B9=B3=208?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126} --- .auto/log.jsonl | 1 + internal/apps/cap/manager.go | 7 +++++-- internal/apps/cap/middleware.go | 4 ++++ internal/apps/cap/routers.go | 8 ++++++++ internal/apps/cap/routers_test.go | 11 +++++++++++ 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.auto/log.jsonl b/.auto/log.jsonl index 54ca8265..62df21b8 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -32,3 +32,4 @@ {"run":31,"commit":"69055a9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":71,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权 Agent 注册口的 discovery token 改为 SHA-256 后恒定时间比较,堵住计时侧信道;空 token / 末字节翻转用例同步补上。metric 持平 8。","timestamp":1787667401636,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","finding":"公开面注册口 ValidateDiscoveryToken 是入侵入口;管理员已登录操作不在范围内。checks 全绿。","next_action_hint":"下一轮可查边缘 Token 比较(agent/relay/flared 走 DB 查找,计时面更弱)或登录口限流"}} {"run":32,"commit":"fb62802","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":81,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开登录/注册邮箱验证码比较改为 SHA-256 后恒定时间 Compare,堵住未授权口的计时侧信道。metric 持平 8。","timestamp":1787667660993,"segment":0,"confidence":null,"asi":{"hypothesis":"verifyEmailCode 用 != 比较 6 位码,公开登录/注册口可被计时","finding":"公开面验证码比较已改恒定时间;冷却仍在,不改限流策略。","next_action_hint":"下一轮可查边缘节点 access_token 比较(DB 查找,计时面更弱)或登录失败锁定"}} {"run":33,"commit":"b8bf82b","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权登录口补哑 bcrypt 比较,用户不存在与密码错误耗时对齐;禁用账号不再返回不同文案,堵住用户枚举。metric 持平 8。","timestamp":1787668237322,"segment":0,"confidence":null,"asi":{"hypothesis":"未授权 /user/login 在用户不存在时跳过 bcrypt,且禁用账号返回不同文案,可枚举用户","finding":"DummyCheckPassword 启动时生成哑哈希,gosec 不报警;禁用账号改统一错误文案。管理员已登录不在范围内。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}} +{"run":34,"commit":"380a42a","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。","timestamp":1787669059138,"segment":0,"confidence":null,"asi":{"hypothesis":"生产 Redis 会话在登录时复用同一 ID,未授权方可固定会话 cookie","finding":"SetLoginSession 先 Clear 再把 gorilla session.ID 置空,Save 时 redistore 生成新 ID;明文改密标记经 extras 写回。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}} diff --git a/internal/apps/cap/manager.go b/internal/apps/cap/manager.go index 474993c1..f2eb3bce 100644 --- a/internal/apps/cap/manager.go +++ b/internal/apps/cap/manager.go @@ -175,10 +175,13 @@ var ( // GetDefaultManager yields the global singleton CAPTCHA manager. func GetDefaultManager() *Manager { once.Do(func() { - secret := []byte("default-captcha-secret-key-at-least-16-bytes") - if config.Config != nil && config.Config.App.SessionSecret != "" { + var secret []byte + if config.Config != nil && strings.TrimSpace(config.Config.App.SessionSecret) != "" { secret = []byte(config.Config.App.SessionSecret) } + if len(secret) == 0 { + return + } var store pkgcap.Store if config.Config != nil && config.Config.Redis.Enabled && db.Redis != nil { diff --git a/internal/apps/cap/middleware.go b/internal/apps/cap/middleware.go index 5b04cd9e..f462baf7 100644 --- a/internal/apps/cap/middleware.go +++ b/internal/apps/cap/middleware.go @@ -16,6 +16,10 @@ func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc { c.Next() return } + if mgr == nil { + response.AbortUnauthorized(c, errCapTokenInvalidOrExpired) + return + } token := c.GetHeader("X-Cap-Token") if token == "" { diff --git a/internal/apps/cap/routers.go b/internal/apps/cap/routers.go index a6ed7c4b..30e2afe7 100644 --- a/internal/apps/cap/routers.go +++ b/internal/apps/cap/routers.go @@ -43,6 +43,10 @@ func Challenge(c *gin.Context) { } mgr := GetDefaultManager() + if mgr == nil { + response.AbortInternal(c, "captcha is not configured") + return + } resp, err := mgr.Generate(c.Request.Context(), req.Scope) if err != nil { response.AbortInternal(c, err.Error()) @@ -75,6 +79,10 @@ func Redeem(c *gin.Context) { } mgr := GetDefaultManager() + if mgr == nil { + response.AbortInternal(c, "captcha is not configured") + return + } resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, req.Scope) if err != nil { response.AbortInternal(c, err.Error()) diff --git a/internal/apps/cap/routers_test.go b/internal/apps/cap/routers_test.go index de9fdd7d..872d6d42 100644 --- a/internal/apps/cap/routers_test.go +++ b/internal/apps/cap/routers_test.go @@ -9,10 +9,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "sync" "testing" "github.com/gin-gonic/gin" + "github.com/Rain-kl/Wavelet/internal/infra/config" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" "github.com/Rain-kl/Wavelet/internal/shared/response" @@ -39,6 +41,15 @@ func TestCapEndpointsAndMiddleware(t *testing.T) { sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() + oldSecret := config.Config.App.SessionSecret + config.Config.App.SessionSecret = "test-captcha-session-secret" + once = sync.Once{} + defaultManager = nil + t.Cleanup(func() { + config.Config.App.SessionSecret = oldSecret + once = sync.Once{} + defaultManager = nil + }) r := testhelper.NewTestGinEngine() // Mount CAPTCHA API endpoints