From 6ab3ca3957e8574c23db4448f53271d0d8e143b5 Mon Sep 17 00:00:00 2001 From: Shadowbroker <43977454+BigBodyCobain@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:08 -0600 Subject: [PATCH] fix(security): reject cross-origin privileged API requests --- frontend/src/proxy.ts | 102 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index cc6f223..b04154a 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -1,9 +1,9 @@ /** - * Phase 5F-A: CSP nonce plumbing proxy. + * Phase 5F-A: CSP nonce plumbing and privileged-request CSRF boundary. * - * Generates a per-request cryptographic nonce and emits a dynamic - * Content-Security-Policy header for document (page) responses. - * API routes, static assets, and image optimization paths are excluded. + * The API guard runs before route handlers so a browser request rejected as + * cross-origin can never be forwarded through the trusted frontend container + * to a backend local-operator route. */ import { NextRequest, NextResponse } from 'next/server'; @@ -36,7 +36,91 @@ function buildCsp(nonce: string, strictScripts = false): string { return directives.join('; '); } +function isPrivilegedApiPath(pathname: string): boolean { + const path = pathname.replace(/\/+$/, ''); + return ( + path === '/api/refresh' || + path === '/api/debug-latest' || + path === '/api/system/update' || + path === '/api/layers' || + path === '/api/ais/feed' || + path === '/api/mesh/infonet/ingest' || + path === '/api/mesh/meshtastic/send' || + path.startsWith('/api/wormhole/') || + path.startsWith('/api/settings/') || + path.startsWith('/api/ai/') || + path === '/api/ai' || + path.startsWith('/api/tools/') || + path === '/api/tools' || + path.startsWith('/api/mesh/peers') || + path.startsWith('/api/agent-shell/') || + path === '/api/agent-shell' || + path === '/api/sar/mode-b' || + path.startsWith('/api/sar/mode-b/') || + path === '/api/sar/aois' || + path.startsWith('/api/sar/aois/') + ); +} + +function normalizedHost(value: string | null): string { + return (value || '').split(',')[0].trim().replace(/^"|"$/g, '').toLowerCase(); +} + +function isSameOriginOrNonBrowser(request: NextRequest): boolean { + const fetchSite = (request.headers.get('sec-fetch-site') || '').trim().toLowerCase(); + const origin = (request.headers.get('origin') || '').trim(); + + // Modern browsers label ambient cross-site requests even when a particular + // request shape omits Origin (for example navigations/resource loads). + if (fetchSite === 'cross-site' || fetchSite === 'same-site') return false; + + if (!origin) { + // CLI/native/server-to-server callers do not send Sec-Fetch-Site. Normal + // dashboard browser calls are same-origin. Both remain frictionless. + return !fetchSite || fetchSite === 'same-origin'; + } + + let originHost = ''; + try { + originHost = new URL(origin).host.toLowerCase(); + } catch { + return false; + } + if (!originHost) return false; + + const candidates = new Set(); + const directHost = normalizedHost(request.headers.get('host')); + if (directHost) candidates.add(directHost); + const forwardedHost = request.headers.get('x-forwarded-host'); + if (forwardedHost) { + for (const value of forwardedHost.split(',')) { + const host = normalizedHost(value); + if (host) candidates.add(host); + } + } + return candidates.has(originHost); +} + export function proxy(request: NextRequest) { + if (isPrivilegedApiPath(request.nextUrl.pathname) && !isSameOriginOrNonBrowser(request)) { + return NextResponse.json( + { detail: 'Cross-origin privileged request denied' }, + { + status: 403, + headers: { + 'Cache-Control': 'no-store, max-age=0', + Pragma: 'no-cache', + }, + }, + ); + } + + // API requests only need the security boundary above. CSP applies to page + // responses, not JSON/API traffic. + if (request.nextUrl.pathname.startsWith('/api/')) { + return NextResponse.next(); + } + const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); // Forward a nonce for staged CSP support. Strict script-src is opt-in until @@ -60,12 +144,10 @@ export function proxy(request: NextRequest) { export const config = { matcher: [ /* - * Match all document/page paths. Exclude: - * - /api/* (API routes — handled by route handlers) - * - /_next/static/* (static assets) - * - /_next/image/* (image optimization) - * - /favicon.ico (browser icon) + * Match pages AND API routes so privileged browser traffic is screened + * before the catch-all API proxy can forward it. Exclude only static/image + * assets and the favicon. */ - '/((?!api|_next/static|_next/image|favicon.ico).*)', + '/((?!_next/static|_next/image|favicon.ico).*)', ], };