107 lines
3.8 KiB
JavaScript
107 lines
3.8 KiB
JavaScript
/**
|
|
* 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;
|