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 }
}).start()
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'],
},
}).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.
'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.
// 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:
- Same-origin guard.
- Body-size pre-check (Content-Length).
- SessionCap required (framework throws at app-boot if absent).
- Envelope verify: constant-time HMAC compare, then check
action_id,origin,session_id,body_hash, freshness window. - Replay defense via IPsec ESP sliding-window counter store (RFC 4303).
- Macaroon verify (when
requiresis set) — caveat chain + op-intersection check. - JSON parse + prototype-pollution guard.
- Schema validate.
- 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."
| Feature | place | Next.js | SvelteKit | Astro | Remix |
|---|---|---|---|---|---|
| CSP out of the box | strict, per-request nonce | manual | manual | built-in (nonce) | manual |
| CSRF auto-enabled | same-origin always; tokens opt-in | SameSite + manual | csurf pkg | manual | session-based |
| Prototype-pollution guard | action boundary | manual | manual | manual | manual |
| Body-size limit on mutations | 1 MB (standard) / 256 kB (strict) | manual | manual | manual | manual |
| Permissions-Policy (deny-all) | 17 features blocked | manual | manual | manual | manual |
| Secure cookie defaults | HttpOnly · Secure · Lax | partial | partial | partial | HttpOnly · Secure · Lax |
| HMAC-signed action envelopes | criticalAction() | no | no | no | no |
| Macaroon bearer tokens | yes, attenuating caveats | no | no | no | no |
| Append-only audit log (Phase 4) | tamper-evident, hash-chained | no | no | no | no |
| Rate limiting (in-memory) | primitive ships | no | no | no | no |
| IP-based bot detection / DDoS | no — deploy layer | no | no | no | no |
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.