From ab03677567aa27012611a30389936c690e7f3952 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 13 Apr 2026 06:52:43 -0500 Subject: [PATCH] fix(security): activate auth middleware and harden shutdown/update routes (#4023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Next.js auth middleware (proxy.ts) was never wired in — it exported `proxy` from a file named proxy.ts, but Next.js requires a `middleware` export from middleware.ts. The middleware-manifest.json was empty, leaving all 42 API routes accessible without authentication. Fixes: - Rename web/proxy.ts → web/middleware.ts, export `middleware` not `proxy` - Add defense-in-depth auth-guard to /api/shutdown and /api/update routes - Remove shell: true from update-service spawn (command injection surface) - Update contract tests to verify middleware file name and export Closes #4014 Co-authored-by: Claude Opus 4.6 (1M context) --- src/tests/integration/web-auth-token.test.ts | 22 +++++---- src/web/update-service.ts | 3 +- web/app/api/shutdown/route.ts | 7 ++- web/app/api/update/route.ts | 11 ++++- web/lib/auth-guard.ts | 47 ++++++++++++++++++++ web/{proxy.ts => middleware.ts} | 4 +- 6 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 web/lib/auth-guard.ts rename web/{proxy.ts => middleware.ts} (94%) diff --git a/src/tests/integration/web-auth-token.test.ts b/src/tests/integration/web-auth-token.test.ts index 9f3571c57..2309cbd26 100644 --- a/src/tests/integration/web-auth-token.test.ts +++ b/src/tests/integration/web-auth-token.test.ts @@ -69,19 +69,23 @@ test('app-shell.tsx sendBeacon does not send bare unauthenticated URL', () => { } }) -// ─── proxy.ts contract tests ──────────────────────────────────────────────── +// ─── middleware.ts contract tests ─────────────────────────────────��───────── -const proxySource = readFileSync(join(projectRoot, 'web', 'proxy.ts'), 'utf-8') +const middlewareSource = readFileSync(join(projectRoot, 'web', 'middleware.ts'), 'utf-8') -test('proxy.ts accepts _token query parameter as fallback authentication', () => { - assert.match(proxySource, /_token/, 'proxy should support _token query parameter for SSE/sendBeacon') +test('middleware.ts exports a function named middleware', () => { + assert.match(middlewareSource, /export function middleware/, 'must export "middleware" for Next.js to activate it') }) -test('proxy.ts validates bearer token from Authorization header', () => { - assert.match(proxySource, /Bearer/, 'proxy should check Authorization: Bearer header') +test('middleware.ts accepts _token query parameter as fallback authentication', () => { + assert.match(middlewareSource, /_token/, 'middleware should support _token query parameter for SSE/sendBeacon') }) -test('proxy.ts skips auth when GSD_WEB_AUTH_TOKEN is not set', () => { - assert.match(proxySource, /GSD_WEB_AUTH_TOKEN/, 'proxy should read GSD_WEB_AUTH_TOKEN from env') - assert.match(proxySource, /NextResponse\.next\(\)/, 'proxy should pass through when no token is configured') +test('middleware.ts validates bearer token from Authorization header', () => { + assert.match(middlewareSource, /Bearer/, 'middleware should check Authorization: Bearer header') +}) + +test('middleware.ts skips auth when GSD_WEB_AUTH_TOKEN is not set', () => { + assert.match(middlewareSource, /GSD_WEB_AUTH_TOKEN/, 'middleware should read GSD_WEB_AUTH_TOKEN from env') + assert.match(middlewareSource, /NextResponse\.next\(\)/, 'middleware should pass through when no token is configured') }) diff --git a/src/web/update-service.ts b/src/web/update-service.ts index 5b6ccfef8..f7c8d185c 100644 --- a/src/web/update-service.ts +++ b/src/web/update-service.ts @@ -75,7 +75,8 @@ export function triggerUpdate(targetVersion?: string): boolean { // Detach so the child process is not killed if the parent exits detached: false, windowsHide: true, - shell: process.platform === "win32", + // Avoid shell: true — npm.cmd is directly executable on Windows via spawn. + // Using shell expands the command injection surface unnecessarily. }) let stderr = "" diff --git a/web/app/api/shutdown/route.ts b/web/app/api/shutdown/route.ts index 348044c85..9921534ad 100644 --- a/web/app/api/shutdown/route.ts +++ b/web/app/api/shutdown/route.ts @@ -1,9 +1,14 @@ import { scheduleShutdown } from "../../../lib/shutdown-gate"; +import { verifyAuthToken } from "../../../lib/auth-guard"; export const runtime = "nodejs" export const dynamic = "force-dynamic" -export async function POST(): Promise { +export async function POST(request: Request): Promise { + // Defense-in-depth: verify auth token even though middleware should catch it. + const authError = verifyAuthToken(request); + if (authError) return authError; + // Schedule a deferred shutdown instead of exiting immediately. // This gives the client a window to cancel the exit on page refresh — // the boot route calls cancelShutdown() when it receives the next request. diff --git a/web/app/api/update/route.ts b/web/app/api/update/route.ts index f0d13c9dd..737790162 100644 --- a/web/app/api/update/route.ts +++ b/web/app/api/update/route.ts @@ -3,11 +3,15 @@ import { getUpdateStatus, triggerUpdate, } from "../../../../src/web/update-service.ts" +import { verifyAuthToken } from "../../../lib/auth-guard"; export const runtime = "nodejs" export const dynamic = "force-dynamic" -export async function GET(): Promise { +export async function GET(request: Request): Promise { + // Defense-in-depth: verify auth token even though middleware should catch it. + const authError = verifyAuthToken(request); + if (authError) return authError; try { const versionInfo = await checkForUpdate() const { status, error, targetVersion } = getUpdateStatus() @@ -37,7 +41,10 @@ export async function GET(): Promise { } } -export async function POST(): Promise { +export async function POST(request: Request): Promise { + // Defense-in-depth: verify auth token even though middleware should catch it. + const authError = verifyAuthToken(request); + if (authError) return authError; try { const versionInfo = await checkForUpdate() const started = triggerUpdate(versionInfo.latestVersion) diff --git a/web/lib/auth-guard.ts b/web/lib/auth-guard.ts new file mode 100644 index 000000000..d05da6e8c --- /dev/null +++ b/web/lib/auth-guard.ts @@ -0,0 +1,47 @@ +// GSD Web — Inline auth token verification for sensitive API routes +// Copyright (c) 2026 Jeremy McSpadden + +/** + * Defense-in-depth auth check for critical API routes (shutdown, update, etc.). + * + * The primary auth gate is Next.js middleware (web/middleware.ts). This helper + * provides a second layer so that even if middleware is misconfigured or + * bypassed, sensitive endpoints still reject unauthenticated requests. + * + * Returns a 401 Response if the token is missing or invalid, or null if auth + * passes (or no token is configured). + */ +export function verifyAuthToken(request: Request): Response | null { + const expectedToken = process.env.GSD_WEB_AUTH_TOKEN + if (!expectedToken) { + // No token configured (e.g. dev mode) — allow through + return null + } + + let token: string | null = null + + // 1. Authorization header (preferred) + const authHeader = request.headers.get("authorization") + if (authHeader?.startsWith("Bearer ")) { + token = authHeader.slice(7) + } + + // 2. Query parameter fallback for EventSource / sendBeacon + if (!token) { + try { + const url = new URL(request.url) + token = url.searchParams.get("_token") + } catch { + // Malformed URL — reject + } + } + + if (!token || token !== expectedToken) { + return Response.json( + { error: "Unauthorized" }, + { status: 401 }, + ) + } + + return null +} diff --git a/web/proxy.ts b/web/middleware.ts similarity index 94% rename from web/proxy.ts rename to web/middleware.ts index de2d6c1bb..97b86b0bf 100644 --- a/web/proxy.ts +++ b/web/middleware.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from "next/server" /** - * Next.js proxy — validates bearer token and origin on all API routes. + * Next.js middleware — validates bearer token and origin on all API routes. * * The GSD_WEB_AUTH_TOKEN env var is set at server launch. Every /api/* request * must carry a matching `Authorization: Bearer ` header. EventSource @@ -11,7 +11,7 @@ import { NextResponse, type NextRequest } from "next/server" * Additionally, if an `Origin` header is present, it must match the expected * localhost origin to prevent cross-site request forgery. */ -export function proxy(request: NextRequest): NextResponse | undefined { +export function middleware(request: NextRequest): NextResponse | undefined { const { pathname } = request.nextUrl // Only gate API routes