Skip to content

Decouple CSRF origin from proxy trust via PUBLIC_ORIGIN; drop 0.0.0.0/0 from templates#14

Merged
DavidBabinec merged 1 commit into
mainfrom
fix/csrf-public-origin
Jun 10, 2026
Merged

Decouple CSRF origin from proxy trust via PUBLIC_ORIGIN; drop 0.0.0.0/0 from templates#14
DavidBabinec merged 1 commit into
mainfrom
fix/csrf-public-origin

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

Security finding

A review flagged the Render/Railway deploy templates shipping TRUSTED_PROXY_CIDRS=0.0.0.0/0,::/0 — blanket trust of every forwarding header from any peer.

The auth path was never spoof-bypassable. clientIp() walks X-Forwarded-For right-to-left and returns the nearest untrusted IP, so 0.0.0.0/0 degrades to the real socket peer rather than trusting a forged leftmost value. The real reason for the broad value was that blanket proxy trust was the only way the CSRF origin check worked behind a TLS-terminating edge: the container sees plain HTTP, so expectedOrigin() built http://host while the browser sent https://hostForbidden: invalid origin.

Single-mechanism fix

The CSRF origin check now derives the expected public origin only from a configured / auto-detected public origin. The X-Forwarded-Host / X-Forwarded-Proto trust is removed from the CSRF path entirely. TRUSTED_PROXY_CIDRS survives but is now used only for client-IP attribution in clientIp() (audit logs / rate-limit keys).

One-click deploys stay one-click via platform auto-detection — no value the user must type:

  • Render: auto-injects RENDER_EXTERNAL_URL → used directly.
  • Railway: auto-injects RAILWAY_PUBLIC_DOMAINhttps://${RAILWAY_PUBLIC_DOMAIN}. Templates set PUBLIC_ORIGIN=https://${{RAILWAY_PUBLIC_DOMAIN}} explicitly for clarity / custom-domain edits.

Code

  • config (server/config.ts): add publicOrigins + resolvePublicOrigins(env) (precedence: PUBLIC_ORIGIN CSV → RENDER_EXTERNAL_URL / https://RAILWAY_PUBLIC_DOMAIN[]) and a shared, exported normalizeOrigin() (URL-based, no regex, no as).
  • security (server/auth/security.ts): expectedOrigin returns the configured canonical origin (Host/req.url fallback when none configured); originAllowed matches the full configured allowlist + dev allowlist, normalizing both sides; added configurePublicOrigins / publicOriginIsHttps; deleted the now-unused trustedForwardedHeader. clientIp / stampSocketIp / trusted-proxy logic unchanged.
  • forms (server/forms/handler.ts): replaced the inline CSRF duplicate (which ignored the multi-origin allowlist) with the shared originAllowed(req) — forms now semantically identical to the admin/AI check.
  • session (server/handlers/cms/session.ts): requestIsHttps derives Secure from the public-origin scheme via publicOriginIsHttps(); dropped the x-forwarded-proto read.
  • boot (server/index.ts): configurePublicOrigins(config.publicOrigins).

Templates / docs

  • Removed TRUSTED_PROXY_CIDRS=0.0.0.0/0,::/0 from both Render blueprints and the Railway / docker-image snippets; Railway/Caddy now set PUBLIC_ORIGIN, Render relies on auto-detect.
  • compose.tls.yml sets PUBLIC_ORIGIN=https://${DOMAIN} and reframes TRUSTED_PROXY_CIDRS as attribution-only.
  • Updated README, render.md, railway.md, docker-image.md, vps.md, tls-caddy.md, server.md, auth-and-access.md, .env.production.example. Every 0.0.0.0/0 mention left is now an explicit "never use this" warning; no stale "set TRUSTED_PROXY_CIDRS for CSRF" guidance remains.

Tests

  • resolvePublicOrigins + normalizeOrigin (new serverConfig.test.ts).
  • expectedOrigin ignores spoofed forwarded headers even from a trusted proxy; falls back to Host/req.url.
  • originAllowed matches multiple configured origins; rejects mismatches; dev allowlist intact.
  • Forms CSRF regression: an Origin matching a non-canonical configured origin passes (guards the allowlist bug).
  • Cookie Secure: https public origin ⇒ Secure even when req.url is http and no x-forwarded-proto.
  • clientIp regression guard (unchanged behavior).

Verification

  • bun run build — pass (tsc + vite)
  • bun run lint — clean
  • bun test — 5019 pass / 0 fail (533 files)

Judgment calls

  • Custom domains: PUBLIC_ORIGIN accepts a comma-separated list so platform + custom domain coexist; docs tell custom-domain users to append their origin.
  • vps.md / compose.tls.yml: Caddy path now sets PUBLIC_ORIGIN=https://${DOMAIN} as THE CSRF mechanism; TRUSTED_PROXY_CIDRS reframed as the optional Caddy-CIDR attribution knob.
  • Other consumers of the removed forwarded-header path: none — trustedForwardedHeader had no callers besides expectedOrigin; the AI and CMS handlers already shared originAllowed, and forms was the only inline duplicate.

🤖 Generated with Claude Code

A security review flagged the Render/Railway deploy templates shipping
TRUSTED_PROXY_CIDRS=0.0.0.0/0,::/0. The auth path was never spoof-
bypassable (clientIp walks X-Forwarded-For right-to-left and returns the
nearest untrusted hop), but blanket proxy trust was the only way the CSRF
origin check worked behind a TLS-terminating edge.

Single-mechanism fix: the CSRF origin check now derives the expected
public origin ONLY from a configured/auto-detected public origin, and the
X-Forwarded-Host/Proto trust is removed from the CSRF path entirely.

- config: add publicOrigins + resolvePublicOrigins(env) (PUBLIC_ORIGIN CSV,
  else RENDER_EXTERNAL_URL / https://RAILWAY_PUBLIC_DOMAIN, else []) and a
  shared normalizeOrigin() (URL-based, no regex/as).
- security: expectedOrigin returns the configured canonical origin (Host
  fallback when unset); originAllowed matches the full configured allowlist
  + dev allowlist, normalizing both sides; delete trustedForwardedHeader.
  TRUSTED_PROXY_CIDRS / clientIp now serve client-IP attribution only.
- forms: replace the inline CSRF duplicate with the shared originAllowed so
  the multi-origin allowlist is honoured (was a bug).
- session: requestIsHttps derives Secure from the public origin scheme via
  publicOriginIsHttps(); drop the x-forwarded-proto read.
- boot: configurePublicOrigins(config.publicOrigins) in server/index.ts.
- templates/docs: drop 0.0.0.0/0 everywhere; Render auto-detects
  RENDER_EXTERNAL_URL, Railway/Caddy templates set PUBLIC_ORIGIN; reframe
  TRUSTED_PROXY_CIDRS as the optional attribution-only knob.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@DavidBabinec DavidBabinec merged commit c32972a into main Jun 10, 2026
6 checks passed
@DavidBabinec DavidBabinec deleted the fix/csrf-public-origin branch June 10, 2026 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant