mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 02:53:31 +00:00
8aa6481421
* feat: enforce RBAC role checks on all mutation endpoints (TASK-150) Add requireMinRole helper and role enforcement to 30+ mutation handlers. Viewers are now blocked from all state-changing operations, editors can mutate items/docs/comments/views but not collections/webhooks/workspace settings, and only owners can perform administrative operations. Includes 11 integration tests with real auth covering viewer/editor/owner access across items, collections, documents, comments, agent roles, item links, and workspace operations. * fix: scope search results to user's workspaces (TASK-151) Search without a ?workspace= param previously returned results from all workspaces in the database. Now the handler resolves the authenticated user's workspace memberships and passes their IDs to the store query, ensuring results only include items from workspaces the user belongs to. Fresh installs (no users) retain unscoped search for backward compat. Includes integration test proving cross-workspace isolation. * fix: add webhook URL validation and SSRF protection (TASK-152) Webhook creation now validates URLs before accepting them: only HTTP(S) schemes allowed, embedded credentials rejected, private/reserved IPs blocked (loopback, RFC1918, link-local, cloud metadata 169.254.169.254), and hostnames are DNS-resolved to verify they don't point to private IPs. Defense-in-depth check also added to the dispatcher's deliver function so existing webhooks with unsafe URLs are blocked at delivery time. * feat: add CSRF protection with double-submit cookie pattern (TASK-153) Implements CSRF middleware that validates X-CSRF-Token header matches the pad_csrf cookie on all state-changing API requests. Bearer token auth, auth endpoints, and fresh installs are exempt. The frontend client reads the CSRF cookie and attaches the header on mutations. * feat: add per-endpoint rate limiting middleware (TASK-154) Adds IP-based rate limiting for auth endpoints (5/min login, 3/hr password reset, 5/hr registration) and user-based limits for API (100/min) and search (30/min). Uses golang.org/x/time/rate with automatic stale-entry cleanup. Adds chi RealIP middleware for correct client IP behind proxies. Returns 429 with Retry-After. * fix: sanitize error responses and remove PII from logs (TASK-155) Replace all writeError(500, err.Error()) calls with writeInternalError that logs the real error server-side and returns a generic message to clients. Remove email addresses, user IDs, and password reset tokens from log output to prevent PII leakage. * feat: add security headers, configurable CORS, and secure cookies (TASK-160) Add SecurityHeaders middleware (CSP, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy). Make CORS origins configurable via PAD_CORS_ORIGINS env var. Add PAD_SECURE_COOKIES for TLS deployments (sets Secure flag on session/CSRF cookies and enables HSTS). Also adds X-CSRF-Token to CORS allowed headers. * fix: address PR review — lazy router init and trusted IP for rate limits Fix two issues flagged by Codex: 1. CORS/HSTS config was ignored because setupRouter() ran in New() before SetCORSOrigins/SetSecureCookies were called. Now uses sync.Once to lazily build the router on first ServeHTTP/Listen. 2. Rate limiter read X-Real-IP directly from untrusted headers, allowing clients to spoof IPs. Now uses RemoteAddr only (which chimiddleware.RealIP already sanitizes from trusted proxy headers).
149 lines
4.3 KiB
Go
149 lines
4.3 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRateLimit_AuthEndpointLimited(t *testing.T) {
|
|
srv := testServer(t)
|
|
|
|
// Bootstrap so auth endpoints actually process (not just "setup required")
|
|
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
|
|
|
|
// Login attempts should be rate-limited after burst (5)
|
|
for i := 0; i < 5; i++ {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
|
|
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "10.0.0.1:1234"
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, req)
|
|
// These should go through (even if returning 401)
|
|
if w.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("request %d should not be rate-limited yet", i+1)
|
|
}
|
|
}
|
|
|
|
// The 6th should be rate-limited
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
|
|
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "10.0.0.1:1234"
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Errorf("expected 429 after burst, got %d", w.Code)
|
|
}
|
|
|
|
// Check Retry-After header
|
|
if w.Header().Get("Retry-After") == "" {
|
|
t.Error("expected Retry-After header on 429 response")
|
|
}
|
|
}
|
|
|
|
func TestRateLimit_DifferentIPsNotAffected(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
|
|
|
|
// Exhaust rate limit for IP 10.0.0.1
|
|
for i := 0; i < 6; i++ {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
|
|
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "10.0.0.1:1234"
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, req)
|
|
}
|
|
|
|
// Different IP should still be allowed
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
|
|
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "10.0.0.2:1234"
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, req)
|
|
|
|
if w.Code == http.StatusTooManyRequests {
|
|
t.Error("different IP should not be rate-limited")
|
|
}
|
|
}
|
|
|
|
func TestRateLimit_SearchEndpointLimited(t *testing.T) {
|
|
srv := testServer(t)
|
|
|
|
// Search limiter has burst=10, so first 10 should succeed
|
|
for i := 0; i < 10; i++ {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil)
|
|
req.RemoteAddr = "10.0.0.3:1234"
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, req)
|
|
if w.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("request %d should not be rate-limited yet (search burst=10)", i+1)
|
|
}
|
|
}
|
|
|
|
// The 11th should be rate-limited
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil)
|
|
req.RemoteAddr = "10.0.0.3:1234"
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, req)
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Errorf("expected 429 after search burst, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRateLimit_NonAPIPathsExempt(t *testing.T) {
|
|
srv := testServer(t)
|
|
|
|
// Non-API paths should not be rate-limited
|
|
for i := 0; i < 50; i++ {
|
|
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
|
req.RemoteAddr = "10.0.0.4:1234"
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, req)
|
|
if w.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("non-API request %d should not be rate-limited", i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClientIP(t *testing.T) {
|
|
// clientIP only reads RemoteAddr (proxy headers are handled by chimiddleware.RealIP)
|
|
tests := []struct {
|
|
name string
|
|
remoteAddr string
|
|
want string
|
|
}{
|
|
{"with port", "192.168.1.1:1234", "192.168.1.1"},
|
|
{"no port", "10.0.0.1", "10.0.0.1"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = tt.remoteAddr
|
|
got := clientIP(req)
|
|
if got != tt.want {
|
|
t.Errorf("clientIP() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClientIP_IgnoresProxyHeaders(t *testing.T) {
|
|
// Ensure clientIP does NOT trust X-Real-IP or X-Forwarded-For
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.168.1.1:1234"
|
|
req.Header.Set("X-Real-IP", "10.0.0.99")
|
|
req.Header.Set("X-Forwarded-For", "10.0.0.88")
|
|
|
|
got := clientIP(req)
|
|
if got != "192.168.1.1" {
|
|
t.Errorf("clientIP should ignore proxy headers, got %q", got)
|
|
}
|
|
}
|