/** * 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. */ '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, 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 } function sourceIpFor(req) { const chain = parseForwardedFor(req.headers['x-forwarded-for']); if (chain.length > 0) return chain[0]; // first entry = original client return req.ip || null; } function hostFor(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;