Skip to content

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 ​

CallerCredentialChecked in
Family browserra_session cookie (server-side session)src/auth.ts (requireUser)
StripeStripe-Signature HMAC over the raw bodysrc/stripe_webhook.ts (verifySignature)
Hermes hostAuthorization: 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 from crypto.getRandomValues, 256-bit output. Hash and salt are stored base64 in users.password_hash and users.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; user objects contain only id, email and name.
  • 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_at is set at creation; the cookie Max-Age is 2592000). Lookup requires expires_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 (sessionCookie in src/http.ts): Path=/, HttpOnly, SameSite=Lax, Max-Age=2592000, and Secure when the request URL scheme is https:. The decision uses request.url, not APP_URL, so production over HTTPS always gets Secure and http://localhost development does not. Clearing sends Max-Age=0 and a 1970 Expires.
  • HttpOnly keeps 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=Lax on the session cookie, so browsers do not send it on cross-site POST, PUT or DELETE requests;
  • JSON-only request bodies: handlers parse with readJson and answer 400 invalid_json for anything else. The server does not check the Content-Type header, 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.origin with new 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 + hash is used, and it must start with /.
  • next is carried to the sibling login/signup link only after this check. A plan parameter 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/* need Authorization: Bearer <token> with exactly one space-separated token. Missing, malformed or wrong gives 401 unauthorized.
  • The token is compared with a constant-time loop over both byte arrays (length difference included).
  • If HERMES_LINK_TOKEN is not configured on the Worker, every Hermes request is rejected with 401; there is no default token.
  • The check runs before routing, so unknown /api/hermes/... paths also need the token to get a 404.
  • The same token is stored on the Hermes host in $HERMES_HOME/securus/portal_link.token (keep it chmod 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:

  1. STRIPE_WEBHOOK_SECRET must be set, else 503 billing_not_configured. Nothing is accepted unverified.
  2. The raw body is read once with request.text(); the signature covers exactly those bytes.
  3. The Stripe-Signature header is split on commas into t=<unix seconds> and one or more v1=<hex> values. Missing header, a non-numeric t, or no v1 fails.
  4. The timestamp must be within 300 seconds of now (past or future). Older or newer fails (replay protection).
  5. The expected value is HMAC-SHA256 with the secret over `${t}.${rawBody}`, computed with Web Crypto.
  6. Each v1 is 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).

NamePurposeUsed in
STRIPE_SECRET_KEYStripe 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_SECRETWebhook signature verification (whsec_...).src/stripe_webhook.ts
STRIPE_PRICE_MONTHLY, STRIPE_PRICE_QUARTERLY, STRIPE_PRICE_ANNUALStripe 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_TOKENBearer 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_error with a fixed message. Stripe failures become 502 stripe_error with 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 is cancellation_response inside a stripe/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.jsonc enables none.