fix(security): reject cross-origin privileged API requests

This commit is contained in:
Shadowbroker
2026-08-17 01:06:08 -06:00
parent 4a39c56949
commit 6ab3ca3957
+92 -10
View File
@@ -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<string>();
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).*)',
],
};