Fix self-host API key proxy auth

This commit is contained in:
BigBodyCobain
2026-05-28 01:54:23 -06:00
parent ef52bd03d2
commit be3ab5823a
4 changed files with 181 additions and 8 deletions
@@ -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' } }),
+73 -5
View File
@@ -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<string>();
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;
+1 -1
View File
@@ -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)]'}`}
>
<Satellite size={10} />
{t('settings.shodan').toUpperCase()}
SENTINEL
</button>
<button
onClick={() => setActiveTab('sar')}
+2 -2
View File
@@ -576,7 +576,7 @@ export default function ShodanPanel({
fetch(`${API_BASE}/api/settings/api-keys`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ env_key: 'SHODAN_API_KEY', value: shodanApiKey.trim() }),
body: JSON.stringify({ SHODAN_API_KEY: shodanApiKey.trim() }),
})
.then(() => refreshStatus())
.finally(() => setKeySaving(false));
@@ -599,7 +599,7 @@ export default function ShodanPanel({
fetch(`${API_BASE}/api/settings/api-keys`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ env_key: 'SHODAN_API_KEY', value: shodanApiKey.trim() }),
body: JSON.stringify({ SHODAN_API_KEY: shodanApiKey.trim() }),
})
.then(() => refreshStatus())
.finally(() => setKeySaving(false));