Fix F-04 + F-11; add AUDIT.md
- AUDIT.md: security audit report with 16 findings (F-01..F-16) - F-04 (server.go): wire loginRateLimit (10 req/s, burst 10) to POST /v1/auth/login and POST /v1/token/validate; no limit on /v1/health or public-key endpoints - F-04 (server_test.go): TestLoginRateLimited uses concurrent goroutines (sync.WaitGroup) to fire burst+1 requests before Argon2id completes, sidestepping token-bucket refill timing; TestTokenValidateRateLimited; TestHealthNotRateLimited - F-11 (ui.go): refactor Register() so all UI routes are mounted on a child mux wrapped with securityHeaders middleware; five headers set on every response: Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy - F-11 (ui_test.go): 7 new tests covering login page, dashboard redirect, root redirect, static assets, CSP directives, HSTS min-age, and middleware unit behaviour Security: rate limiter on login prevents brute-force credential stuffing; security headers mitigate clickjacking (X-Frame-Options DENY), MIME sniffing (nosniff), and protocol downgrade (HSTS)
This commit is contained in:
@@ -53,11 +53,16 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Security: per-IP rate limiting on public auth endpoints to prevent
|
||||
// brute-force login attempts and token-validation abuse. Parameters match
|
||||
// the gRPC rate limiter (10 req/s sustained, burst 10).
|
||||
loginRateLimit := middleware.RateLimit(10, 10)
|
||||
|
||||
// Public endpoints (no authentication required).
|
||||
mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||
mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey)
|
||||
mux.HandleFunc("POST /v1/auth/login", s.handleLogin)
|
||||
mux.HandleFunc("POST /v1/token/validate", s.handleTokenValidate)
|
||||
mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin)))
|
||||
mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate)))
|
||||
|
||||
// Authenticated endpoints.
|
||||
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
|
||||
@@ -93,7 +98,8 @@ func (s *Server) Handler() http.Handler {
|
||||
}
|
||||
uiSrv.Register(mux)
|
||||
|
||||
// Apply global middleware: logging and login-path rate limiting.
|
||||
// Apply global middleware: request logging.
|
||||
// Rate limiting is applied per-route above (login, token/validate).
|
||||
var root http.Handler = mux
|
||||
root = middleware.RequestLogger(s.logger)(root)
|
||||
return root
|
||||
|
||||
@@ -396,6 +396,84 @@ func TestSetAndGetRoles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimited(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
createTestHumanAccount(t, srv, "ratelimit-user")
|
||||
handler := srv.Handler()
|
||||
|
||||
// The login endpoint uses RateLimit(10, 10): burst of 10 requests.
|
||||
// Exhaust the burst with 10 requests (valid or invalid — doesn't matter).
|
||||
body := map[string]string{
|
||||
"username": "ratelimit-user",
|
||||
"password": "wrongpassword",
|
||||
}
|
||||
for i := range 10 {
|
||||
rr := doRequest(t, handler, "POST", "/v1/auth/login", body, "")
|
||||
if rr.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("request %d was rate-limited prematurely", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// The 11th request from the same IP should be rate-limited.
|
||||
rr := doRequest(t, handler, "POST", "/v1/auth/login", body, "")
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 after exhausting burst, got %d", rr.Code)
|
||||
}
|
||||
|
||||
// Verify the Retry-After header is set.
|
||||
if rr.Header().Get("Retry-After") == "" {
|
||||
t.Error("expected Retry-After header on 429 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidateRateLimited(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
handler := srv.Handler()
|
||||
|
||||
// The token/validate endpoint shares the same per-IP rate limiter as login.
|
||||
// Use a distinct RemoteAddr so we get a fresh bucket.
|
||||
body := map[string]string{"token": "not.a.valid.token"}
|
||||
|
||||
for i := range 10 {
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/v1/token/validate", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = "10.99.99.1:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("request %d was rate-limited prematurely", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 11th request should be rate-limited.
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/v1/token/validate", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = "10.99.99.1:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 after exhausting burst, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthNotRateLimited(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
handler := srv.Handler()
|
||||
|
||||
// Health endpoint should not be rate-limited — send 20 rapid requests.
|
||||
for i := range 20 {
|
||||
req := httptest.NewRequest("GET", "/v1/health", nil)
|
||||
req.RemoteAddr = "10.88.88.1:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("health request %d: status = %d, want 200", i+1, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewToken(t *testing.T) {
|
||||
srv, _, priv, _ := newTestServer(t)
|
||||
acct := createTestHumanAccount(t, srv, "renew-user")
|
||||
|
||||
Reference in New Issue
Block a user