Security hardening (hosting-service)
Production deployments should configure the controls below. Defaults follow DEBUG=false unless noted.
CORS (browser API calls)
Shellui customer shells run on unknown domains and call /hosting/v1/* from the browser with a Bearer JWT in the Authorization header. A static CORS_ALLOWED_ORIGINS list does not scale for multi-tenant hosting previews.
Default (recommended): CORS_ALLOW_ALL_ORIGINS=true with CORS_ALLOW_CREDENTIALS=false. API auth is Bearer JWT, not cookies — permissive CORS is intentional (Supabase-style).
Token delivery after OAuth login is not governed by CORS. Identity-service CompanyOAuthRedirect remains the strict boundary for redirect targets.
Lock-down (optional): set CORS_ALLOW_ALL_ORIGINS=false and list first-party origins in CORS_ALLOWED_ORIGINS.
Blocked at startup: CORS_ALLOW_ALL_ORIGINS=true with CORS_ALLOW_CREDENTIALS=true — wildcard origins cannot safely carry credentials.
Rate limiting
Cache-backed limits apply to abuse-prone hosting endpoints (per authenticated user, falling back to client IP):
| Scope | Default | Endpoints |
|---|---|---|
deploy | 30/min | POST /hosting/v1/preview, POST …/deployments, finalize, rollback |
upload | 20/min | PUT …/deployments/{id}/upload |
destructive | 10/min | DELETE /hosting/v1/apps/{ref} |
access_request | 10 per 5 min | POST /hosting/v1/access/request |
Tune with HOSTING_RATE_LIMIT_* env vars or set HOSTING_RATE_LIMIT_ENABLED=false to disable (not recommended in production).
Limits use Django cache (REDIS_URL when set, otherwise in-process LocMem). With GUNICORN_WORKERS > 1 (Docker default 2), set REDIS_URL so counters are shared across workers. Example: redis://redis:6379/0. manage.py check --deploy emits authapi.W001 when production uses LocMem with multiple workers.
Waitlist bypass (HOSTING_DEBUG_OPEN)
Fail-closed: only an explicit truthy env value skips the company waitlist. DEBUG=true does not auto-enable bypass.
Blocked at startup: HOSTING_DEBUG_OPEN=true when DEBUG=false.
JWT verification (production)
When DEBUG=false:
IDENTITY_ISSUERandIDENTITY_AUDIENCEare required (iss/aud validation).- JWKS must be pinned via
IDENTITY_JWKS_FILEorIDENTITY_JWKS. Runtime fetch fromIDENTITY_JWKS_URLis for local/dev only.
See claim-trust.md for privileged JWT claims (is_staff, is_company_owner, pat_agm).
HTTPS, HSTS, and secure cookies
When DEBUG=false:
SECURE_SSL_REDIRECT=true— redirect HTTP to HTTPS (disable only behind TLS-terminating proxies that handle redirects)SECURE_HSTS_SECONDS=31536000(1 year)SESSION_COOKIE_SECURE=true,CSRF_COOKIE_SECURE=true
Override any flag via env (see .env.example).
Postgres SSL
When POSTGRES_DATABASE_URL is set and DEBUG=false, connections use ssl_require=true by default. Set POSTGRES_SSL_REQUIRE=false only for local Postgres without TLS (e.g. Coolify internal DB on a private network).
Django admin isolation
The Django admin UI is cross-tenant (local Django users, not JWT company scope). Operational controls:
- Network lock: expose
/admin/only on an internal hostname or VPN; do not publish it on the public hosting apex. - MFA: enforce MFA on admin accounts at the identity / SSO layer where staff authenticate.
- Disable when unused: set
DJANGO_ADMIN_ENABLED=falseto remove admin routes entirely.
Trusted proxies and client IP
Rate limits derive client IP from REMOTE_ADDR unless the direct peer is listed in TRUSTED_PROXY_IPS (comma-separated IPs or CIDRs). When the peer is trusted, the service walks X-Forwarded-For from the right (closest to hosting-service), skips hops that match TRUSTED_PROXY_IPS (including CIDR ranges), and uses the first untrusted address as the client IP. That ignores a client-controlled leftmost spoof entry when Traefik, Coolify, or nginx append the real chain.
Example (nginx on the same host):
TRUSTED_PROXY_IPS=127.0.0.1,::1
Example (private load balancer subnet):
TRUSTED_PROXY_IPS=10.0.0.0/8
Example (Coolify / Traefik forwarding to Gunicorn): list the Traefik container or ingress subnet in TRUSTED_PROXY_IPS so REMOTE_ADDR is the proxy while the client IP is taken from the rightmost untrusted X-Forwarded-For hop.
Without trusted proxies, clients cannot spoof audit IPs by sending X-Forwarded-For directly; the app uses REMOTE_ADDR only.
IPv6 rate limits bucket by /64 prefix; logs and audit use the full address from get_client_ip.