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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 06:00:30 +00:00
co-authored by Cursor
parent d2903a1662
commit 2dd8f46e64
7 changed files with 160 additions and 8 deletions
+1 -1
View File
@@ -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}
+4 -4
View File
@@ -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:
@@ -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")
}
}
+25 -2
View File
@@ -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())
+80
View File
@@ -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))
}
+46
View File
@@ -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")
}
}
+3
View File
@@ -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)