Decouple CSRF origin from proxy trust via PUBLIC_ORIGIN; drop 0.0.0.0/0 from templates#14
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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()walksX-Forwarded-Forright-to-left and returns the nearest untrusted IP, so0.0.0.0/0degrades 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, soexpectedOrigin()builthttp://hostwhile the browser senthttps://host→Forbidden: 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-Prototrust is removed from the CSRF path entirely.TRUSTED_PROXY_CIDRSsurvives but is now used only for client-IP attribution inclientIp()(audit logs / rate-limit keys).One-click deploys stay one-click via platform auto-detection — no value the user must type:
RENDER_EXTERNAL_URL→ used directly.RAILWAY_PUBLIC_DOMAIN→https://${RAILWAY_PUBLIC_DOMAIN}. Templates setPUBLIC_ORIGIN=https://${{RAILWAY_PUBLIC_DOMAIN}}explicitly for clarity / custom-domain edits.Code
server/config.ts): addpublicOrigins+resolvePublicOrigins(env)(precedence:PUBLIC_ORIGINCSV →RENDER_EXTERNAL_URL/https://RAILWAY_PUBLIC_DOMAIN→[]) and a shared, exportednormalizeOrigin()(URL-based, no regex, noas).server/auth/security.ts):expectedOriginreturns the configured canonical origin (Host/req.urlfallback when none configured);originAllowedmatches the full configured allowlist + dev allowlist, normalizing both sides; addedconfigurePublicOrigins/publicOriginIsHttps; deleted the now-unusedtrustedForwardedHeader.clientIp/stampSocketIp/ trusted-proxy logic unchanged.server/forms/handler.ts): replaced the inline CSRF duplicate (which ignored the multi-origin allowlist) with the sharedoriginAllowed(req)— forms now semantically identical to the admin/AI check.server/handlers/cms/session.ts):requestIsHttpsderivesSecurefrom the public-origin scheme viapublicOriginIsHttps(); dropped thex-forwarded-protoread.server/index.ts):configurePublicOrigins(config.publicOrigins).Templates / docs
TRUSTED_PROXY_CIDRS=0.0.0.0/0,::/0from both Render blueprints and the Railway / docker-image snippets; Railway/Caddy now setPUBLIC_ORIGIN, Render relies on auto-detect.compose.tls.ymlsetsPUBLIC_ORIGIN=https://${DOMAIN}and reframesTRUSTED_PROXY_CIDRSas attribution-only..env.production.example. Every0.0.0.0/0mention left is now an explicit "never use this" warning; no stale "set TRUSTED_PROXY_CIDRS for CSRF" guidance remains.Tests
resolvePublicOrigins+normalizeOrigin(newserverConfig.test.ts).expectedOriginignores spoofed forwarded headers even from a trusted proxy; falls back to Host/req.url.originAllowedmatches multiple configured origins; rejects mismatches; dev allowlist intact.Secureeven whenreq.urlis http and nox-forwarded-proto.clientIpregression guard (unchanged behavior).Verification
bun run build— pass (tsc + vite)bun run lint— cleanbun test— 5019 pass / 0 fail (533 files)Judgment calls
PUBLIC_ORIGINaccepts a comma-separated list so platform + custom domain coexist; docs tell custom-domain users to append their origin.PUBLIC_ORIGIN=https://${DOMAIN}as THE CSRF mechanism;TRUSTED_PROXY_CIDRSreframed as the optional Caddy-CIDR attribution knob.trustedForwardedHeaderhad no callers besidesexpectedOrigin; the AI and CMS handlers already sharedoriginAllowed, and forms was the only inline duplicate.🤖 Generated with Claude Code