mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-27 09:32:28 +02:00
External audit by @tg12 found three coupled vulnerabilities in the
Next.js admin-auth surface that together let any webpage the operator
visits trigger arbitrary privileged backend calls:
#249/#254 — Cross-origin webpages can have process.env.ADMIN_KEY
injected into their forwarded backend requests just by
issuing fetch('http://localhost:3000/api/wormhole/...')
from a browser tab the operator has open. Full
identity-takeover CSRF.
#255 — When ADMIN_KEY is unset on the server (the default in
.env.example), the admin session route fell through to
GET /api/settings/privacy-profile to "verify" the user-
supplied key. That endpoint is public; it always returns
200 for any X-Admin-Key value. So arbitrary attacker
keys minted full admin session cookies on default
installs.
Both fixes preserve every legitimate UX path. Origin-header gating is
transparent to browser tabs on the dashboard's own host, transparent
to Tauri/native shells (no Origin), and transparent to server-to-
server callers (no Origin). Only cross-origin browser fetches with a
foreign Origin lose the injection.
frontend/src/app/api/[...path]/route.ts
Adds isSameOriginOrNonBrowser() — checks the Origin header against
the request's own Host. Allow if no Origin (native/server-to-
server), allow if Origin host == Host host (same-origin), reject
otherwise. The admin-key injection now requires EITHER a valid
session cookie (auth) OR same-origin-or-non-browser (CSRF guard).
frontend/src/app/api/admin/session/route.ts
verifyAdminKey() simplified to local-only string comparison. When
ADMIN_KEY is configured, the supplied key must match exactly.
When ADMIN_KEY is unset, minting is refused entirely with a clear
message pointing the operator at the backend's auto-trust-loopback
behavior (SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=1, the
Docker default — local users keep working without a session).
The previous round-trip to /api/settings/privacy-profile was both
the source of the bug AND useless on its own merits (the endpoint
is public). Removing it makes the validation honest about what
it's checking.
Tests:
frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts (new, 12)
Cross-origin fetch to sensitive route → no admin-key injection
Cross-origin POST to sensitive route → no admin-key injection
Same-origin fetch → admin-key injection works
No-Origin (server-to-server / native) → admin-key injection works
Valid session cookie on cross-origin → cookie auth wins
Malformed Origin → conservative reject
Non-sensitive routes unaffected
Mint with ADMIN_KEY unset → refused (no fetch happens)
Empty key → 400
Mint with matching ADMIN_KEY → success
Mint with mismatched key → 403
Mint never round-trips to the backend (local-only validation)
frontend/src/__tests__/desktop/adminSessionBoundary.test.ts (updated)
Three tests updated to reflect the new local-only validation
contract. The previous tests asserted fetchMock.toHaveBeenCalled
which validated the now-removed (and broken) backend round-trip.
Full frontend suite: 707 passed, 72 files. No regressions.
Credit: @tg12 for the report. The cross-origin CSRF angle was
non-obvious — they specifically called out that the proxy's
admin-key injection was an open door for any page running in the
operator's browser, which is exactly the right framing.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -45,12 +45,12 @@ describe('admin/session boundary hardening', () => {
|
||||
});
|
||||
|
||||
it('accepts a verified admin key and reports the minted session as present', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
// Issue #255 fix: the route no longer round-trips to the backend
|
||||
// to "verify" the key (the previous implementation called a public
|
||||
// endpoint that always returned 200, so any key was accepted when
|
||||
// ADMIN_KEY was unset). Local string comparison is the only
|
||||
// validation, so we don't mock fetch and don't assert it was called.
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
@@ -65,7 +65,8 @@ describe('admin/session boundary hardening', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(cookie).toContain('sb_admin_session=');
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
// Validation is local-only — no backend round-trip should happen.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const getReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'GET',
|
||||
@@ -88,12 +89,8 @@ describe('admin/session boundary hardening', () => {
|
||||
});
|
||||
|
||||
it('invalidates the previous admin session token when a new one is minted', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
// Issue #255 fix: no backend round-trip. Validation is local-only.
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const firstReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
@@ -135,21 +132,25 @@ describe('admin/session boundary hardening', () => {
|
||||
);
|
||||
const newBody = await newSessionCheck.json();
|
||||
expect(newBody.hasSession).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
// Local validation only — backend should not be called during minting.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects session minting when frontend admin key is set but backend has no configured admin key', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: 'Forbidden — admin key not configured' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
it('refuses session minting when frontend ADMIN_KEY env var is unset (#255)', async () => {
|
||||
// Issue #255 (tg12): previously, when ADMIN_KEY was unset the route
|
||||
// fell through to a public backend endpoint that always returned
|
||||
// 200, so any user-supplied key minted a full admin session. The
|
||||
// fix is to refuse minting entirely when ADMIN_KEY is unconfigured
|
||||
// and surface a clear message pointing the operator at the
|
||||
// backend's auto-trust-loopback behavior.
|
||||
process.env.ADMIN_KEY = '';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
body: JSON.stringify({ adminKey: 'any-key-an-attacker-supplies' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
@@ -158,8 +159,11 @@ describe('admin/session boundary hardening', () => {
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.detail).toBe('Forbidden — admin key not configured');
|
||||
expect(String(body.detail)).toMatch(/no admin key configured/i);
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
// Crucially: no backend round-trip happens. The previous broken
|
||||
// verifyAgainstBackend() call must NOT be re-introduced.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not forward raw x-admin-key headers through the sensitive proxy path', async () => {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Regression coverage for the auth-bypass chain audited by @tg12 in
|
||||
* issues #249, #254, and #255.
|
||||
*
|
||||
* #249 / #254 — Cross-origin webpages must not have the operator's
|
||||
* server-side ADMIN_KEY injected into their forwarded requests. The
|
||||
* proxy enforces a CSRF guard by checking the Origin header against
|
||||
* the request's own Host header. Same-origin (the dashboard itself),
|
||||
* Tauri/native shells (no Origin), and authenticated session cookies
|
||||
* are all allowed; cross-origin browser fetches with a foreign Origin
|
||||
* are rejected.
|
||||
*
|
||||
* #255 — Admin session minting must require ADMIN_KEY to be configured
|
||||
* AND the supplied key to match exactly. The previous implementation
|
||||
* round-tripped to a public backend endpoint (/api/settings/privacy-
|
||||
* profile) which always returns 200, so any key value would mint a
|
||||
* full admin session when ADMIN_KEY was unset on the server.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { GET as proxyGet, POST as proxyPost } from '@/app/api/[...path]/route';
|
||||
import { POST as postAdminSession } from '@/app/api/admin/session/route';
|
||||
|
||||
|
||||
function capturedHeaders(fetchMock: ReturnType<typeof vi.fn>): Headers {
|
||||
const forwarded = fetchMock.mock.calls[0]?.[1];
|
||||
return new Headers((forwarded as RequestInit | undefined)?.headers);
|
||||
}
|
||||
|
||||
|
||||
describe('proxy CSRF guard on admin-key injection (#249/#254)', () => {
|
||||
const ADMIN_KEY = 'env-side-admin-key-32-chars-min!!!!!';
|
||||
const originalAdminKey = process.env.ADMIN_KEY;
|
||||
const originalBackendUrl = process.env.BACKEND_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ADMIN_KEY = ADMIN_KEY;
|
||||
process.env.BACKEND_URL = 'http://127.0.0.1:8000';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ADMIN_KEY = originalAdminKey;
|
||||
process.env.BACKEND_URL = originalBackendUrl;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('cross-origin GET to a sensitive route does NOT inject X-Admin-Key', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// Hostile-webpage CSRF: Origin is a different site than Host.
|
||||
const req = new NextRequest('http://localhost:3000/api/wormhole/identity', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'http://evil.example',
|
||||
},
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['wormhole', 'identity'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
it('cross-origin POST to a sensitive route does NOT inject X-Admin-Key', 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/wormhole/identity/bootstrap', {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'http://attacker.example',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
await proxyPost(req, {
|
||||
params: Promise.resolve({ path: ['wormhole', 'identity', 'bootstrap'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
it('same-origin request (Origin matches Host) DOES inject X-Admin-Key', 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/wormhole/identity', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['wormhole', 'identity'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
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' } }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/settings/wormhole', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
// no Origin
|
||||
},
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['settings', 'wormhole'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('cross-origin request with a valid session cookie STILL injects (cookie auth wins)', async () => {
|
||||
// Mint a session first (against the real handler).
|
||||
const mintReq = new NextRequest('http://localhost:3000/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: ADMIN_KEY }),
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const mintRes = await postAdminSession(mintReq);
|
||||
const cookieHeader = mintRes.headers.get('set-cookie') || '';
|
||||
const cookie = cookieHeader.split(';')[0];
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// Now hit a sensitive route from a foreign Origin but WITH the cookie.
|
||||
// Since the cookie itself is SameSite=strict, a real cross-origin
|
||||
// browser fetch wouldn't carry it — but if the operator deliberately
|
||||
// forwards their session (e.g. CLI tool), it should work.
|
||||
const req = new NextRequest('http://localhost:3000/api/wormhole/identity', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'http://evil.example',
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['wormhole', 'identity'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('malformed Origin header is treated as not-same-origin (conservative)', 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/wormhole/identity', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'not-a-real-origin',
|
||||
},
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['wormhole', 'identity'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
it('cross-origin to a non-sensitive route is unaffected (no injection either way)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// /api/health is not sensitive — no admin-key injection happens at all.
|
||||
const req = new NextRequest('http://localhost:3000/api/health', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
host: 'localhost:3000',
|
||||
origin: 'http://evil.example',
|
||||
},
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['health'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('admin session minting refuses arbitrary keys when ADMIN_KEY unset (#255)', () => {
|
||||
const originalAdminKey = process.env.ADMIN_KEY;
|
||||
const originalBackendUrl = process.env.BACKEND_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.ADMIN_KEY;
|
||||
process.env.BACKEND_URL = 'http://127.0.0.1:8000';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ADMIN_KEY = originalAdminKey;
|
||||
process.env.BACKEND_URL = originalBackendUrl;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('refuses to mint a session when ADMIN_KEY is unset on the server', async () => {
|
||||
// Even if the (previously-relied-on) public endpoint returned 200,
|
||||
// the new logic must not accept the key — it does local validation only.
|
||||
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/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'literally-anything-an-attacker-sends' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
const res = await postAdminSession(req);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(false);
|
||||
expect(String(body.detail)).toMatch(/no admin key configured/i);
|
||||
|
||||
// No session cookie should have been set
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
|
||||
// The buggy round-trip to the public endpoint must no longer happen
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an empty key with 400 (Missing admin key)', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: '' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
const res = await postAdminSession(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('admin session minting still works when ADMIN_KEY is set (#255 regression)', () => {
|
||||
const ADMIN_KEY = 'configured-admin-key-32-chars-min!!!!';
|
||||
const originalAdminKey = process.env.ADMIN_KEY;
|
||||
const originalBackendUrl = process.env.BACKEND_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ADMIN_KEY = ADMIN_KEY;
|
||||
process.env.BACKEND_URL = 'http://127.0.0.1:8000';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ADMIN_KEY = originalAdminKey;
|
||||
process.env.BACKEND_URL = originalBackendUrl;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('mints a session when the supplied key matches the configured ADMIN_KEY', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: ADMIN_KEY }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
const res = await postAdminSession(req);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('set-cookie')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a non-matching key with 403', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'wrong-key-attempted-by-attacker' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
const res = await postAdminSession(req);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT round-trip to a backend endpoint for verification (local-only validation)', 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/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: ADMIN_KEY }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
await postAdminSession(req);
|
||||
|
||||
// The previous implementation did a fetch to verify against the
|
||||
// backend; the fix removes that round-trip because the backend
|
||||
// endpoint it called was public anyway. Local string-compare suffices.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,48 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF guard for the server-side admin-key injection (issues #249 / #254).
|
||||
*
|
||||
* The proxy injects ``process.env.ADMIN_KEY`` into the forwarded
|
||||
* X-Admin-Key header for sensitive backend routes. Without an origin
|
||||
* check, any cross-origin webpage the operator visits could fire
|
||||
* ``fetch('http://localhost:3000/api/wormhole/identity/bootstrap')`` and
|
||||
* have that request get the operator's admin key injected for free —
|
||||
* full identity-takeover CSRF.
|
||||
*
|
||||
* We allow injection when ANY of these is true:
|
||||
* - 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)
|
||||
*
|
||||
* 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
|
||||
* then sees the request without auth and rejects it via
|
||||
* require_local_operator — exactly the desired outcome.
|
||||
*/
|
||||
function isSameOriginOrNonBrowser(req: NextRequest): boolean {
|
||||
const origin = req.headers.get('origin');
|
||||
if (!origin) {
|
||||
// No Origin header = server-to-server / native shell / older browser
|
||||
// doing a same-origin GET. CSRF requires the attacker to control a
|
||||
// page running in a browser, which always sends Origin on the
|
||||
// dangerous methods. Treat missing Origin as not-CSRF.
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const originUrl = new URL(origin);
|
||||
const host = req.headers.get('host') || '';
|
||||
if (!host) return false;
|
||||
return originUrl.host.toLowerCase() === host.toLowerCase();
|
||||
} catch {
|
||||
// Malformed Origin header — be conservative.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResponse> {
|
||||
try {
|
||||
const isMesh = pathSegments[0] === 'mesh';
|
||||
@@ -192,8 +234,23 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResp
|
||||
}
|
||||
});
|
||||
if (isSensitiveProxyPath(pathSegments)) {
|
||||
// Issues #249 / #254: gate the server-side admin-key injection on
|
||||
// either a valid admin session cookie OR a same-origin request.
|
||||
// Cross-origin webpages must not silently inherit the operator's
|
||||
// ADMIN_KEY just by being open in the same browser.
|
||||
const cookieToken = req.cookies.get(ADMIN_COOKIE)?.value || '';
|
||||
const injectedAdmin = process.env.ADMIN_KEY || resolveAdminSessionToken(cookieToken) || '';
|
||||
const sessionAdminKey = resolveAdminSessionToken(cookieToken) || '';
|
||||
const allowEnvKeyInjection = isSameOriginOrNonBrowser(req);
|
||||
let injectedAdmin = '';
|
||||
if (sessionAdminKey) {
|
||||
// Authenticated session always works — Origin doesn't matter
|
||||
// because the cookie itself is same-site / strict.
|
||||
injectedAdmin = sessionAdminKey;
|
||||
} else if (allowEnvKeyInjection && process.env.ADMIN_KEY) {
|
||||
// Fall back to the server-side ADMIN_KEY only for legitimate
|
||||
// callers (same-origin dashboard, Tauri shell, server-to-server).
|
||||
injectedAdmin = process.env.ADMIN_KEY;
|
||||
}
|
||||
if (injectedAdmin) {
|
||||
forwardHeaders.set('X-Admin-Key', injectedAdmin);
|
||||
}
|
||||
|
||||
@@ -22,40 +22,41 @@ function cookieOptions() {
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyAdminKey(adminKey: string): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||
const backendUrl = process.env.BACKEND_URL ?? 'http://127.0.0.1:8000';
|
||||
const verifyAgainstBackend = async (): Promise<
|
||||
{ ok: true } | { ok: false; detail: string }
|
||||
> => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/settings/privacy-profile`, {
|
||||
method: 'GET',
|
||||
headers: { 'X-Admin-Key': adminKey },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return {
|
||||
ok: false,
|
||||
detail: String(data?.detail || data?.message || 'Unable to verify admin key'),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
detail: 'Unable to verify admin key against backend',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify an operator-supplied admin key before minting a session cookie.
|
||||
*
|
||||
* Issue #255: the previous implementation, when ADMIN_KEY was unset on
|
||||
* the server, fell through to verifying against the backend by GET-ing
|
||||
* /api/settings/privacy-profile. That endpoint is public — it returns
|
||||
* 200 for any X-Admin-Key value (or none at all) — so the fallback
|
||||
* accepted *arbitrary* keys and minted full admin sessions for them.
|
||||
*
|
||||
* Fix: require ADMIN_KEY to be configured before any session can be
|
||||
* minted, and do the validation locally instead of round-tripping to a
|
||||
* potentially-public endpoint. If ADMIN_KEY is unset, the backend
|
||||
* already auto-trusts loopback / docker-bridge callers via
|
||||
* require_local_operator + SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR,
|
||||
* so legitimate local users keep working — they just don't get (and
|
||||
* don't need) a privileged session cookie.
|
||||
*/
|
||||
async function verifyAdminKey(
|
||||
adminKey: string,
|
||||
): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||
const configuredAdmin = String(process.env.ADMIN_KEY || '').trim();
|
||||
if (configuredAdmin) {
|
||||
if (adminKey !== configuredAdmin) {
|
||||
return { ok: false, detail: 'Invalid admin key' };
|
||||
}
|
||||
return verifyAgainstBackend();
|
||||
if (!configuredAdmin) {
|
||||
return {
|
||||
ok: false,
|
||||
detail:
|
||||
'No admin key configured on the server. Local-host requests are '
|
||||
+ 'already auto-trusted by the backend — no session is needed. '
|
||||
+ 'To enable session-based admin auth, set ADMIN_KEY in the backend '
|
||||
+ 'environment and restart.',
|
||||
};
|
||||
}
|
||||
|
||||
return verifyAgainstBackend();
|
||||
if (adminKey !== configuredAdmin) {
|
||||
return { ok: false, detail: 'Invalid admin key' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
||||
Reference in New Issue
Block a user