Browse Source

feat(auth): shared-secret trust factor for trusted-header SSO

The source-IP allowlist alone is insufficient behind a Docker bridge: the
userland proxy SNATs every inbound connection to the docker0 gateway, so the
peer IP can't distinguish the outpost from any LAN host that reaches the
published port. Add TRUSTED_PROXY_SECRET as the PRIMARY trust factor — the
proxy injects a secret-bearing header, verified constant-time. IP allowlist
is now optional defense-in-depth. Fails closed if enabled without a secret.

Authored By: Culpur Defense Inc.
pull/2693/head
Culpur Defense Inc. 6 days ago
parent
commit
32dd0ff4e1
  1. 69
      src/server/middleware/trustedAuth.ts
  2. 10
      src/server/utils/config.ts

69
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})`
);
});

10
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'

Loading…
Cancel
Save