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:
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user