Skip to main content

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.goSecurityHeaders.
  • internal/api/ratelimit.goRateLimiter, 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}.
These admin endpoints require an authenticated workspace context and ADMIN or OWNER (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.
These overrides are instance-wide, not per-workspace — the write is gated by the OWNER/ADMIN role check (like the other instance-scoped admin operations: re-encrypt, prune, backups), but its effect applies to the whole daemon. On a multi-tenant deployment, treat OWNER/ADMIN on any workspace as the authority to retune (and therefore weaken) rate limiting for every tenant. On a single-tenant self-hosted instance — Crewship’s primary shape — this is a non-issue.
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.
Dispatch happens in Router.routeWithRateLimiting:

Authenticated-CLI exemption (#1333)

A crewship 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 polls GET /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:
  1. Router splitGET /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. See Router.routeWithRateLimiting (internal/api/router.go).
  2. Frontend resilience — the session probe (hooks/use-auth.tsx) treats a 429, a 5xx/408, or a network error as a transient failure: it backs off and retries (honouring Retry-After) instead of dropping the session. An existing authenticated session is never downgraded to logged-out by a transient response — only a definitive 200 {} (empty), 401, or 403 unauthenticates.

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-Limit is the bucket burst (the per-minute limit); X-RateLimit-Reset is 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:
  1. If the immediate hop is not in the trusted-proxy set, forwarding headers are ignored and the client IP is r.RemoteAddr (host portion).
  2. If the immediate hop is a trusted proxy, X-Forwarded-For is parsed right to left, skipping trusted-proxy hops, and the first untrusted entry is returned. X-Real-IP is consulted only if that yields nothing.
  3. r.RemoteAddr (host portion) is the final fallback.
The trusted-proxy set defaults to loopback (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.
If you run a non-local reverse proxy (nginx, Caddy, Traefik) without setting CREWSHIP_TRUSTED_PROXY_CIDRS, its address is not trusted, so every request is keyed by the proxy’s IP and all clients share one bucket. Set the CIDR so the true client IP is used. Conversely, exposing Crewship directly to the internet is safe against XFF spoofing by default — the headers are ignored unless the peer is a trusted proxy.

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 … RETURNING to read and remove the row in one statement. A replayed ?state=… returns 400 Invalid or expired state.
  • 15-minute expiry — the callback rejects any state whose created_at is older than 15 minutes with 400 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_verifier alongside the state for the token-exchange step.
  • Safe redirect allowlist — Google sign-in passes the caller’s ?redirect= through isSafeRedirect before 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.