Security¶
Security in Qubit16 is a set of enforced invariants, each with a test, rather than a checklist. This page lays out the threat model and the controls; the two sub-pages cover rate limiting and dependencies. The full audit history, including findings by identifier and the fixes that closed them, is in docs/SECURITY.md in the repository.
Threat model¶
What an attacker could want, and what stands in the way:
| Goal | Primary control |
|---|---|
| Read another user's data | Row-level security on all 29 tables; identity only from a verified token; no user id accepted from a request body |
| Use a paid feature without paying | Entitlements resolved and enforced server-side only (ADR-0005); the paid lesson text is in neither the HTML nor the client bundle |
| Spend the operator's hardware budget | Eight-step gate chain ending in an atomic, per-user-locked quota reservation that fails closed |
| Grant themselves a plan or seats | subscriptions has no user write policy; the Stripe webhook is its only writer; org creation requires the caller's own subscription at that plan |
| Steal credentials or keys | API keys stored as SHA-256 hashes; provider secrets sealed with AES-256-GCM; secrets never logged or placed in error text; no secrets in the repository |
| Inject instructions through content the model reads | Learner circuits and retrieved documents fenced with untrusted-content markers; model output parsed, never evaluated |
| Reach internal services | The hardware API is behind a shared secret and only the needed endpoints are republished; webhooks have an SSRF guard |
| Abuse an endpoint at volume | Two-layer rate limiting keyed on the Cloudflare-supplied client address, applied before any expensive work |
| Script injection on the site | A Content-Security-Policy with default-src 'self', react/no-danger as a lint error with two reviewed exceptions, HSTS, frame ancestors restricted |
Headers¶
lib/security/headers.ts is the single source of the response headers, applied by proxy.ts to every page and API response, and mirrored byte-for-byte in vercel.json for that deployment path:
Content-Security-Policy:default-src 'self', withconnect-srcwidened only to the configured service origins,frame-ancestors 'self'(widened only for/embed/*and only to the allow-list), and'wasm-unsafe-eval'onscript-srconly when the on-device tutor is enabled.script-srccarries'unsafe-inline', and the file documents the trade-off: a per-request nonce would require dynamic rendering of every page, which would undo the static-by-default architecture, so the CSP's value here is in restricting where scripts may send data rather than whether inline scripts may run.Strict-Transport-Securitywith a two-year max-age and subdomains, set whenever the request arrived over HTTPS.X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin,X-Frame-Options: SAMEORIGIN, and aPermissions-Policythat disables camera, microphone, geolocation, payment, USB and interest-cohort.
Authentication and authorisation¶
Covered in Trust boundaries. The essentials: the session is in browser storage and travels as a bearer header; supabase.auth.getUser(token) is the only path from a token to an identity; every route handler that touches user data verifies and authorises for itself; the middleware is not an authorisation boundary and says so.
Secrets¶
- None in the repository. The deploy script loads a gitignored
.env.production; the CD workflow uses OIDC federation to Azure with no stored client secret; mobile signing keys are environment variables;.env.examplecontains only placeholders. - Public versus private is explicit. Only
NEXT_PUBLIC_*variables are baked into the browser bundle; aNEXT_PUBLIC_name is deliberately not accepted for the API shared secret. - Rotation without an outage. The API accepts a comma-separated list of secrets and compares against every one in constant time with no early exit; blank entries are dropped so a trailing comma cannot admit anonymous requests.
- At rest. Provider keys and bring-your-own hardware credentials are sealed with AES-256-GCM under
SETTINGS_ENCRYPTION_KEY; a column check requires the sealed shape. - API keys are shown once at creation and stored as a SHA-256 hash plus a 16-character display prefix.
Inputs¶
Every route handler validates its body through lib/security/validate.ts (typed string, integer, array, enumeration, UUID, email and HTTPS-URL checkers, and a safeJson reader with a byte limit) and answers 400 with a stable error shape from lib/apiError.ts. Bodies are size-capped before parsing. The MCP endpoint checks the Origin header against the deployment host as a DNS-rebinding defence and rejects unsupported protocol versions.
Services¶
services/apirefuses to start ifALLOWED_ORIGINScontains*, warns when it is the localhost default, and runs its environment validator before uvicorn. Its container runs as a non-root user and its health endpoint makes no vendor calls.services/agentapplies the same clamps as its caller, bounds tool rounds and wall clock, and returns a partial answer rather than a 500 when a limit is hit.- Both containers are two-stage builds from
python:3.11-slimwith test tooling stripped from the runtime image.
Edge¶
The production deployment sits behind Cloudflare, which terminates TLS, provides a WAF, and supplies the cf-connecting-ip header the rate limiters key on. The operational checklist in the repository records the remaining edge hardening: restricting the Azure origin hostnames to Cloudflare's address ranges (which is what makes cf-connecting-ip trustworthy), Full (strict) TLS mode, and WAF rules for scanner paths.
Audit history¶
docs/SECURITY.md records a full audit with a threat model, a findings table (identifiers such as BILL-1, PROXY-1, ORG-3, INJ-1, CORS-1, PY-1, MAIL-1, INV-1), the regression test added for each fix, and a list of what was checked and found clean. Findings reference their tests by path, so a reader can confirm a fix is still in force by running the suite. The practice is to write the test first against the vulnerable build, confirm it fails, then fix.