Security Runbook: API Schema Exposure Prevention
Security Runbook: API Schema Exposure Prevention
The Incident (2026-07)
/openapi.json was publicly accessible because it relied solely on edge-level
(Cloudflare Access) auth, which was misconfigured. This happened twice.
The route has been deleted, but the underlying class of bug — sensitive routes
accidentally exposed without auth — is easy to repeat. This runbook documents
the layered defenses now in place to keep it from happening a third time.
Defense Layers
1. Runtime: Nitro plugin (apps/web/plugins/sensitive-route-guard.ts)
Intercepts every request at the server level. If the path matches a sensitive
prefix (/admin, /dispatch, /openapi, /graphql, /schema, /swagger,
/internal) and is not in the public exception list (/health, /admin/login,
/admin/logout), the plugin requires either:
- a valid
WORKER_SHARED_SECRETBearer token, OR - a valid admin session cookie.
Bypasses route-level guards. Cannot be removed by deleting a route handler.
2. Commit gate: lefthook
lefthook.yml pre-commit runs scripts/check-sensitive-routes.ts against
staged route files, alongside check-workspace-manifests.ts. Raw
observability imports and cross-package boundaries are enforced separately,
as .oxlintrc.json overrides and ruff’s TID251 banned-api rule, both run
inside mise run validate. check-sensitive-routes.ts reads the route path
from createFileRoute("...") and requires an auth marker
(requireWorkerSecret, requireAdmin, middleware, or
sensitiveRouteGuard) on any sensitive path.
mise run validate (pre-push and CI) runs the same script with --all for a
repo-wide sweep.
3. CI gate: GitHub Actions
.github/workflows/security.yml runs three jobs on PRs to main and pushes
to main:
- semgrep-scan: runs the rules in
.semgrep/rules/sensitive-routes.yml - route-auth-audit: runs
scripts/check-sensitive-routes.ts --allto scan
every route file in the repo - canary-check (push only): curls
https://usebrioso.com/openapi.jsonand
fails the job if it returns 200. This catches the case where a route was
deleted but the response is cached at the edge.
4. Documentation
AGENTS.md has a “Security Invariants” section that agents must read before
adding new server routes.
5. Server function auth
Account-level server functions (e.g. delete-account-fn.ts) use requireUser()
from apps/web/src/server/auth/session.ts. This resolves the session cookie and
throws 401 if the user is not authenticated. These functions are not file routes,
so they are not caught by check-sensitive-routes.ts, but the requireUser()
gate is mandatory for any function that mutates user data.
If a Sensitive Endpoint Returns 200 in Production
- Verify the
sensitive-route-guardplugin is registered invite.config.ts
(plugins: ["./plugins/logging.ts", "./plugins/sensitive-route-guard.ts"]). - Verify the endpoint path is in the
SENSITIVE_PREFIXESlist. - Check if the endpoint was added to the
PUBLIC_PATHSexception list by
mistake (it shouldn’t have been — exception list reviews are required). - Investigate which commit removed or bypassed the route-level guard.
- Invalidate any edge cache for the path.
Adding a New Sensitive Endpoint
- Pick the runner: worker (requires
WORKER_SHARED_SECRET) or admin
(requires signed session cookie). - Add an auth check at the top of the route handler:
const gate = requireWorkerSecret(request); if (gate) return gate; - The Nitro plugin will automatically cover the endpoint if the path matches
a sensitive prefix. No additional work needed. - If the prefix is not in
SENSITIVE_PREFIXES, add it.
Adding a New Public Endpoint (rare)
This is discouraged. Only do it for:
- webhook endpoints that authenticate via a provider signature (e.g.
/stripe/webhook) - monitoring endpoints that intentionally expose health data
- explicit allowlist with documented justification
If you must:
- Get explicit sign-off from the project owner.
- Add the path to
PUBLIC_PATHSin the plugin. - Add a comment in the plugin explaining why this exception exists.
Existing exceptions
GET /dev/login?persona=<...>|email=<...>@brioso.test&redirect=<path> (local test login)
Mints a real session cookie for a @brioso.test user — either a named
persona (see PERSONAS in apps/web/src/routes/dev/login.ts) or an
ad-hoc email — so an agent or developer can authenticate against a local
dev server without pasting document.cookie = ... into devtools. Not registered with the Nitro
sensitive-route-guard plugin — that plugin gates on WORKER_SHARED_SECRET,
which is the wrong credential for a route whose entire purpose is minting a
user session before any secret exchange has happened.
Guard is request-time, not a route-level auth marker: the handler’s first
statement returns 404 when process.env.NODE_ENV === "production", and it
is also gated behind import.meta.env.DEV so it tree-shakes out of the
production bundle entirely. scripts/check-sensitive-routes.ts’s
AUTH_MARKERS list includes the literal NODE_ENV === "production" string
so the commit gate recognizes this pattern instead of silently skipping the
route — see that file’s comment.
Further narrowing: even if both gates above were bypassed, the handler
refuses any email not ending in @brioso.test, so it can never mint a
session for a real account.
GET /cosmos/$fixture (React Cosmos component sandbox)
Routes to a fixture in the standalone React Cosmos dev server (bunx cosmos)
via an iframe mount — see apps/web/src/routes/cosmos/$fixture.tsx.
Why it is in SENSITIVE_PREFIXES even though /dev/login is not:
/cosmos is registered with the Nitro sensitive-route-guard plugin (it
takes the WORKER_SHARED_SECRET-Bearer credential, like /dispatch/*,
/openapi, /graphql). Unlike /dev/login — whose purpose is to mint a
user session before any secret exchange — /cosmos is downstream of an
authenticated deploy, and the worker secret is the right credential to gate
it. A misconfigured deploy that fails the guard will 503, not 200.
Component-level import.meta.env.DEV short-circuit tree-shakes the
iframe JSX out of production bundles; the server handler additionally
returns 404 when process.env.NODE_ENV === "production". The commit gate
scripts/check-sensitive-routes.ts recognizes both patterns (see the
AUTH_MARKERS list in that file).
Param narrowing: the $fixture segment is validated against
/^[A-Za-z0-9._-]{1,128}$/ at both the server handler and the component,
so path traversal or oversized input 404s before reaching the iframe.
Rotation
WORKER_SHARED_SECRET: rotate quarterly. The secret lives on three
surfaces that do not sync — rotate them in sequence:- Generate new value:
openssl rand -hex 32. infisical secrets set WORKER_SHARED_SECRET=<new> --env=devand
--env=prod(apps/web picks it up on nextsync-secrets.shrun /
deploy).- Update the home worker daemon env the same way.
gh secret set WORKER_SHARED_SECRET(GitHub Actions deploy +
stripe-doctor cron call/internal/*with it).- Smoke: a green
Deploy webrun confirms the GitHub↔apps/web pair (the
deploy job curls/internal/config-auditwith the secret).
The
WORKER_SHARED_SECRET_ROLLOVERoverlap-window idea (second env var
accepted during a 24h window) applies across all three surfaces.Coupled secret:
OTEL_RELAY_TOKEN(worker side) MUST equal
WORKER_SHARED_SECRET(apps/web side). The relay route/otel/v1/traces
validates the inbound Bearer againstWORKER_SHARED_SECRET. This is a
deliberate reuse (BRI-63) to avoid a 4th secret. When rotating
WORKER_SHARED_SECRET, rotateOTEL_RELAY_TOKENon the home daemon to
match. Drift = silent trace darkness (relay 403, exporter non-fatal).- Generate new value:
SESSION_SECRET: rotate annually. Rotate immediately on suspected compromise.API keys (Anthropic, Stripe, Modal proxy keys): rotate on personnel
changes and suspected exposure.
Audio Protection (2026-07)
Three linked defenses against preview/master audio leaking as clean, ownership-free
files or against one customer’s job details leaking to another guesser. Tracked as
BRI-103/104/105.
Preview watermarking
Preview streams (never masters) are watermarked at generation time — a Pocket TTS
voice line (developpeuse-3), band-specific ducking, jittered per-track offsets so
the watermark can’t be reliably gated out by timing alone. Masters sent to a paying
customer are never watermarked; this is verified by test, not just by convention.
Preview streaming: proxy + Fetch-Metadata + playback token
GET /jobs/:slug/preview proxies the R2 object through the app (never a redirect to
a presigned URL, which would leak a bearer-style link anyone could replay or share)
and requires two things:
- a Fetch-Metadata check (
packages/auth-core/src/fetch-metadata.ts) rejecting
requests whoseSec-Fetch-*headers don’t look like an in-page<audio>fetch, - a short-TTL (10 min) HMAC playback token scoped to
slug+track_idx
(packages/auth-core/src/playback-token.ts, same secret as the session cookie) —
minted server-side per track, verified withtimingSafeEqual, and long enough
that a paused listener’s seek still works without minting a fresh one.
Range/206 requests are supported so scrubbing doesn’t require a full re-download.
getJob access gate + share links
getJob (apps/web/src/server/jobs/handlers.ts) used to return a full job — including
email and track storage keys — to anyone who guessed or was given a slug, no auth at
all. It now requires either a session that owns the job, or a valid share_token
resolved against packages/sharing.
Share links are a deliberate design tradeoff, not an oversight:
- Crypto-random, not HMAC — 24 bytes via
node:crypto.randomBytes, stored in the
share_linkstable rather than derived, so a link can be looked up/audited/counted
(play_count) without a stateless-verification scheme. - No
expires_at/revoked_at. The product is deliberately link-shareable with no
login required (an agency forwards a status page or a single proposal), and a
short-lived credential would just break that. Additive later if forensic
per-recipient watermarking is ever built — see
.agents/plans/2026-07-28-audio-protection.md. - Kit-scope vs track-scope. A kit-scope link (minted once at job creation, reused
idempotently viagetOrCreateKitShareLink) is threaded through the customer’s own
status-page URL and both proposal-ready/verify-otp emails. A track-scope link
(ShareButtononDeliveryPanel) is minted on demand for forwarding a single
proposal via/s/:token, a read-only page with no download/checkout/feedback
controls.
Audit Log
Every admin mutation logs structured context with request.headers.get("x-forwarded-for").
Worker mutations log worker_id from the Bearer token. Use PostHog (correlated
by trace_id via @brioso/obs-js/obs_py) for event-level forensics, or
Arize Phoenix (dev + prod — see .agents/docs/phoenix-runbook.md) for
correlated distributed traces of the same trace_id.
Local dev authentication
Agents (and humans) testing locally must use /dev/login, never /login.
The production OTP path sends through the real Brevo API; from developer IPs
outside Brevo’s authorised IP allowlist, the send is rejected, the OTP never
arrives, and the UI offers no explanation beyond the code prompt. /dev/login
avoids the external dependency entirely and mints the same real session
cookie.
/jobs Gate Seam (2026-09-09)
The /jobs route family is authorized by one declarative seam (plan:
.agents/plans/2026-09-09-jobs-auth-gate-seam.md):
- Route table (source of truth):
apps/web/src/server/jobs/gates.ts.
Every route+method undersrc/routes/jobs/must appear there; the
completeness test inhandlers.test.tsfails CI otherwise. Gate types:
worker(WORKER_SHARED_SECRET),session,owner-or-share,
owner-or-share-or-anon,hmac-preview,public, resolved via
resolveGatefrom@brioso/auth-core/gate. - Middleware ordering:
gate-middleware.tsandsensitive-route-guard.ts
both register global h3~middleware. Both run BEFORE any route handler;
their route sets are disjoint (/internal/*,/openapi,/dispatchvs
/jobspatterns), so relative order between them is unobservable. Do not
convert either to anitroApp.hooks.hook("request", ...)listener — the
hook chain swallows errors and still routes (see sensitive-route-guard.ts). - X-Brioso-Actor response header — leak surface: carries ONLY the actor
kind (worker|session|share-token|anonymous), never an email or a token.
Emails are PII and response headers leak into proxy/CDN access logs. The
value derives fromauditFields(result).actorKind, the same projection the
audit row uses, so header and audit log cannot drift. - Owner-only recreate (
POST /jobs/:slug/recreate): gate issession
plus an ownership refinement (refineOwnerOnly). Share links are
read/thread-scoped by design; recreate is a state-mutating, GPU-consuming
write that must be attributable to the account that owns the job.
Authenticated non-owners get the same 404 as cancelMyJob (no existence
leak). Flip back to share-allowed by editing one table line. - Audit TTL:
security_auditrows expire after
SECURITY_AUDIT_RETENTION_DAYS = 90
(packages/dispatch/src/constants.ts); the daily sweep runs via
startDbGroomerSchedulerinapps/web/plugins/db-groomer.ts. With no cold
archive, a day-91 incident row is unpullable — revisit retention if WORM
storage lands. - Append-only limit: no UPDATE/DELETE code path touches
security_audit
except the groomer; there are no DB-level append-only triggers (SQLite/
Turso pragmatism). A compromised DB writer could rewrite history — accepted
risk, noted here so it is not an accident. - /api/ scope*: the gate middleware matches ONLY the explicit
/jobs
patterns in the table, no catch-all. New routes outside/jobs(including
/api/*) are NOT gated by this middleware — wire their auth explicitly at
the route or handler level;sensitive-route-guardstill covers the
sensitive prefixes (/internal/*,/openapi,/dispatch, …) as the
last-line defense. - Audit writes are fire-and-forget (plan §3):
voided inserts with
captureErroron failure, so an audit write can be lost on a crash between
gate resolution and insert. The obsevent("jobs.gate", ...)emitted in
the same call is the parallel forensic signal.