/** * forward_auth enforcement routes — DC-154 (spec §4 Layer 2). * * GET|POST /network/forward-auth/ — the Caddy forward_auth target * * This route IS the auth check — deliberately NO auth middleware in front * of it and completely stateless. Caddy calls it on every protected * request; a 2xx lets the request through, anything else is the answer the * client sees. Mounted via the SAME lazy-provider pattern as the WG redeem * route (src/app.js p7WgRedeemLazy): no active provider → 503 * { error: 'network provider not active' }. * * Caddy convention: the client IP arrives in X-Forwarded-For (set from the * remote host by the reverse proxy), a chained comma list whose FIRST * entry is the original client. parseForwardedFor() extracts it; fall back * to req.ip when the header is absent. * * Host: X-Forwarded-Host first (proxy-rewritten), then Host. * * TRUST BOUNDARY: forwarded headers are consulted only when the immediate * peer is in trustedProxies; every other request is identified by its * socket address alone. */ 'use strict'; /** * Parse an X-Forwarded-For chain into IP strings. * Returns [] when the header is missing/empty. * '203.0.113.7' → ['203.0.113.7'] * '203.0.113.7, 10.53.0.1, 10.53.0.2' → ['203.0.113.7', '10.53.0.1', '10.53.0.2'] * Port-bearing entries are trimmed: '203.0.113.7:1234' → '203.0.113.7'. * IPv6 bracketed forms keep their brackets stripped: '[fd00::1]:443' → 'fd00::1'. */ function parseForwardedFor(header) { if (typeof header !== 'string') return []; return header .split(',') .map((part) => part.trim()) .filter((part) => part.length > 0) .map((part) => { // ipv6 "[addr]:port" or "[addr]" const v6 = part.match(/^\[([0-9a-fA-F:.]+)\](?::\d+)?$/); if (v6) return v6[1]; // "addr:port" — only strip when the tail is all digits and the head // is not itself a bare ipv6 (no second colon). const colon = part.lastIndexOf(':'); if (colon !== -1 && part.indexOf(':') === colon && /^\d+$/.test(part.slice(colon + 1))) { return part.slice(0, colon); } return part; }) .filter((part) => part.length > 0); } /** Build the forward_auth router. */ module.exports = function networkForwardAuthRoutesFactory({ service, asyncHandler, trustedProxies = null, // e.g. ['127.0.0.1', '::1'] — the Caddy proxy addrs log = { info() {}, warn() {}, error() {} }, } = {}) { const router = require('express').Router(); // eslint-disable-line global-require if (!service || typeof service.check !== 'function') { throw new Error('networkForwardAuthRoutes requires a forward-auth service (check)'); } if (!asyncHandler) { if (process.env.NODE_ENV !== 'test') { throw new Error('networkForwardAuthRoutes requires asyncHandler'); } asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign } // Trust boundary (r7 judge blocker): forwarded headers are honored ONLY // when the immediate peer (socket remote address) is an explicitly // configured trusted proxy (the Caddy reverse proxy on the host). A direct // request — or one arriving via any other hop — gets its headers IGNORED, // so a spoofed X-Forwarded-For cannot smuggle a tailnet identity. function isTrustedPeer(req) { if (trustedProxies === null) { // disabled trust: never honor forwarded headers return false; } const peer = (req.socket && req.socket.remoteAddress) || req.connection?.remoteAddress || null; if (!peer) return false; const norm = peer.startsWith('::ffff:') ? peer.slice(7) : peer; return trustedProxies.includes(norm); } function sourceIpFor(req) { if (isTrustedPeer(req)) { const chain = parseForwardedFor(req.headers['x-forwarded-for']); if (chain.length > 0) return chain[0]; // first entry = original client } // Untrusted/absent proxy info: the socket address is the only // non-spoofable source. (whois of a proxy LAN IP simply resolves to // unknown-device — safe default, never an inherited identity.) const sock = (req.socket && req.socket.remoteAddress) || req.connection?.remoteAddress || req.ip || null; if (typeof sock === 'string' && sock.startsWith('::ffff:')) { return sock.slice(7); } return sock; } function hostFor(req) { if (isTrustedPeer(req)) { const fwd = req.headers['x-forwarded-host']; if (typeof fwd === 'string' && fwd.trim().length > 0) { return fwd.split(',')[0].trim(); } } return req.headers.host || req.hostname || null; } const handle = asyncHandler(async (req, res) => { const sourceIp = sourceIpFor(req); const host = hostFor(req); const decision = await service.check({ sourceIp, host, requestPath: req.originalUrl || req.url, req, }); for (const [name, value] of Object.entries(decision.headers || {})) { res.set(name, value); } return res.status(decision.status).json(decision.body); }, 'network-forward-auth'); router.get('/', handle); router.post('/', handle); log.info && log.info('network:forward-auth', 'forward_auth route mounted (stateless)'); return router; }; module.exports.parseForwardedFor = parseForwardedFor;