DC-154: forward_auth enforcement endpoint (whois + access map + sensitive step-up) + tests
This commit is contained in:
6 files changed
+789
-1
No files matched your search
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in new issue
Block a user