From 2dd8f46e641f8c545ed9e088106e12c82083b373 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 06:00:30 +0000 Subject: [PATCH] fix(server): login/register rate limiting + compose secret hygiene (H4/H8/M8) - H4: sliding-window rate limiter (10 attempts / 5min per IP+username) on login and register; successful login resets the counter; pure stdlib, no new dependency - H8: docker-compose.test.yml DB/ADMIN passwords now :?required (align with main); docker-compose.main.yml AUTH_REGISTRATION_ENABLED defaults to false - M8: fix FAV_PUBLISHER_ENABLE -> FAV_PUBLISHER_ENABLED typo so the gate test actually guards the real flag Co-authored-by: Cursor --- docker-compose.main.yml | 2 +- docker-compose.test.yml | 8 +- internal/artifactvault/publisher_gate_test.go | 2 +- internal/server/auth_handlers.go | 27 ++++++- internal/server/login_rate_limit.go | 80 +++++++++++++++++++ internal/server/login_rate_limit_test.go | 46 +++++++++++ internal/server/server.go | 3 + 7 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 internal/server/login_rate_limit.go create mode 100644 internal/server/login_rate_limit_test.go diff --git a/docker-compose.main.yml b/docker-compose.main.yml index d1ebb18a..f21fa10c 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -95,7 +95,7 @@ services: KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required for main/production} - AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true} + AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-false} AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false} EMBEDDING_ENDPOINT_URL: ${EMBEDDING_ENDPOINT_URL:-} EMBEDDING_MODEL: ${EMBEDDING_MODEL:-bge-m3} diff --git a/docker-compose.test.yml b/docker-compose.test.yml index af9112c7..5538a08b 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -14,7 +14,7 @@ services: command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_connections=150"] environment: POSTGRES_USER: ${SYSTEM_DB_USER:-app} - POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:-test_secret_db_pass} + POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:?required} POSTGRES_DB: ${SYSTEM_DB_NAME:-agent_system_test} ports: - "127.0.0.1:${SYSTEM_DB_PUBLISH_PORT:-5434}:5432" @@ -82,7 +82,7 @@ services: MAX_CONCURRENT_AGENTS: ${MAX_CONCURRENT_AGENTS:-8} DATA_DIR: /app/data # 独占测试业务与对话库 - APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-test_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable + APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable # 共享知识库 POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:?KB_DB_PASSWORD is required}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable PI_AGENT_RUNNER_URL: http://pi-agent:18866 @@ -97,7 +97,7 @@ services: RERANKER_MODEL: ${RERANKER_MODEL:-bge-reranker-v2-m3-free} RERANKER_API_KEY: ${RERANKER_API_KEY:-} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin-secret-password-123} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?required} AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true} AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} @@ -152,7 +152,7 @@ services: HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}" EVENTS_HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}" DATA_DIR: /app/data - APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-test_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable + APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable ports: - "127.0.0.1:${EVENTS_HTTP_PORT:-8089}:${EVENTS_HTTP_PORT:-8089}" healthcheck: diff --git a/internal/artifactvault/publisher_gate_test.go b/internal/artifactvault/publisher_gate_test.go index b8c5f473..0ec7d095 100644 --- a/internal/artifactvault/publisher_gate_test.go +++ b/internal/artifactvault/publisher_gate_test.go @@ -13,7 +13,7 @@ func TestFAVDegradedReadDefaultOff(t *testing.T) { } func TestPublisherEnableDefaultOff(t *testing.T) { - if os.Getenv("FAV_PUBLISHER_ENABLE") == "1" { + if os.Getenv("FAV_PUBLISHER_ENABLED") == "1" { t.Fatal("test env must not force publisher on") } } diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 6e0f12df..c4fd8db1 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -36,7 +36,7 @@ func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) { return nil, errors.New("missing auth token") } -// handleAuthLogin 用户名密码登录 +// handleAuthLogin 用户名密码登录(限流:连续爆破触发 429) func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` @@ -47,6 +47,17 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { return } + if s.loginLimiter != nil { + key := s.loginLimiter.key(r, req.Username) + if !s.loginLimiter.allow(key) { + writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{ + "error_code": "ERR_LOGIN_RATE_LIMITED", + "message": "登录尝试过于频繁,请稍后再试", + }) + return + } + } + session, err := s.authSvc.Login(r.Context(), req.Username, req.Password) if err != nil { errMsg := err.Error() @@ -62,11 +73,15 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { return } + if s.loginLimiter != nil { + s.loginLimiter.reset(s.loginLimiter.key(r, req.Username)) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(session) } -// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客) +// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客,限流防批量注册) func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { var req auth.RegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -74,6 +89,14 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { return } + if s.loginLimiter != nil && !s.loginLimiter.allow(s.loginLimiter.key(r, req.Username)) { + writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{ + "error_code": "ERR_REGISTER_RATE_LIMITED", + "message": "注册尝试过于频繁,请稍后再试", + }) + return + } + resp, err := s.authSvc.Register(r.Context(), req) if err != nil { writeSafeError(w, r, http.StatusBadRequest, err.Error()) diff --git a/internal/server/login_rate_limit.go b/internal/server/login_rate_limit.go new file mode 100644 index 00000000..fcd66983 --- /dev/null +++ b/internal/server/login_rate_limit.go @@ -0,0 +1,80 @@ +package server + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +// loginRateLimiter 基于 IP+用户名的滑动窗口登录限流器(纯 stdlib,防爆破/撞库)。 +// 窗口内超过 maxAttempts 次尝试即拒绝,窗口过期后自动重置。 +type loginRateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + maxAttempts int + window time.Duration +} + +func newLoginRateLimiter(maxAttempts int, window time.Duration) *loginRateLimiter { + if maxAttempts <= 0 { + maxAttempts = 10 + } + if window <= 0 { + window = 5 * time.Minute + } + return &loginRateLimiter{ + attempts: make(map[string][]time.Time), + maxAttempts: maxAttempts, + window: window, + } +} + +// allow 判断 key 本次尝试是否放行;放行则记录一次尝试。 +func (l *loginRateLimiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + cutoff := now.Add(-l.window) + + // 滑出窗口的尝试丢弃 + ts := l.attempts[key] + kept := ts[:0] + for _, t := range ts { + if t.After(cutoff) { + kept = append(kept, t) + } + } + if len(kept) >= l.maxAttempts { + l.attempts[key] = kept + return false + } + l.attempts[key] = append(kept, now) + return true +} + +// reset 认证成功后清空该 key 的计数(避免正常用户被自己的历史失败累积误伤)。 +func (l *loginRateLimiter) reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.attempts, key) +} + +// clientIP 取客户端 IP(优先 X-Forwarded-For 首跳,回落 RemoteAddr)。 +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if first, _, _ := strings.Cut(xff, ","); strings.TrimSpace(first) != "" { + return strings.TrimSpace(first) + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func (l *loginRateLimiter) key(r *http.Request, username string) string { + return clientIP(r) + "|" + strings.ToLower(strings.TrimSpace(username)) +} diff --git a/internal/server/login_rate_limit_test.go b/internal/server/login_rate_limit_test.go new file mode 100644 index 00000000..a0cbbebe --- /dev/null +++ b/internal/server/login_rate_limit_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "testing" + "time" +) + +func TestLoginRateLimiter_BlocksAfterMaxAttempts(t *testing.T) { + l := newLoginRateLimiter(3, time.Minute) + key := "1.2.3.4|alice" + for i := 0; i < 3; i++ { + if !l.allow(key) { + t.Fatalf("attempt %d should be allowed", i+1) + } + } + if l.allow(key) { + t.Fatalf("4th attempt within window should be blocked") + } +} + +func TestLoginRateLimiter_ResetClearsWindow(t *testing.T) { + l := newLoginRateLimiter(2, time.Minute) + key := "1.2.3.4|bob" + l.allow(key) + l.allow(key) + if l.allow(key) { + t.Fatalf("should be blocked before reset") + } + l.reset(key) + if !l.allow(key) { + t.Fatalf("should be allowed after reset") + } +} + +func TestLoginRateLimiter_WindowExpiry(t *testing.T) { + l := newLoginRateLimiter(1, 10*time.Millisecond) + key := "1.2.3.4|carol" + l.allow(key) + if l.allow(key) { + t.Fatalf("should be blocked within window") + } + time.Sleep(20 * time.Millisecond) + if !l.allow(key) { + t.Fatalf("should be allowed after window expiry") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c98f2976..08ad0661 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -79,6 +79,7 @@ type Server struct { vaultPublisherStarted int32 vaultStop chan struct{} reminderStop chan struct{} + loginLimiter *loginRateLimiter } // NewFromRuntime attaches the HTTP adapter to an already-wired composition root. @@ -114,6 +115,8 @@ func NewFromRuntime(rt *app.Runtime) *Server { kb: rt.KB, sysadmin: rt.SysAdmin, intentPlans: rt.IntentPlans, + // 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口 + loginLimiter: newLoginRateLimiter(10, 5*time.Minute), } s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz()) s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog)