diff --git a/src/server/middleware/trustedAuth.ts b/src/server/middleware/trustedAuth.ts index 90e8b4c2..4bbf4910 100644 --- a/src/server/middleware/trustedAuth.ts +++ b/src/server/middleware/trustedAuth.ts @@ -1,8 +1,10 @@ +import { timingSafeEqual } from 'node:crypto'; + /* * Trusted-header SSO (Culpur Defense fork). * - * Runs before route handlers. When TRUSTED_PROXY_ENABLED is set AND the request's - * real socket peer is in TRUSTED_PROXY_IPS, this reads the identity header that a + * Runs before route handlers. When TRUSTED_PROXY_ENABLED is set and the request + * proves it came from the trusted proxy, this reads the identity header that a * trusted upstream (an Authentik forward-auth outpost) has asserted, resolves or * auto-provisions the matching wg-easy user, and establishes the normal wg-easy * session. Everything downstream (getCurrentUser -> definePermissionEventHandler @@ -10,36 +12,69 @@ * * SECURITY MODEL — why this is not an auth bypass: * 1. Off by default. Stock wg-easy never runs this branch. - * 2. Source-IP allowlist. The identity header is honoured ONLY when the request - * arrives from the trusted proxy. We read the real connection peer via - * getRequestIP(event, { xForwardedFor: false }) — NOT X-Forwarded-For, which - * a client could spoof. In our topology the outpost also strips any - * client-supplied X-authentik-* headers and sets its own, so the header cannot - * arrive pre-set from a browser. + * 2. Shared secret (PRIMARY). The proxy injects TRUSTED_PROXY_SECRET_HEADER with + * a secret only it knows; wg-easy verifies it with a constant-time compare. A + * direct-to-port attacker on the LAN cannot forge it. THIS is the real trust + * boundary in a shared-bridge topology, where Docker SNATs every inbound + * connection to the docker0 gateway so the source IP alone can't tell the + * proxy apart from any other host that can reach the published port. + * 3. Source-IP allowlist (OPTIONAL defense-in-depth). If TRUSTED_PROXY_IPS is + * set, the real socket peer must also match. Read via getRequestIP(event, + * { xForwardedFor: false }) — the connection remoteAddress, NOT the spoofable + * X-Forwarded-For. Left empty when SNAT makes the peer non-discriminating. + * + * In our topology the outpost also strips any client-supplied X-authentik-* headers + * and sets its own, so the identity header cannot arrive pre-set from a browser. * - * If the feature is disabled, the source is not allowlisted, or no header is - * present, this middleware does nothing and the request falls through to the - * existing password/session auth untouched. + * If the feature is disabled, the secret is missing/wrong, the (optional) IP check + * fails, or no identity header is present, this middleware does nothing and the + * request falls through to the existing password/session auth untouched. */ + +/** Constant-time string compare that never throws on length/'' mismatch. */ +function secretMatches(provided: string | undefined, expected: string): boolean { + if (!provided) { + return false; + } + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) { + return false; + } + return timingSafeEqual(a, b); +} + export default defineEventHandler(async (event) => { if (!WG_ENV.TRUSTED_PROXY_ENABLED) { return; } + // Fail closed: enabling the feature without a secret must NOT trust anything. + if (!WG_ENV.TRUSTED_PROXY_SECRET) { + return; + } + // Only act on API calls and page loads; leave setup/i18n alone (mirrors setup.ts). const url = getRequestURL(event); if (url.pathname.startsWith('/_i18n/') || url.pathname.startsWith('/setup/')) { return; } - // 1. Trust gate — the real socket peer must be an allowlisted proxy. - // xForwardedFor:false makes h3 return the connection remoteAddress, so a - // spoofed X-Forwarded-For cannot satisfy this check. - const peer = getRequestIP(event, { xForwardedFor: false }); - if (!peer || !WG_ENV.TRUSTED_PROXY_IPS.includes(peer)) { + // 1a. Trust gate — the proxy-injected shared secret must match (constant time). + const providedSecret = getHeader(event, WG_ENV.TRUSTED_PROXY_SECRET_HEADER); + if (!secretMatches(providedSecret, WG_ENV.TRUSTED_PROXY_SECRET)) { return; } + // 1b. Optional defense-in-depth — if an IP allowlist is configured, the real + // socket peer must also be in it. Skipped when the list is empty (SNAT case). + if (WG_ENV.TRUSTED_PROXY_IPS.length > 0) { + const peer = getRequestIP(event, { xForwardedFor: false }); + if (!peer || !WG_ENV.TRUSTED_PROXY_IPS.includes(peer)) { + return; + } + } + // 2. Read the identity the upstream verified. const username = getHeader(event, WG_ENV.TRUSTED_PROXY_HEADER)?.trim(); if (!username) { @@ -76,6 +111,6 @@ export default defineEventHandler(async (event) => { await session.update({ userId: user.id }); SERVER_DEBUG( - `Trusted-header SSO: session established for ${user.id} (${user.username}) from ${peer}` + `Trusted-header SSO: session established for ${user.id} (${user.username})` ); }); diff --git a/src/server/utils/config.ts b/src/server/utils/config.ts index 8f2a4b34..4e00c5e7 100644 --- a/src/server/utils/config.ts +++ b/src/server/utils/config.ts @@ -51,6 +51,16 @@ export const WG_ENV = { * allowlist this would be an auth bypass. Leave both unset to keep the feature off. */ TRUSTED_PROXY_ENABLED: process.env.TRUSTED_PROXY_ENABLED === 'true', + /** + * Shared secret the trusted proxy injects (PRIMARY trust factor). The feature + * fails closed if this is unset while enabled. In a shared-bridge / SNAT topology + * this — not the source IP — is what proves a request came from the proxy. + */ + TRUSTED_PROXY_SECRET: process.env.TRUSTED_PROXY_SECRET ?? '', + /** Header the proxy uses to carry the shared secret. */ + TRUSTED_PROXY_SECRET_HEADER: ( + process.env.TRUSTED_PROXY_SECRET_HEADER ?? 'x-wg-proxy-secret' + ).toLowerCase(), /** Header carrying the verified username, e.g. 'x-authentik-username'. */ TRUSTED_PROXY_HEADER: ( process.env.TRUSTED_PROXY_HEADER ?? 'x-authentik-username'