From b2c8d99af72dda90560fdf6cdc6effadb115dc2d Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Fri, 28 Aug 2026 15:32:25 -0700 Subject: [PATCH] lab: gate /lab behind API_TOKEN (basic auth + x-api-token) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /lab was mounted before mcp's auth middleware, so every lab route — including run launches that spend LLM budget — was reachable unauthenticated on a public host (the known follow-up in lab/AGENTS.md). Gate the whole mount with the mcp-wide API_TOKEN, accepting either HTTP Basic admin: (the browser prompts once for the UI and then attaches credentials to every request, including EventSource streams, which cannot carry custom headers) or the x-api-token header (parity with the rest of mcp for server-to-server callers). API_TOKEN unset = dev mode = open, the same posture as the /events route. Verified against a live instance: 401 + WWW-Authenticate with no/wrong credentials (wrong user, wrong pass), 200 for basic and x-api-token on /lab/health and the UI, open when API_TOKEN is unset. Co-Authored-By: Claude Fable 5 --- mcp/src/lab/mount.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/mcp/src/lab/mount.ts b/mcp/src/lab/mount.ts index 7af4a2202..ef6b15da6 100644 --- a/mcp/src/lab/mount.ts +++ b/mcp/src/lab/mount.ts @@ -35,6 +35,30 @@ function bridge(factory: () => Promise<{ app: { fetch: any } }>) { * Registration MUST happen before `express.json()` so vein receives the * raw request stream (same constraint as the graph SSE routes). */ +/** + * Gate every /lab route behind the mcp-wide API_TOKEN (unset = dev mode = + * open, the same posture as the /events route). Two accepted credentials: + * HTTP Basic `admin:` — the browser prompts once for the UI and + * then attaches it to every request including EventSource streams, which + * cannot carry custom headers — and the `x-api-token` header, matching the + * rest of mcp for server-to-server callers. + */ +function labAuth(req: Request, res: Response, next: NextFunction): void { + const apiToken = process.env.API_TOKEN; + if (!apiToken) return next(); + if (req.header("x-api-token") === apiToken) return next(); + const header = req.header("authorization") ?? ""; + if (header.startsWith("Basic ")) { + const decoded = Buffer.from(header.slice(6), "base64").toString(); + const sep = decoded.indexOf(":"); + const user = decoded.slice(0, sep); + const pass = decoded.slice(sep + 1); + if (sep > 0 && user === "admin" && pass === apiToken) return next(); + } + res.set("WWW-Authenticate", 'Basic realm="stakgraph-lab"'); + res.status(401).json({ error: "Unauthorized" }); +} + export function mountLab(app: Express): void { // Trailing slash so the SPA's relative asset URLs resolve under /lab/. // Express routing is non-strict, so `/lab` also matches `/lab/`; guard @@ -45,5 +69,5 @@ export function mountLab(app: Express): void { if (req.path === "/lab/") return next(); res.redirect(308, "/lab/"); }); - app.use("/lab", bridge(() => createLabVein({ serveUi: true }))); + app.use("/lab", labAuth, bridge(() => createLabVein({ serveUi: true }))); }