Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt

Use this file to discover all available pages before exploring further.

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:
HeaderValuePurpose
X-Content-Type-OptionsnosniffForbid MIME-type sniffing of responses.
X-Frame-OptionsDENYForbid rendering the UI inside a frame/iframe (clickjacking defence).
X-XSS-Protection0Disable the legacy XSS auditor (known to introduce bugs; CSP is the modern path).
Referrer-Policystrict-origin-when-cross-originLeak only origin — not path — to cross-origin navigations.
Permissions-Policycamera=(), microphone=(), geolocation=()Explicitly deny powerful features the UI does not use.
Cross-Origin-Opener-Policysame-originIsolate the browsing context so a top-level cross-origin opener cannot access window references (Spectre / XS-Leak hardening).
Content-Security-Policypath-aware (see below)Lockdown for API / health / WS surfaces; SPA-friendly policy for the bundled Next.js UI.

Path-aware CSP

securityHeadersMiddleware (internal/server/security_headers.go) inspects the request path and applies one of three policies:
PathCSP value
SPA UI (anything outside /api/, /healthz, /readyz, /metrics, /ws*, /exposed/)default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
API / health / WebSocketdefault-src 'none'; frame-ancestors 'none'; base-uri 'none' (reapplied by api.SecurityHeaders inside the API router so the lockdown survives even if the outer wrapper is bypassed)
/exposed/ (port-exposed user apps)No CSP header. Upstream owns its own policy; the reverse proxy does not stamp ours on arbitrary HTML/JS.
HSTS is intentionally not set: the single binary must support plain-HTTP deployments (dev, on-prem behind a TLS-terminating reverse proxy). Set HSTS at the reverse proxy when TLS is in front.

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:
Route familyLimitHandler
/api/auth/*, /api/v1/auth/*, /api/v1/bootstrap10 req/min per IPAuth endpoints — strict because these gate the entire system.
/api/* (all other)120 req/min per IPPublic API endpoints.
/api/v1/internal/*no rate limitSidecar IPC. Authenticated via X-Internal-Token, not IP.
Static UI / other pathsno rate limitServed from the embedded Next.js export.
Dispatch happens in Router.routeWithRateLimiting:
// Skip rate limiting for internal routes (sidecar IPC, X-Internal-Token auth)
if strings.HasPrefix(path, "/api/v1/internal/") { r.mux.ServeHTTP(w, req); return }
if strings.HasPrefix(path, "/api/auth/") || … { r.authRateLimitedMux.ServeHTTP(…); return }
if strings.HasPrefix(path, "/api/")          { r.apiRateLimitedMux.ServeHTTP(…);  return }
r.mux.ServeHTTP(w, req)

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:
    HTTP/1.1 429 Too Many Requests
    Retry-After: 60
    Content-Type: application/json
    
    {"error": "Too many requests"}
    

IP extraction

extractIP picks the client address in this order — callers must run Crewship behind a reverse proxy that either strips or rewrites these headers if they cannot be trusted:
  1. X-Forwarded-For — split on comma, only the first entry is used. If the header is 1.2.3.4, 10.0.0.1, the client IP is 1.2.3.4.
  2. X-Real-IP — used if X-Forwarded-For is absent.
  3. r.RemoteAddr (host portion) — final fallback.
If Crewship is exposed directly to the internet without a proxy, any client can spoof X-Forwarded-For to bypass per-IP rate limits. Run behind a reverse proxy (nginx, Caddy, Traefik) that overwrites these headers from the true connecting peer.

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

POST /api/auth/google/start
  → generate 16 random bytes, base64url-encode
  → INSERT INTO oauth_states (state, redirect_uri, [code_verifier])
  → 307 redirect to provider with ?state=<token>

GET /api/auth/google/callback?state=<token>&code=<code>
  → DELETE FROM oauth_states WHERE state = ? RETURNING redirect_uri, created_at
  → if row missing              → 400 Invalid or expired state
  → if created_at > 15 min ago  → 400 OAuth state expired
  → otherwise exchange code and sign the user in

Reviewing behaviour in practice

SymptomLikely cause
429 Too Many Requests on /api/auth/login after 10 attemptsAuth rate limiter (10 req/min). Retry after 60 s or behind a different IP.
400 OAuth state expired after leaving the sign-in tab openState row older than 15 minutes. Retry from the start.
400 Invalid or expired state on back-button replayState row already consumed — single-use.
Legitimate internal caller rate-limitedRequest hit the /api/* bucket because it was not sent under /api/v1/internal/*. Fix the route prefix; internal callers go through X-Internal-Token auth.

See also

  • RBAC — role-gated authorisation layered on top of these controls.
  • Encryption — credential + memory at-rest protection.