mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-18 08:27:18 +02:00
fix(security): reject cross-origin privileged API requests
This commit is contained in:
+92
-10
@@ -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).*)',
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user