[security] Close tg12 auth-bypass chain #249, #254, #255 (#263)

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:
Shadowbroker
2026-05-20 20:59:40 -06:00
committed by GitHub
parent 729ea78cb2
commit bcc2d036b3
4 changed files with 446 additions and 56 deletions
+58 -1
View File
@@ -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);
}
+33 -32
View File
@@ -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) {