Rate Limiting & Security Headers
Crewship’s HTTP layer applies three defensive controls to every request: response-header hardening, per-IP rate limiting, and CSRF-safe OAuth state handling. All three are wired at the router level — no handler opt-out. Code of record:internal/api/middleware.go—SecurityHeaders.internal/api/ratelimit.go—RateLimiter,extractIP.internal/api/router.go— middleware composition and route buckets.internal/api/auth_google.go+internal/api/oauth.go— OAuth state lifecycle.
Security response headers
SecurityHeaders wraps every response (static UI and API alike) with a fixed set of headers:
X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, and Permissions-Policy are stamped by both api.SecurityHeaders (API router) and server.securityHeadersMiddleware (outer wrapper). Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Resource-Policy, and Strict-Transport-Security are set only by the outer server.securityHeadersMiddleware.
Path-aware CSP
securityHeadersMiddleware (internal/server/security_headers.go) inspects the request path and applies one of three policies:
The SPA
img-src allows one extra host, https://logos.composio.dev, which serves the managed-integration brand logos (Gmail, GitHub, Slack, …) shown in the connector catalog — scoped to that one host, not a blanket https:.
HSTS is set (
max-age=31536000; includeSubDomains) on every non-/exposed/ response, so a browser that reaches Crewship over HTTPS is pinned to it for a year. preload is omitted on purpose. For plain-HTTP dev the header is inert (browsers ignore HSTS delivered over HTTP). When you terminate TLS at a reverse proxy in front of Crewship, the header still rides through; there is nothing extra to configure at the proxy.Per-IP rate limiting
RateLimiter is a token-bucket limiter keyed by client IP, implemented with golang.org/x/time/rate. Two independent buckets guard different route families:
The limiter can be disabled entirely (dev only) by setting
CREWSHIP_RATELIMIT_DISABLED — but in production (CREWSHIP_ENV=prod/production) the toggle is ignored and the limiter always runs.
Runtime tuning (admin “Rate Limiters”)
The per-IP bucket sizes — and every other tunable limiter in the system (login lockout, the notification anti-storm bucket, crew provisioning, agent webhooks) — are adjustable at runtime by an OWNER/ADMIN, without a redeploy. The shipped defaults above are unchanged until an operator overrides them.- UI: Admin Console → Infrastructure → Rate Limiters — a table of every limiter with its current value, default, and an inline editor + reset-to-default.
- CLI:
crewship admin ratelimits list | set <key> <value> | reset <key>(see the admin CLI reference). - API:
GET /api/v1/admin/rate-limits,PUT /api/v1/admin/rate-limits/{key}({"value": N}),DELETE /api/v1/admin/rate-limits/{key}.
manage) permission. GET has no body and returns 200 with a
limiters array; PUT takes {"value": N} and returns 200 with the updated
limiter; DELETE has no body and returns 200 with the reset limiter. Invalid
JSON or out-of-range values return 400, unknown keys return 404, and an
unavailable configuration store returns 503 (all admin routes return 403
for an insufficient role). See the Admin API rate-limit contract.
Overrides persist in the instance-global rate_limit_overrides table and apply immediately: the per-IP HTTP buckets are retuned live (RateLimiter.SetReqPerMin); the other limiters read their value on next use. The registry of keys, defaults, and bounds lives in internal/ratelimitcfg. A value out of a limiter’s [min, max] range is rejected (400); an unknown key is 404.
Raising the auth bucket (
http.auth_per_min) is the knob for an instance whose users are being logged out under heavy refresh traffic behind a shared NAT. The read-only session polls that caused the original refresh-logout no longer touch this bucket at all (they ride the 120/min general bucket), so most instances never need to change it.Router.routeWithRateLimiting:
Authenticated-CLI exemption (#1333)
Acrewship seed or template-import run fires far more requests than the 120/min general bucket in seconds — 429ing mid-run used to leave a half-seeded tenant. Requests bearing a genuinely valid CLI token (crewship_cli_… or crewship_admin_…, hash-matched against cli_tokens, non-revoked, non-expired — checked via IsValidCLIToken, not just the token’s prefix shape) skip the per-IP bucket entirely and go straight to the body-capped mux. This check is side-effect-free: it never writes the ADMIN per-use audit row or touches last_used_at — the real RequireAuth validation (with its audit trail) still runs exactly once, downstream in the handler.
The exemption applies only to the general /api/* bucket. The auth endpoints (/api/auth/*) and the credential-test anti-oracle bucket (/api/v1/credentials/test) are unaffected — a valid CLI token does not bypass either, since both exist specifically to throttle actions that stay dangerous even when authenticated (login/signup credential stuffing, key-validation oracling). A CLI-token-shaped bearer that doesn’t hash-match a live row (forged, revoked, expired) gets no exemption and falls through to the normal per-IP bucket like any other request.
Because the validity check runs before the limiter, failed lookups are remembered in a small bounded negative cache (SHA-256 of the bearer, 30 s TTL, 1024-entry cap) so a flood of spoofed CLI-prefix bearers cannot force an unthrottled database lookup on every request. Only failures are cached — a valid token is re-checked on every request, so revoking or expiring it removes the exemption immediately.
Read-only auth GETs stay out of the login bucket
The dashboard’s auth provider pollsGET /api/auth/session (and GET /api/auth/csrf) on every page load. If those reads shared the 10 req/min login bucket, a handful of rapid refreshes would drain it; the resulting 429 was interpreted by the frontend session probe as “logged out”, bouncing the user to /login.
Two independent guards close this:
- Router split —
GET /api/auth/*requests carry no credentials, so they route through the general 120 req/min bucket. Only credential-submitting auth requests (the login callback, token refresh, sign-out — all non-GET) stay on the strict 10/min bucket, so brute-force protection is untouched. SeeRouter.routeWithRateLimiting(internal/api/router.go). - Frontend resilience — the session probe (
hooks/use-auth.tsx) treats a429, a5xx/408, or a network error as a transient failure: it backs off and retries (honouringRetry-After) instead of dropping the session. An existing authenticated session is never downgraded to logged-out by a transient response — only a definitive200 {}(empty),401, or403unauthenticates.
Burst, cleanup, 429 response
- Bucket burst equals the per-minute limit (full bucket on first visit).
- Stale per-IP entries are swept every 3 minutes; anything not seen in the last 5 minutes is evicted.
- Exceeding the bucket returns:
X-RateLimit-Limitis the bucket burst (the per-minute limit);X-RateLimit-Resetis a Unix timestamp 60 s out. The values come from the limiter’s own state, not hard-coded, so well-behaved clients can back off intelligently.
IP extraction
extractIP (internal/api/ratelimit.go) honours X-Forwarded-For / X-Real-IP only when the immediate connecting hop (r.RemoteAddr) is itself a trusted proxy. Untrusted clients cannot spoof their way into a fresh token bucket:
- If the immediate hop is not in the trusted-proxy set, forwarding headers are ignored and the client IP is
r.RemoteAddr(host portion). - If the immediate hop is a trusted proxy,
X-Forwarded-Foris parsed right to left, skipping trusted-proxy hops, and the first untrusted entry is returned.X-Real-IPis consulted only if that yields nothing. r.RemoteAddr(host portion) is the final fallback.
127.0.0.0/8, ::1/128). Operators behind a non-local reverse proxy must add its address:
Right-to-left parsing defeats the append-style proxy spoof: with nginx’s default
proxy_add_x_forwarded_for, an attacker who pre-seeds X-Forwarded-For: 8.8.8.8 produces 8.8.8.8, <real-attacker-ip>, and the right-to-left walk correctly reads the real client past the forged prefix.
OAuth CSRF state
Both the Google sign-in flow (internal/api/auth_google.go) and the credential OAuth connector flow (internal/api/oauth.go) store an opaque state token in the oauth_states table at the start of the round-trip and consume it atomically on callback.
Guarantees:
- Single-use — the callback uses
DELETE … RETURNINGto read and remove the row in one statement. A replayed?state=…returns400 Invalid or expired state. - 15-minute expiry — the callback rejects any state whose
created_atis older than 15 minutes with400 OAuth state expired. The OAuth connector flow also sweeps expired rows on each new request (DELETE FROM oauth_states WHERE created_at < datetime('now', '-15 minutes')). - Fail closed on parse error — unreadable timestamps are treated as invalid rather than allowed through.
- PKCE — the credential-connector flow additionally stores a PKCE
code_verifieralongside the state for the token-exchange step. - Safe redirect allowlist — Google sign-in passes the caller’s
?redirect=throughisSafeRedirectbefore storing, so a bad actor cannot smuggle in an open-redirect target via the state row.
State lifecycle
Reviewing behaviour in practice
See also
- RBAC — role-gated authorisation layered on top of these controls.
- Encryption — credential + memory at-rest protection.