From be3ab5823a42f9beac1ae6a59abcde7ad2e238e8 Mon Sep 17 00:00:00 2001 From: BigBodyCobain <43977454+BigBodyCobain@users.noreply.github.com> Date: Thu, 28 May 2026 01:54:23 -0600 Subject: [PATCH] Fix self-host API key proxy auth --- .../proxy/proxyAuthBypassChain.test.ts | 105 ++++++++++++++++++ frontend/src/app/api/[...path]/route.ts | 78 ++++++++++++- frontend/src/components/SettingsPanel.tsx | 2 +- frontend/src/components/ShodanPanel.tsx | 4 +- 4 files changed, 181 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts b/frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts index 99d46af..8d1b092 100644 --- a/frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts +++ b/frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts @@ -110,6 +110,111 @@ describe('proxy CSRF guard on admin-key injection (#249/#254)', () => { expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); }); + it('same-origin request behind a reverse proxy uses X-Forwarded-Host for injection', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://frontend:3000/api/settings/api-keys', { + method: 'GET', + headers: { + host: 'frontend:3000', + origin: 'https://shadowbroker.example', + 'x-forwarded-host': 'shadowbroker.example', + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['settings', 'api-keys'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); + }); + + it('same-origin request behind a Docker bridge proxy can use a private Host with X-Forwarded-Host', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://172.18.0.3:3000/api/settings/api-keys', { + method: 'GET', + headers: { + host: '172.18.0.3:3000', + origin: 'https://shadowbroker.example', + 'x-forwarded-host': 'shadowbroker.example', + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['settings', 'api-keys'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); + }); + + it('same-origin request behind a reverse proxy uses Forwarded host for injection', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://frontend:3000/api/tools/shodan/status', { + method: 'GET', + headers: { + host: 'frontend:3000', + origin: 'https://shadowbroker.example', + forwarded: 'for=172.18.0.1;proto=https;host="shadowbroker.example"', + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['tools', 'shodan', 'status'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); + }); + + it('cross-origin request cannot spoof same-origin with X-Forwarded-Host on a public Host', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('https://shadowbroker.example/api/settings/api-keys', { + method: 'GET', + headers: { + host: 'shadowbroker.example', + origin: 'https://evil.example', + 'x-forwarded-host': 'evil.example', + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['settings', 'api-keys'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull(); + }); + + it('cross-origin request cannot spoof same-origin with X-Forwarded-Host on localhost', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost:3000/api/settings/api-keys', { + method: 'GET', + headers: { + host: 'localhost:3000', + origin: 'https://evil.example', + 'x-forwarded-host': 'evil.example', + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['settings', 'api-keys'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull(); + }); + it('no Origin header (native shell, server-to-server, curl) DOES inject X-Admin-Key', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index ba8cfd0..0c8ee33 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -77,6 +77,72 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean { return false; } +function normalizeHeaderHost(host: string | null): string { + return (host || '').trim().replace(/^"|"$/g, '').toLowerCase(); +} + +function hostnameFromHeaderHost(host: string): string { + const normalized = normalizeHeaderHost(host); + if (!normalized) return ''; + try { + return new URL(`http://${normalized}`).hostname.toLowerCase(); + } catch { + return normalized.replace(/:\d+$/, '').toLowerCase(); + } +} + +function isPrivateIpv4(hostname: string): boolean { + const parts = hostname.split('.').map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + const [first, second] = parts; + return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168); +} + +function isInternalProxyHost(host: string): boolean { + const hostname = hostnameFromHeaderHost(host); + if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { + return false; + } + return ( + !hostname.includes('.') || + isPrivateIpv4(hostname) || + hostname.endsWith('.internal') || + hostname.endsWith('.docker') + ); +} + +function forwardedHostCandidates(req: NextRequest): string[] { + const hosts = new Set(); + const directHost = normalizeHeaderHost(req.headers.get('host')); + if (directHost) hosts.add(directHost); + + if (!isInternalProxyHost(directHost)) { + return [...hosts]; + } + + const forwardedHost = req.headers.get('x-forwarded-host'); + if (forwardedHost) { + for (const value of forwardedHost.split(',')) { + const host = normalizeHeaderHost(value); + if (host) hosts.add(host); + } + } + + const forwarded = req.headers.get('forwarded'); + if (forwarded) { + const hostPattern = /(?:^|[;,])\s*host=(?:"([^"]+)"|([^;,]+))/gi; + let match: RegExpExecArray | null; + while ((match = hostPattern.exec(forwarded)) !== null) { + const host = normalizeHeaderHost(match[1] || match[2] || ''); + if (host) hosts.add(host); + } + } + + return [...hosts]; +} + /** * CSRF guard for the server-side admin-key injection (issues #249 / #254). * @@ -91,8 +157,10 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean { * - The request carries a valid admin session cookie (already auth'd) * - The Origin header is absent (server-to-server fetch, Tauri/Electron * native shells, curl/cli — none of these are browser-CSRF surfaces) - * - The Origin header host matches the request's own Host (genuine - * same-origin browser fetch from our own dashboard) + * - The Origin header host matches the request's own Host or, when the + * direct Host is an internal service name, a reverse proxy's forwarded + * host (genuine same-origin browser fetch from our own dashboard, + * including Docker/Traefik deployments where Host is internal) * * If Origin is present AND doesn't match Host, the caller is a hostile * cross-origin webpage. We refuse to inject the admin key. The backend @@ -110,9 +178,9 @@ function isSameOriginOrNonBrowser(req: NextRequest): boolean { } try { const originUrl = new URL(origin); - const host = req.headers.get('host') || ''; - if (!host) return false; - return originUrl.host.toLowerCase() === host.toLowerCase(); + const originHost = normalizeHeaderHost(originUrl.host); + if (!originHost) return false; + return forwardedHostCandidates(req).includes(originHost); } catch { // Malformed Origin header — be conservative. return false; diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 4daa324..9fe8c44 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1325,7 +1325,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'sentinel' ? 'text-purple-400 border-b-2 border-purple-500 bg-purple-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`} > - {t('settings.shodan').toUpperCase()} + SENTINEL