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
app({
pages: [...],
security: 'standard', // 'standard' | 'strict' | 'off' | { ...custom }
}).run()
What 'standard' enables
// 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
// 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':
// 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';
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.
// 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.
// 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:
// 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'],
},
}).run()
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.
'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.