mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-31 03:19:42 +02:00
Fix self-host API key proxy auth
This commit is contained in:
@@ -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' } }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user