Skip to content

Origin-header CSRF defense so SSR pages are CDN-cacheable #659

@vivek7405

Description

@vivek7405

Problem

Every webjs SSR HTML response carries a per-request CSRF Set-Cookie (webjs_csrf, see packages/server/src/csrf.js + the issuance in packages/server/src/ssr.js). This makes webjs SSR pages effectively un-cacheable at a CDN: a CDN skips caching any response with Set-Cookie, and if it did cache one, every visitor would share the same token, defeating the protection. So a webjs page cannot sit on a Cloudflare edge the way a Next.js / Astro / SvelteKit / Remix page can.

This is the dominant first-paint cost in production. Measured on the live webjs.dev (Cloudflare in front of a Railway origin in ams1): cold TTFB is ~385ms, of which ~299ms is request-to-first-byte, dominated by the edge-to-origin round-trip (a non-AMS visitor pays the full transcontinental RTT on EVERY request because the HTML is cache-control: no-store). LCP is otherwise excellent (445ms warm, CLS 0.01), so the framework's no-build payload is lean. The single lever that cuts global TTFB to tens of ms is edge-caching the HTML, and the CSRF cookie is what blocks it.

Design / approach

Adopt the peer-standard model. Next.js (packages/next/src/server/app-render/action-handler.ts, isCsrfOriginAllowed), Astro (core/app/middlewares.ts, the checkOrigin option), SvelteKit, and Remix all defend Server Actions from CSRF by comparing the Origin header host to the request host (with an allowedOrigins allowlist escape hatch), NOT with a per-request token cookie. A cross-site attacker's forged POST carries the attacker's Origin (or none), so it is rejected without any token, which is exactly what lets their SSR/SSG HTML be CDN-cacheable.

Plan:

  1. Replace the double-submit verify() in csrf.js with Origin-header verification: the request's Origin (fall back to Referer) host must equal the request host, OR be in a configured webjs.allowedOrigins list. Missing Origin AND Referer follows Next's stance: warn-and-allow (a handcrafted request cannot carry a victim's SameSite cookies cross-site; auth/session cookies stay SameSite=Lax as defense-in-depth). Keep constant-time semantics where relevant.
  2. Stop issuing the webjs_csrf cookie on SSR responses (ssr.js htmlResponse / cachedHtmlResponse), and drop the client stub's __csrf() read + x-webjs-csrf header send (actions.js).
  3. Add a webjs.allowedOrigins config key (string[]), surfaced through the WebjsConfig type + the JSON Schema + configuration.md.
  4. With no Set-Cookie on HTML, a page that sets a public Cache-Control (via metadata.cacheControl) becomes genuinely CDN-cacheable. Then opt the four in-repo apps' static pages into Cache-Control: public, s-maxage=... (+ export const revalidate for the origin memo): website, docs, packages/ui/packages/website, examples/blog (the genuinely-static, visitor-identical pages only).

This keeps the route.ts REST boundary unchanged (already not CSRF-protected; carries its own auth), and CORS via cors() is unaffected.

Implementation notes (for the implementing agent)

  • Where to edit (framework):
    • packages/server/src/csrf.js: replace verify(req) (currently double-submit cookie-vs-header) with an origin-check verify(req, { allowedOrigins }). Keep parseCookies (other code may use it). Remove newToken / cookieHeader / CSRF_HEADER export usage once callers are migrated (grep first).
    • packages/server/src/ssr.js: htmlResponse() (~L324-348) and cachedHtmlResponse() (~L364-385) both append the CSRF Set-Cookie via readToken/newToken/cookieHeader. Remove that issuance. The BUFFERED_MARKER + conditional-GET funnel already only ETags non-no-store responses, so a now-cookieless public page flows through cleanly.
    • packages/server/src/actions.js: the CSRF gate in invokeAction (~L462-464, if (!SAFE_VERBS.has(method) && !verifyCsrf(req))) becomes the origin check; the generated client stub emits __csrf() (~L337) and sets CSRF_HEADER on body/url fetches (~L387, L404) — drop those.
    • packages/server/src/html-cache.js, context.js, testing.js/testing.d.ts: grep for CSRF_COOKIE / CSRF_HEADER / verifyCsrf / readToken and migrate/remove.
    • Config: add allowedOrigins to the webjs block parsing (packages/server/src/ config reader), the WebjsConfig type, and the JSON Schema.
  • Where to edit (apps): add export const revalidate = <seconds> + export const metadata = { cacheControl: 'public, max-age=0, s-maxage=<seconds>, stale-while-revalidate=86400' } to the static pages of website/app/, docs/app/docs/**, packages/ui/packages/website/app/, and examples/blog/app/ (only visitor-identical pages; never a page that reads cookies() / session).
  • Landmines:
    • The webjs_csrf cookie is ONLY for CSRF (it is NOT the auth/session cookie). Confirm with a repo-wide grep before removing, but auth lives separately in auth.js.
    • Origin check must handle: same-origin with Origin present (allow), cross-origin not in allowlist (403), Origin: null from sandboxed iframes (treat as cross-origin unless allowlisted, per Next), and BOTH Origin and Referer absent (warn-and-allow, matching Next, else legitimate non-browser clients break).
    • Behind a reverse proxy / CDN the host may arrive as x-forwarded-host; compare against that when present (Next uses x-forwarded-host precedence). webjs.dev sits behind Cloudflare, so get this right or same-origin actions 403 in prod.
    • A public Cache-Control page must NOT read per-user state; the existing #241 revalidate per-user guard (html-cache.js warnDynamicRevalidateOnce) is the model.
  • Invariants to respect: AGENTS.md "RPC + REST endpoint security" section; invariant 11 (prose punctuation) in any new docs; the published-package release rule.
  • Tests: unit (packages/server/test/**): origin-check accept/reject/allowlist/missing-origin/forwarded-host, with a counterfactual (cross-origin POST must 403 when the check is active, succeed if reverted). e2e (test/e2e/*): a server action still works same-origin in a real browser; the HTML response has NO Set-Cookie and a cacheable page emits public Cache-Control + a weak ETag (304 on If-None-Match). Smoke: the four apps still boot.
  • Docs: AGENTS.md (RPC security + the .server boundary notes), agent-docs/advanced.md (CSRF/security), agent-docs/configuration.md (webjs.allowedOrigins), agent-docs/built-ins.md (caching: SSR pages are now CDN-cacheable), the docs site configuration + caching/deployment pages. Invoke webjs-doc-sync.

Acceptance criteria

  • Action RPC rejects a cross-origin POST (Origin host != request host, not allowlisted) with 403, and accepts a same-origin one, with no cookie involved.
  • webjs.allowedOrigins allows a configured cross-origin caller; x-forwarded-host is honored behind a proxy.
  • Missing Origin AND Referer is warn-and-allowed (documented), not a hard 403.
  • SSR HTML responses carry NO Set-Cookie for CSRF; a page with metadata.cacheControl: 'public, ...' returns that header and is CDN-cacheable (weak ETag + 304 verified).
  • The four in-repo apps' static pages opt into public Cache-Control + revalidate; all four still boot and serve 200.
  • A counterfactual proves the origin check fires (cross-origin POST passes when reverted).
  • Tests at unit + e2e; docs synced across all surfaces.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions