Blame · Line-by-line history
no-unauthenticated-writes.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 6cbc9cb | 1 | /** |
| 2 | * Every state-changing route is either guarded or explicitly declared public. | |
| 3 | * | |
| 4 | * POST /api/repos took the target namespace from the request body and had no | |
| 5 | * auth middleware at all, so anyone could create repositories in anyone's | |
| 6 | * account. POST /api/setup upserted a user with the fixed password | |
| 7 | * "changeme". Both shipped because nothing enumerated the write surface — | |
| 8 | * there are 413 state-changing routes and no list of which ones are meant to | |
| 9 | * be reachable anonymously. | |
| 10 | * | |
| 11 | * This test does not try to prove a route is *correctly* authorized; static | |
| 12 | * analysis can't, and there are at least five guard idioms in this codebase | |
| 13 | * (inline middleware, router-level `.use("*")`, router prefix `.use("/x/*")`, | |
| 14 | * an in-handler `gate(c)` returning a Response, and bespoke helpers like | |
| 15 | * `requireSandboxAdmin`). What it does is force a decision: a write route | |
| 16 | * either matches a recognised guard idiom, or it appears in PUBLIC_WRITES | |
| 17 | * with a stated reason. A newly added unguarded write fails the build. | |
| 18 | * | |
| 19 | * When this test fails on a route you added: if it should be authenticated, | |
| 20 | * add a guard. If it genuinely must be public — an auth entry point, a | |
| 21 | * third-party webhook receiver, the git protocol — add it to PUBLIC_WRITES | |
| 22 | * with the reason. | |
| 23 | */ | |
| 24 | ||
| 25 | import { describe, expect, it } from "bun:test"; | |
| 26 | import { readdirSync, readFileSync, statSync } from "fs"; | |
| 27 | import { join } from "path"; | |
| 28 | ||
| 29 | const ROOT = "src/routes"; | |
| 30 | ||
| 31 | /** Guards that appear in middleware position on the route registration. */ | |
| 32 | const MIDDLEWARE_GUARD = | |
| 33 | /requireAuth|requireApiAuth|requireAdmin|requireScope|requireRepoAccess|requireOrgRole|agentAuth|adminOnly/; | |
| 34 | ||
| 35 | /** | |
| 36 | * Guards that appear inside the handler body and return a Response on | |
| 37 | * failure before any state is touched. | |
| 38 | */ | |
| 39 | const HANDLER_GUARD = | |
| 40 | /\bgate\(c\)|require[A-Z]\w*\(c\)|isSiteAdmin|assertAdmin|assertRepoReadable|assertRepoWritable|resolveRepoAccess|c\.get\("user"\)!|const viewer = c\.get\("user"\)|const user = c\.get\("user"\)|if \(!c\.get\("user"\)\)|verifyBearer|authenticateBearer|verifyPatAuth|verifySignature|EMERGENCY_PAT_SECRET|scimAuth|verifyScimToken/; | |
| 41 | ||
| 42 | /** | |
| 43 | * Write routes that are reachable anonymously ON PURPOSE. Each needs a | |
| 44 | * reason, because "it was already like that" is how POST /api/setup survived. | |
| 45 | */ | |
| 46 | const PUBLIC_WRITES: Record<string, string> = { | |
| 47 | // Authentication entry points — cannot require an existing session. | |
| 48 | "POST /register": "signup", | |
| 49 | "POST /login": "sign-in", | |
| 50 | "POST /login/2fa": "second factor; the session exists but is not yet valid", | |
| 51 | "POST /login/magic": "magic-link request", | |
| 52 | "POST /api/auth/register": "signup (API)", | |
| 53 | "POST /api/auth/login": "sign-in (API)", | |
| 54 | "POST /oauth/token": "OAuth token exchange; authenticates via client creds", | |
| 55 | "POST /api/passkeys/auth/options": "WebAuthn sign-in challenge; username optional, no session yet", | |
| 56 | "POST /api/passkeys/auth/verify": "WebAuthn sign-in; authenticates via the signed assertion", | |
| 57 | "POST /forgot-password": "password reset request", | |
| 58 | "POST /reset-password": "password reset completion; authenticates via token", | |
| 59 | ||
| 60 | // Git Smart HTTP — authenticates itself via lib/git-push-auth.ts. | |
| 61 | "POST /:owner/:repo.git/git-upload-pack": "git protocol, own auth", | |
| 62 | "POST /:owner/:repo.git/git-receive-pack": "git protocol, own auth", | |
| 63 | ||
| 64 | // Third-party webhook receivers — authenticate by signature, not session. | |
| 65 | "POST /api/hooks/gatetest": "GateTest callback", | |
| 66 | "POST /hooks/pagerduty": "incident webhook", | |
| 67 | "POST /hooks/datadog": "incident webhook", | |
| 68 | "POST /hooks/opsgenie": "incident webhook", | |
| 69 | "POST /hooks/incident": "incident webhook", | |
| 70 | "POST /api/v2/integrations/slack/events": "Slack Events API", | |
| 71 | "POST /api/v2/integrations/discord/interactions": "Discord interactions", | |
| 72 | "POST /api/webhooks/stripe": "Stripe webhook; verified by stripe-signature", | |
| 73 | "POST /sso/saml/:orgSlug/callback": "SAML assertion consumer; authenticates via the signed assertion", | |
| 74 | ||
| 75 | // Bearer-token surfaces whose guard helper this scan does not recognise. | |
| 76 | // Each was read and confirmed to reject an unauthenticated caller. | |
| 77 | "POST /api/v1/runs/:runId/artifacts": "CI artifact upload; glc_ PAT bearer with repo/write scope", | |
| 78 | "DELETE /api/v1/artifacts/:artifactId": "CI artifact delete; glc_ PAT bearer", | |
| 79 | "POST /deploy/started": "deploy event; Authorization: Bearer DEPLOY_EVENT_TOKEN", | |
| 80 | "POST /deploy/finished": "deploy event; Authorization: Bearer DEPLOY_EVENT_TOKEN", | |
| 81 | "POST /deploy/step": "deploy event; Authorization: Bearer DEPLOY_EVENT_TOKEN", | |
| 82 | "POST /v2/*": "OCI registry; Docker Basic auth validated against api_tokens", | |
| 83 | "PUT /v2/*": "OCI registry; Docker Basic auth validated against api_tokens", | |
| 84 | "PATCH /v2/*": "OCI registry; Docker Basic auth validated against api_tokens", | |
| 85 | "DELETE /v2/*": "OCI registry; Docker Basic auth validated against api_tokens", | |
| 86 | ||
| 87 | // OAuth endpoints that are public by specification. | |
| 88 | "POST /oauth/register": "RFC 7591 dynamic client registration", | |
| 89 | "POST /oauth/revoke": "RFC 7009 revocation; authenticates via the token itself", | |
| 90 | "POST /api/v2/integrations/slack/install": "OAuth install redirect start", | |
| 91 | "POST /api/v2/integrations/discord/install": "OAuth install redirect start", | |
| 92 | ||
| 93 | // Deliberately public product surfaces. | |
| 94 | "POST /loops/:slug/invoke": "public hosted loop; gated on loop.isPublic", | |
| 95 | "POST /enterprise/contact": "contact form", | |
| 96 | "POST /markdown/preview": "stateless render, touches no state", | |
| 97 | "POST /play": "playground account creation; the point is to need no account", | |
| 98 | "POST /status/subscribe": "status-page email subscription", | |
| 99 | "POST /flywheel-telemetry/feedback": "anonymous product telemetry", | |
| 100 | "POST /api/claude/session": "fire-and-forget session telemetry, documented as no-auth", | |
| 101 | "POST /setup": "first-run bootstrap; refuses once the users table is non-empty", | |
| 102 | }; | |
| 103 | ||
| 104 | function walk(d: string): string[] { | |
| 105 | const out: string[] = []; | |
| 106 | for (const e of readdirSync(d)) { | |
| 107 | const p = join(d, e); | |
| 108 | if (statSync(p).isDirectory()) out.push(...walk(p)); | |
| 109 | else if (e.endsWith(".ts") || e.endsWith(".tsx")) out.push(p); | |
| 110 | } | |
| 111 | return out; | |
| 112 | } | |
| 113 | ||
| 114 | type Route = { method: string; path: string; file: string; guard: string }; | |
| 115 | ||
| 116 | function analyze(): Route[] { | |
| 117 | const routes: Route[] = []; | |
| 118 | for (const f of walk(ROOT)) { | |
| 119 | const src = readFileSync(f, "utf8"); | |
| 120 | const rel = f.replace(/\\/g, "/").split("/routes/")[1]; | |
| 121 | ||
| 122 | const prefixGuards: string[] = []; | |
| 123 | let guardAll = false; | |
| 124 | for (const m of src.matchAll(/\.use\(\s*(["'`])([^"'`]*)\1\s*,([\s\S]{0,200}?)\)\s*;/g)) { | |
| 125 | if (!MIDDLEWARE_GUARD.test(m[3])) continue; | |
| 126 | if (m[2] === "*") guardAll = true; | |
| 127 | else prefixGuards.push(m[2]); | |
| 128 | } | |
| 129 | ||
| 130 | const re = /\.(post|put|patch|delete)\(\s*(["'`])([^"'`\n]*)\2/g; | |
| 131 | let m; | |
| 132 | while ((m = re.exec(src))) { | |
| 133 | const path = m[3]; | |
| 134 | if (!path.startsWith("/")) continue; | |
| 135 | const window = src.slice(m.index, m.index + 1200); | |
| 136 | // Middleware sits between the path literal and the handler arrow. | |
| 137 | const arrow = window.indexOf("=>"); | |
| 138 | const sig = arrow > -1 ? window.slice(0, arrow) : window.slice(0, 300); | |
| 139 | ||
| 140 | let guard = "NONE"; | |
| 141 | if (MIDDLEWARE_GUARD.test(sig)) guard = "inline"; | |
| 142 | else if (guardAll) guard = "router-*"; | |
| 143 | else if (prefixGuards.some((g) => { const p = g.replace(/\*$/, ""); return p && path.startsWith(p); })) guard = "router-prefix"; | |
| 144 | else if (HANDLER_GUARD.test(window)) guard = "in-handler"; | |
| 145 | routes.push({ method: m[1].toUpperCase(), path, file: rel, guard }); | |
| 146 | } | |
| 147 | } | |
| 148 | return routes; | |
| 149 | } | |
| 150 | ||
| 151 | const ROUTES = analyze(); | |
| 152 | ||
| 153 | describe("state-changing routes", () => { | |
| 154 | it("finds a substantial write surface (guards against the scan silently breaking)", () => { | |
| 155 | // If a refactor breaks the regex this test would vacuously pass, so | |
| 156 | // assert the scan still sees roughly the surface we know exists. | |
| 157 | expect(ROUTES.length).toBeGreaterThan(300); | |
| 158 | }); | |
| 159 | ||
| 160 | it("every unguarded write is explicitly declared public", () => { | |
| 161 | const unexplained = ROUTES | |
| 162 | .filter((r) => r.guard === "NONE") | |
| 163 | .filter((r) => !(`${r.method} ${r.path}` in PUBLIC_WRITES)) | |
| 164 | .map((r) => `${r.method} ${r.path} (${r.file})`) | |
| 165 | .sort(); | |
| 166 | expect(unexplained).toEqual([]); | |
| 167 | }); | |
| 168 | ||
| 169 | it("every PUBLIC_WRITES entry carries a reason", () => { | |
| 170 | for (const [route, reason] of Object.entries(PUBLIC_WRITES)) { | |
| 171 | expect(reason.length, `${route} needs a reason`).toBeGreaterThan(3); | |
| 172 | } | |
| 173 | }); | |
| 174 | ||
| 175 | it("PUBLIC_WRITES has no stale entries", () => { | |
| 176 | // A route that gained a guard, or was deleted, should leave the list — | |
| 177 | // otherwise the allowlist rots into a blanket exemption. | |
| 178 | const live = new Set( | |
| 179 | ROUTES.filter((r) => r.guard === "NONE").map((r) => `${r.method} ${r.path}`) | |
| 180 | ); | |
| 181 | const stale = Object.keys(PUBLIC_WRITES).filter((k) => !live.has(k)); | |
| 182 | expect(stale).toEqual([]); | |
| 183 | }); | |
| 184 | }); |