Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ bun scripts/smoke-prod.ts # READ-ONLY live checks vs production

- **CORS split (#35, intentional divergence from the hub)**: wildcard-uncredentialed on `/oauth/token` + `/oauth/revoke`, reflected-Origin+credentials on `/oauth/register`, **no CORS headers at all on `/oauth/authorize`** (pinned by a negative conformance test). The hub applies echo-Origin+credentials uniformly across `/oauth/*`; the cloud split is strictly more conservative, and every wire BODY stays hub-identical. Both CORS helpers expose `WWW-Authenticate` (hub parity). Canonical doc: the posture block in `workers/identity/src/oauth-shared.ts`.
- **KDF (#28, the honest version)**: workerd caps PBKDF2 at 100k iterations and ships no argon2 — 100k is the runtime's ceiling and we don't pretend otherwise. Verifiers are PBKDF2-**SHA512**@100k (versioned format; legacy sha256 hashes verify and transparently re-hash on next successful password login). The actual defenses are architectural: passwords are an **optional secondary** factor (magic-link primary), the DO rate limiter (#30) blunts online guessing, TOTP 2FA is available, and the D1 password store never crosses the wire. Full note: `workers/identity/src/users.ts`.
- **CSP (P0.2, 0.0.8-rc.53)**: EVERY server-rendered page carries a `Content-Security-Policy`, attached at the single HTML choke point `htmlResponse` (`oauth-shared.ts`) — so JSON/machine endpoints (token/JWKS/webhook) never get one. Strict ceremony policy: `default-src 'none'`; `script-src 'self' 'nonce-<per-response>'` (NO `'unsafe-inline'` for script — the one inline script, the console's create-moment/clipboard/checklist JS, is admitted only by a **per-response 128-bit nonce**); `style-src 'self' 'unsafe-inline'` (the pages carry inline `<style>` blocks AND 100+ inline `style="…"` attributes — a nonce covers `<style>` ELEMENTS but not style attributes, so `'unsafe-inline'` for styles is the pragmatic, low-risk choice; refactoring the attribute styles out is a later cleanup); `connect-src 'self'` (the console script's same-origin fetches); `form-action 'self'` (every form posts same-origin — billing's checkout form posts same-origin then the worker 302s to Stripe, which form-action does NOT govern); `img-src 'self' data:`, `frame-ancestors 'none'`, `base-uri 'none'`, `object-src 'none'`. **The nonce mechanism**: a template stamps the marker `NONCE_ATTR` (= `nonce="__CSP_NONCE__"`) on inline `<script>`; `htmlResponse` mints one nonce per response, substitutes it into the body, and emits the matching `'nonce-…'` in the header — header and body agree by construction. Injection-safe: `esc()` turns every `"` into `&quot;`, so the marker (which needs literal double-quotes) can only come from a trusted template, never user data. **Per-route ready (PW4 two-tier connect-src)**: `contentSecurityPolicy(nonce, opts)` RETURNS the policy string (not a constant), so Phase 1's SPA route (served from Static Assets on the same origin) can widen `connect-src` to the vault REST + live-query WS origins without touching the ceremony default. Pinned by `test/csp.test.ts` (the policy helper, the nonce threading, injection safety, and a render-scan of every ceremony page for the one thing a naive CSP breaks — an un-nonced inline `<script>`).

## Critical rule: shared core + shared wire contract, forked runtime

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openparachute/cloud",
"version": "0.0.8-rc.52",
"version": "0.0.8-rc.53",
"private": true,
"description": "Open Parachute PBC's Vault Cloud \u2014 one Durable Object per vault on Cloudflare, OAuth issuer + self-serve console (accounts + vault ownership).",
"license": "AGPL-3.0",
Expand Down
86 changes: 84 additions & 2 deletions workers/identity/src/oauth-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/
import { VAULT_VERBS } from "./audience.ts";
import { billingConfig, mockBillingEnabled } from "./billing-config.ts";
import { randomBase64url } from "./crypto.ts";
import type { Env } from "./env.ts";
import type { RateLimiterNamespace } from "./rate-limit.ts";

Expand Down Expand Up @@ -231,10 +232,91 @@ export function jsonResponse(body: unknown, status = 200, extra: Record<string,
});
}

/**
* CONTENT-SECURITY-POLICY (P0.2) — every server-rendered page carries one.
*
* The marker a template stamps on an inline `<script>` so {@link htmlResponse}
* can swap it for the response's real nonce: `<script ${NONCE_ATTR}>`. The
* surrounding double-quotes are LOAD-BEARING for injection safety — `esc()`
* turns every `"` in user-supplied content into `&quot;`, so this exact byte
* sequence can only originate from a trusted template literal, never from user
* data. That's why the substitution below (which grants a nonce) can never be
* spoofed into nonce-ing an attacker-supplied `<script>`: an attacker cannot
* emit a raw `"` to forge the marker.
*/
export const NONCE_ATTR = 'nonce="__CSP_NONCE__"';

export interface CspOptions {
/**
* `connect-src` sources (defaults to `['self']`). The ceremony pages this PR
* governs only fetch same-origin (the console script's `/console/*` calls), so
* the strict default fits. Kept an OPTION, not a constant, for PW4's two-tier
* connect-src: Phase 1's SPA route (served from Static Assets on the same
* origin) will widen this to the vault REST + live-query WS origins —
* `contentSecurityPolicy(nonce, { connectSrc: ["'self'", "https://u.parachute.computer", "wss://u.parachute.computer"] })`.
*/
connectSrc?: readonly string[];
}

/**
* The Content-Security-Policy for a server-rendered page. Returns the policy
* STRING (not a hardcoded constant) so a per-route caller can widen it without
* touching the strict ceremony default.
*
* The posture:
* - `default-src 'none'` — deny by default; every source below is explicit.
* - `script-src 'self' 'nonce-…'` — the one inline script (the console's
* create-moment/clipboard/checklist JS) is admitted ONLY by a per-response
* nonce; NO `'unsafe-inline'` for script (the protection that matters).
* `'self'` is forward-compat for Phase 1's bundled SPA JS.
* - `style-src 'self' 'unsafe-inline'` — the pages carry inline `<style>`
* blocks AND 100+ inline `style="…"` attributes. A nonce covers `<style>`
* ELEMENTS but NOT style attributes, so `'unsafe-inline'` is required unless
* all attribute styles are refactored out (a later cleanup, not this PR).
* Style injection is far lower-risk than script injection — accepted.
* - `img-src 'self' data:` — no `<img>` today (the TOTP QR is inline `<svg>`),
* kept for favicons + forward-compat.
* - `connect-src` — see {@link CspOptions.connectSrc}.
* - `form-action 'self'` — every form POSTs same-origin (billing's checkout
* form posts same-origin, THEN the worker 302s to Stripe — form-action
* governs the form's target, not the server redirect, so Checkout is safe).
* - `frame-ancestors 'none'` / `base-uri 'none'` / `object-src 'none'` —
* no framing, no `<base>`, no plugins.
*/
export function contentSecurityPolicy(nonce: string, opts: CspOptions = {}): string {
const connectSrc = opts.connectSrc ?? ["'self'"];
return [
"default-src 'none'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
`connect-src ${connectSrc.join(" ")}`,
"form-action 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"object-src 'none'",
].join("; ");
}

/**
* Every HTML response funnels through here, so this is where the CSP header is
* attached and the per-response nonce is minted + threaded into the body. A
* fresh 128-bit nonce is generated per response; the header's `'nonce-…'` and
* the `<script nonce="…">` in the body are the SAME value by construction (one
* generation, substituted in both), so they can never drift. Pages with no
* inline script simply have no marker to substitute — they still carry the
* header (an unreferenced nonce is harmless).
*/
export function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
return new Response(body, {
const nonce = randomBase64url(16);
const html = body.includes(NONCE_ATTR) ? body.replaceAll(NONCE_ATTR, `nonce="${nonce}"`) : body;
return new Response(html, {
status,
headers: { "content-type": "text/html; charset=utf-8", ...extra },
headers: {
"content-type": "text/html; charset=utf-8",
"content-security-policy": contentSecurityPolicy(nonce),
...extra,
},
});
}

Expand Down
6 changes: 5 additions & 1 deletion workers/identity/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
vaultCapMessage,
} from "./plans.ts";
import { type BillingInterval } from "./billing-config.ts";
import { NONCE_ATTR } from "./oauth-shared.ts";

export interface AuthorizeParams {
clientId: string;
Expand Down Expand Up @@ -921,7 +922,10 @@ function firstRunHero(csrfToken: string, values: FirstRunValues, error?: string)
* stranded in the "building" state.
*/
function consoleScript(csrfToken: string): string {
return `<script>(function(){
// The nonce marker is swapped for the response's real CSP nonce in
// htmlResponse (oauth-shared.ts). Any future inline <script> MUST carry
// ${NONCE_ATTR} or the CSP will block it — the csp.test.ts scan pins that.
return `<script ${NONCE_ATTR}>(function(){
var CSRF=${JSON.stringify(csrfToken)};
document.addEventListener("click",function(e){
var b=e.target&&e.target.closest?e.target.closest("[data-copy]"):null;
Expand Down
Loading