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_SECRET Bearer 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 --all to scan
    every route file in the repo
  • canary-check (push only): curls https://usebrioso.com/openapi.json and
    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

  1. Verify the sensitive-route-guard plugin is registered in vite.config.ts
    (plugins: ["./plugins/logging.ts", "./plugins/sensitive-route-guard.ts"]).
  2. Verify the endpoint path is in the SENSITIVE_PREFIXES list.
  3. Check if the endpoint was added to the PUBLIC_PATHS exception list by
    mistake (it shouldn’t have been — exception list reviews are required).
  4. Investigate which commit removed or bypassed the route-level guard.
  5. Invalidate any edge cache for the path.

Adding a New Sensitive Endpoint

  1. Pick the runner: worker (requires WORKER_SHARED_SECRET) or admin
    (requires signed session cookie).
  2. Add an auth check at the top of the route handler:
    const gate = requireWorkerSecret(request);
    if (gate) return gate;
    
  3. The Nitro plugin will automatically cover the endpoint if the path matches
    a sensitive prefix. No additional work needed.
  4. 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:

  1. Get explicit sign-off from the project owner.
  2. Add the path to PUBLIC_PATHS in the plugin.
  3. 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:

    1. Generate new value: openssl rand -hex 32.
    2. infisical secrets set WORKER_SHARED_SECRET=<new> --env=dev and
      --env=prod (apps/web picks it up on next sync-secrets.sh run /
      deploy).
    3. Update the home worker daemon env the same way.
    4. gh secret set WORKER_SHARED_SECRET (GitHub Actions deploy +
      stripe-doctor cron call /internal/* with it).
    5. Smoke: a green Deploy web run confirms the GitHub↔apps/web pair (the
      deploy job curls /internal/config-audit with the secret).

    The WORKER_SHARED_SECRET_ROLLOVER overlap-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 against WORKER_SHARED_SECRET. This is a
    deliberate reuse (BRI-63) to avoid a 4th secret. When rotating
    WORKER_SHARED_SECRET, rotate OTEL_RELAY_TOKEN on the home daemon to
    match. Drift = silent trace darkness (relay 403, exporter non-fatal).

  • 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 whose Sec-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 with timingSafeEqual, 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 (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_links table 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 via getOrCreateKitShareLink) is threaded through the customer’s own
    status-page URL and both proposal-ready/verify-otp emails. A track-scope link
    (ShareButton on DeliveryPanel) 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 under src/routes/jobs/ must appear there; the
    completeness test in handlers.test.ts fails CI otherwise. Gate types:
    worker (WORKER_SHARED_SECRET), session, owner-or-share,
    owner-or-share-or-anon, hmac-preview, public, resolved via
    resolveGate from @brioso/auth-core/gate.
  • Middleware ordering: gate-middleware.ts and sensitive-route-guard.ts
    both register global h3 ~middleware. Both run BEFORE any route handler;
    their route sets are disjoint (/internal/*, /openapi, /dispatch vs
    /jobs patterns), so relative order between them is unobservable. Do not
    convert either to a nitroApp.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 from auditFields(result).actorKind, the same projection the
    audit row uses, so header and audit log cannot drift.
  • Owner-only recreate (POST /jobs/:slug/recreate): gate is session
    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_audit rows expire after
    SECURITY_AUDIT_RETENTION_DAYS = 90
    (packages/dispatch/src/constants.ts); the daily sweep runs via
    startDbGroomerScheduler in apps/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-guard still covers the
    sensitive prefixes (/internal/*, /openapi, /dispatch, …) as the
    last-line defense.
  • Audit writes are fire-and-forget (plan §3): voided inserts with
    captureError on failure, so an audit write can be lost on a crash between
    gate resolution and insert. The obs event("jobs.gate", ...) emitted in
    the same call is the parallel forensic signal.