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:
parent
b7ad8bf31a
commit
ab03677567
6 changed files with 79 additions and 15 deletions
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
47
web/lib/auth-guard.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue