place

Security

place ships with security defaults that are on by default. The presets are named values, not opt-in arrays of headers to maintain — pick 'standard' or 'strict', and the framework wires CSP, CSRF, same-origin enforcement, body size limits, and prototype-pollution guards in one shot.

Pick a preset

ts
app({ pages: [...], security: 'standard', // 'standard' | 'strict' | 'off' | { ...custom } }).start()

What 'standard' enables

ts
// security: 'standard' enables: // - Content-Security-Policy (strict, no inline scripts/styles) // - X-Content-Type-Options: nosniff // - X-Frame-Options: DENY // - Referrer-Policy: strict-origin-when-cross-origin // - Permissions-Policy: deny camera/mic/geo/USB/payment/etc. // - Cross-Origin-Opener-Policy: same-origin // - Auto-CSRF token injection + same-origin enforcement on actions // - 1 MB body-size limit on action() bodies // - Prototype-pollution guard (rejects __proto__, constructor, prototype keys)

What 'strict' adds

ts
// security: 'strict' adds: // - Cross-Origin-Embedder-Policy: require-corp (tightens to SAB-eligible) // - Stricter CSP (no 'unsafe-inline' fallbacks; no eval) // - 256 KB body-size limit on action() bodies

Content-Security-Policy

place's strict CSP is the framework's first-class output, not an afterthought. The directives that ship under 'standard':

text
// What ships when security: 'standard': Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<random>'; style-src 'self' 'nonce-<random>'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';
Per-response hashes, never 'unsafe-inline'
script-src uses per-request nonces; the framework's SPA-nav runtime is the only inline script and it carries the request nonce. style-src uses per-response SHA-256 hashes for any inline style="…" attribute the SSR actually emitted — so the directive is tight without breaking author-written inline styles. Reactive style:* bindings still write through setProperty() on hydration and need no hash.

How inline styles + style:* directives stay CSP-safe

SSR renders inline style="…" attrs verbatim, but the framework collects every value it emits during the render and adds 'unsafe-hashes' 'sha256-<hash>' to the response's style-src. ISR cache hits reuse the same hash list, so the CSP is byte-stable across cache + live renders. Reactive bindings stay out of the SSR'd HTML entirely.

ts
// Inline style="…" attributes that SSR emits are CSP-safe by // per-response hash injection. The framework collects every inline // style attribute value while rendering, SHA-256 hashes them, and // adds 'unsafe-hashes' + each 'sha256-<hash>' to the response's // style-src CSP directive. Pages with no inline styles ship a // tight 'self'-only style-src; pages that do ship exactly the hashes // they need. <div style={`color: red;`}>Hi</div> // SSR emits: <div style="color: red;">Hi</div> // CSP gets: style-src 'self' 'unsafe-hashes' 'sha256-<hash>'; // Reactive style bindings (style:transform, style:opacity) write // through element.style.setProperty() at hydration — no inline // attribute, no hash needed. <div style:transform={() => `translateX(${x()}px)`} /> // → el.style.setProperty('transform', `translateX(${x()}px)`)

Auto-CSRF

State-changing routes (POST / PUT / DELETE) verify a same-origin CSRF token by default. The token issuance is zero-config — return a csrf field from load() and the framework injects a <meta name="csrf-token"> into the head; the client transport reads it back when calling actions.

ts
// Auto-CSRF: when load() returns { csrf }, the framework injects a // <meta name="csrf-token"> in the SSR'd <head>. <Form> and // action.call() auto-read it. Zero developer wiring. load: async () => ({ csrf: await issueCsrfToken(), user: await getUser(), }) // On the client, the action handler verifies the token automatically: const updateProfile = action({ path: '/profile/update', input: shape({ name: 'string' }), fn: async (input) => { /* token already validated */ }, })

Same-origin enforcement

action() handlers reject cross-origin requests unless the origin is in the allowlist. Override the default if you legitimately need cross-origin requests:

ts
// Same-origin: state-changing actions reject cross-origin requests // by default. Set the allowed origins in app() if you need to // allowlist a specific host: app({ pages: [...], security: { preset: 'standard', sameOrigin: ['https://app.example.com', 'https://staging.example.com'], }, }).start()

Body-size + prototype-pollution guards

action() enforces a body-size limit (1 MB on 'standard', 256 KB on 'strict') before any user code runs. JSON parsing rejects keys named __proto__, constructor, or prototype — the single-line patch that closes the entire class of prototype-pollution exploits.

Don't relax for ergonomics
The security defaults stay on for a reason. If a feature in your app fights with 'standard', that's signal — the framework documents the failure mode at the violation site (CSP report, body-too-large 413, CSRF reject 403). Read those, don't relax the defaults.

High-assurance actions — criticalAction()

For mutations where being wrong matters (payments, role changes, deletions, anything compliance audits), action()'s same-origin + CSRF defaults are not enough. A valid CSRF token can be replayed; a same-origin XSS bug can still drive an action() handler with any body. criticalAction() raises every request to a signed HMAC envelope that binds the session, origin, action, body bytes, and a monotonic counter — and gates execution behind macaroon-based capability checks.

ts
// criticalAction() — the high-assurance sibling of action(). Same // author shape; every request is verified against an HMAC envelope // BEFORE the handler body runs. ADR 0055; OWASP ASVS 5.0 V11/V13; // NIST SP 800-53 Rev 5 AU-10 + SI-7; RFC 4303 (IPsec ESP anti-replay). // // Wire format (browser → server): // // POST /__a/transferFunds // X-Place-Envelope: <base64url(canonical)>.<base64url(tag)> // X-Place-Macaroon: <serialised macaroon> ← when requires: is set // Content-Type: application/json // // {"to":"acct_123","amountCents":5000} // // The canonical envelope binds: // v=1 // action_id — METHOD + path; envelope minted for /deposit fails on /withdraw // body_hash — sha256(body bytes); flipping a byte in the body fails // counter — monotonic per session; replay is rejected // iat — issued-at unix seconds; maxAgeSec bounds the window // origin — request origin; cross-site reuse fails // session_id — session.id; user A's envelope rejected for user B // key_id — daily-rotation key id import { criticalAction, perm } from '@place-ts/component/server' export const transferFunds = criticalAction({ path: 'POST /__a/transfer', input: shape({ to: 'string', amountCents: 'number' }), requires: [perm('payments.transfer')], fn: async (input, { session }) => { // session GUARANTEED non-null. Envelope verified. Macaroon checked. await ledger.transfer(session.userId, input.to, input.amountCents) return { ok: true } }, })

The pipeline that runs before fn:

  1. Same-origin guard.
  2. Body-size pre-check (Content-Length).
  3. SessionCap required (framework throws at app-boot if absent).
  4. Envelope verify: constant-time HMAC compare, then check action_id, origin, session_id, body_hash, freshness window.
  5. Replay defense via IPsec ESP sliding-window counter store (RFC 4303).
  6. Macaroon verify (when requires is set) — caveat chain + op-intersection check.
  7. JSON parse + prototype-pollution guard.
  8. Schema validate.
  9. Run fn; auto-append entry to the hash-chained audit log; return JSON.

Total added crypto: ~5–15 µs per request. Every failure returns 403 with identical body bytes (typed reason logged server-side, not on the wire). See criticalAction() API ref for the full surface; ADR 0055 has the threat model + standards mapping.

Compared to other frameworks

Honest table — what place ships out of the box vs Next.js, SvelteKit, Astro, and Remix. The comparison is "default behaviour" (what you get with a fresh scaffold) not "what's possible with a third-party library and some glue."

FeatureplaceNext.jsSvelteKitAstroRemix
CSP out of the boxstrict, per-request noncemanualmanualbuilt-in (nonce)manual
CSRF auto-enabledsame-origin always; tokens opt-inSameSite + manualcsurf pkgmanualsession-based
Prototype-pollution guardaction boundarymanualmanualmanualmanual
Body-size limit on mutations1 MB (standard) / 256 kB (strict)manualmanualmanualmanual
Permissions-Policy (deny-all)17 features blockedmanualmanualmanualmanual
Secure cookie defaultsHttpOnly · Secure · LaxpartialpartialpartialHttpOnly · Secure · Lax
HMAC-signed action envelopescriticalAction()nononono
Macaroon bearer tokensyes, attenuating caveatsnononono
Append-only audit log (Phase 4)tamper-evident, hash-chainednononono
Rate limiting (in-memory)primitive shipsnononono
IP-based bot detection / DDoSno — deploy layernononono
Honest scope: what's NOT in the framework
place doesn't try to be a bot-mitigation product. There's no per-IP rate limiting, no Cloudflare-grade DDoS layer, no managed secret rotation, no SQL injection wrapper (delegated to bun:sqlite's .prepare()). Those are deployment / vendor responsibilities; the framework provides honest primitives (signed tokens, audit log, rate-limit token bucket, capability scopes) that compose with whatever you put in front.