Appearance
Security
What protects the portal, stated as the code does it, including the gaps. Paths are relative to cloud/reform-assistant/. Related: Architecture, HTTP API, Data model.
Three kinds of caller
| Caller | Credential | Checked in |
|---|---|---|
| Family browser | ra_session cookie (server-side session) | src/auth.ts (requireUser) |
| Stripe | Stripe-Signature HMAC over the raw body | src/stripe_webhook.ts (verifySignature) |
| Hermes host | Authorization: Bearer <HERMES_LINK_TOKEN> | src/hermes.ts (authorized) |
Passwords
- Signup requires a password of at least 10 characters. There is no maximum, complexity rule or breached-password check.
- Hashing is Web Crypto PBKDF2, hash SHA-256, 100,000 iterations (
PASSWORD_ITERATIONS), a 16-byte random salt per user fromcrypto.getRandomValues, 256-bit output. Hash and salt are stored base64 inusers.password_hashandusers.password_salt. - Verification re-derives the key and compares in constant time.
- Login for an unknown email still runs one PBKDF2 derivation against a dummy salt and hash before answering
401 invalid_credentials, so an unknown email and a wrong password give the same response and similar timing. - Signup does reveal whether an email is registered (
409 email_taken). There is no rate limiting or lockout on login or signup. - Passwords and hashes are never returned by any endpoint;
userobjects contain onlyid,emailandname.
Sessions and cookie
- A session token is 32 random bytes, base64url without padding. Only its SHA-256 (base64) is stored, as
sessions.token_hash; a database read does not yield usable cookies. The token exists only in the cookie. - A session lasts 30 days (
expires_atis set at creation; the cookieMax-Ageis 2592000). Lookup requiresexpires_at > now; a cookie with no valid row has that hash deleted and is treated as logged out. - Expiry cleanup happens whenever a session is created (signup or login), with no scheduled job: all expired rows of that user, plus up to 100 expired rows of any user.
- Logout deletes the presented session row and clears the cookie. Other sessions of the same user stay valid; there is no "log out everywhere" and no password-change feature.
- Cookie attributes (
sessionCookieinsrc/http.ts):Path=/,HttpOnly,SameSite=Lax,Max-Age=2592000, andSecurewhen the request URL scheme ishttps:. The decision usesrequest.url, notAPP_URL, so production over HTTPS always getsSecureandhttp://localhostdevelopment does not. Clearing sendsMax-Age=0and a 1970Expires. HttpOnlykeeps the token away from page scripts; the frontend never reads it.
CSRF stance
There is no CSRF token and no Origin check. State-changing family endpoints rely on:
SameSite=Laxon the session cookie, so browsers do not send it on cross-sitePOST,PUTorDELETErequests;- JSON-only request bodies: handlers parse with
readJsonand answer400 invalid_jsonfor anything else. The server does not check theContent-Typeheader, so the protection is the cookie policy, not the content type.
Consequences: a same-site attacker (another subdomain of the site's registrable domain) is not stopped by SameSite=Lax, and old browsers that ignore SameSite are not protected. Deploy the portal on a domain whose sibling subdomains you control. The Stripe and Hermes endpoints are not cookie-authenticated and need no CSRF protection.
Open-redirect guard
safeNext in public/console/auth.js decides where login or signup sends the user after success (?next=):
- The value is resolved against
location.originwithnew URL(value, location.origin). - If the resulting origin differs (absolute URLs to other sites, protocol-relative
//host,javascript:and similar) the value is rejected and the user goes to/console/. - Otherwise only
pathname + search + hashis used, and it must start with/. nextis carried to the sibling login/signup link only after this check. Aplanparameter makes signup finish at/console/#billing; it is only used as a flag, never as a URL.
The console follows server-supplied redirect URLs (Stripe Checkout and billing portal) only when they start with http:// or https:// (goExternal in public/console/app.js). All API data is rendered with textContent, never innerHTML.
Hermes bearer token
- Requests to
/api/hermes/*needAuthorization: Bearer <token>with exactly one space-separated token. Missing, malformed or wrong gives401 unauthorized. - The token is compared with a constant-time loop over both byte arrays (length difference included).
- If
HERMES_LINK_TOKENis not configured on the Worker, every Hermes request is rejected with401; there is no default token. - The check runs before routing, so unknown
/api/hermes/...paths also need the token to get a404. - The same token is stored on the Hermes host in
$HERMES_HOME/securus/portal_link.token(keep itchmod 600); the bridge reads it from the file and never prints it (test_token_is_not_printed_by_cron_entry_point). Rotation steps are in the runbook.
Stripe webhook signature
POST /api/stripe/webhook accepts an event only after verifySignature passes:
STRIPE_WEBHOOK_SECRETmust be set, else503 billing_not_configured. Nothing is accepted unverified.- The raw body is read once with
request.text(); the signature covers exactly those bytes. - The
Stripe-Signatureheader is split on commas intot=<unix seconds>and one or morev1=<hex>values. Missing header, a non-numerict, or nov1fails. - The timestamp must be within 300 seconds of now (past or future). Older or newer fails (replay protection).
- The expected value is HMAC-SHA256 with the secret over
`${t}.${rawBody}`, computed with Web Crypto. - Each
v1is hex-decoded and compared in constant time; any match passes.
Failure answers 400 invalid_signature. After verification: invalid JSON or a missing id/type gives 400 invalid_event; the event id is reserved in stripe_events (replays answer duplicate: true) before any processing; if processing throws, the reservation is removed so Stripe's retry can succeed. The webhook does not check the event's livemode; use keys, prices and endpoint secrets from one Stripe mode (see the Stripe section).
Secrets
Never committed. Set with npx wrangler secret put <NAME> on the Worker, or in .dev.vars locally (git-ignored; .dev.vars.example lists the names).
| Name | Purpose | Used in |
|---|---|---|
STRIPE_SECRET_KEY | Stripe REST calls (Checkout, portal, subscription re-fetch, duplicate cancel). Sent only as a bearer to https://api.stripe.com/v1/. | src/billing.ts, src/stripe_webhook.ts |
STRIPE_WEBHOOK_SECRET | Webhook signature verification (whsec_...). | src/stripe_webhook.ts |
STRIPE_PRICE_MONTHLY, STRIPE_PRICE_QUARTERLY, STRIPE_PRICE_ANNUAL | Stripe Price ids for the three plans. Not credentials, but kept as secrets so test and live values are swapped together. | src/billing.ts, src/stripe_webhook.ts |
HERMES_LINK_TOKEN | Bearer token for the Hermes link. | src/hermes.ts |
APP_URL is a plain (non-secret) variable in wrangler.jsonc; it only builds Stripe return URLs. The bridge adds REFORM_PORTAL_URL (http or https, no embedded credentials) on the Hermes host.
What is never logged
- The Worker contains no
console.*calls: it does not log request bodies, cookies, tokens, passwords, secrets or Stripe payloads. - Error responses are generic. Unexpected errors become
500 internal_errorwith a fixed message. Stripe failures become502 stripe_errorwith a fixed message; Stripe's error body is not forwarded to the client. - The raw Stripe event body is kept only in R2 (
stripe/events/), not in logs. The one place a Stripe response body is stored iscancellation_responseinside astripe/duplicates/record; treat the bucket as sensitive. - The bridge prints a one-line JSON summary of counts on stdout and fixed, generic strings for reported errors on stderr (for example
Could not connect one pending portal inmate.); it never prints the token. An unexpected exception at the cron boundary prints a Python traceback to stderr. - Unrelated to the Worker: Cloudflare's own platform logging and any observability you enable in the dashboard are outside this repository's control;
wrangler.jsoncenables none.