fix(security): activate auth middleware and harden shutdown/update routes (#4023)

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) <noreply@anthropic.com>
This commit is contained in:
Jeremy McSpadden 2026-04-13 06:52:43 -05:00 committed by GitHub
parent b7ad8bf31a
commit ab03677567
6 changed files with 79 additions and 15 deletions

View file

@ -69,19 +69,23 @@ test('app-shell.tsx sendBeacon does not send bare unauthenticated URL', () => {
}
})
// ─── proxy.ts contract tests ────────────────────────────────────────────────
// ─── middleware.ts contract tests ─────────────────────────────────<EFBFBD><EFBFBD>─────────
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')
})

View file

@ -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 = ""

View file

@ -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<Response> {
export async function POST(request: Request): Promise<Response> {
// 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.

View file

@ -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<Response> {
export async function GET(request: Request): Promise<Response> {
// 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<Response> {
}
}
export async function POST(): Promise<Response> {
export async function POST(request: Request): Promise<Response> {
// 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)

47
web/lib/auth-guard.ts Normal file
View file

@ -0,0 +1,47 @@
// GSD Web — Inline auth token verification for sensitive API routes
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
/**
* 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
}

View file

@ -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 <token>` 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