merge dc/DC-154 into p7-wave1
This commit is contained in:
6 files changed
+789
-1
No files matched your search
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* forward_auth enforcement tests — DC-154 (spec §4 Layer 2).
|
||||||
|
*
|
||||||
|
* Hermetic: the app is a bare Express mount of routes/network-forward-auth
|
||||||
|
* with a fully mocked provider.whois, stores, sessionValidator, and auditLog.
|
||||||
|
* No disk, no network, no app context.
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
const { createForwardAuthService } = require('../src/network/forward-auth');
|
||||||
|
const forwardAuthRoutes = require('../routes/network-forward-auth');
|
||||||
|
const { parseForwardedFor } = forwardAuthRoutes;
|
||||||
|
|
||||||
|
// ── fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PERSON = 'person:sami';
|
||||||
|
const DEVICE = 'wgd:abc123';
|
||||||
|
const OTHER_PERSON = 'person:guest';
|
||||||
|
|
||||||
|
const RESOURCES = [
|
||||||
|
{ name: 'plex', displayName: 'Plex', sensitive: false },
|
||||||
|
{ name: 'vault', displayName: 'Vault', sensitive: true },
|
||||||
|
{ name: 'nas', displayName: 'NAS', sensitive: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildDeps(overrides = {}) {
|
||||||
|
const auditEntries = [];
|
||||||
|
const deps = {
|
||||||
|
provider: {
|
||||||
|
id: () => 'wireguard',
|
||||||
|
whois(ip) {
|
||||||
|
if (ip === '10.53.0.7') return { personId: PERSON, deviceId: DEVICE };
|
||||||
|
if (ip === '10.53.0.9') return { personId: OTHER_PERSON, deviceId: 'wgd:other' };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accessStore: {
|
||||||
|
effectiveAccess(principal) {
|
||||||
|
// person:sami is allowed plex+vault; person:guest only plex.
|
||||||
|
const map = {
|
||||||
|
[PERSON]: { plex: true, 'plex.internal': true, vault: true, nas: false },
|
||||||
|
[OTHER_PERSON]: { plex: true, vault: false, nas: false },
|
||||||
|
};
|
||||||
|
return { ok: true, resources: map[principal.id] || {} };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resourcesStore: {
|
||||||
|
listResources() { return Promise.resolve(RESOURCES.map(r => ({ ...r }))); },
|
||||||
|
},
|
||||||
|
peopleStore: {
|
||||||
|
getPerson(id) {
|
||||||
|
if (id === PERSON) return { id, groups: ['admins'] };
|
||||||
|
if (id === OTHER_PERSON) return { id, groups: ['guests'] };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionValidator: () => false,
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
auditLog: {
|
||||||
|
logForwardAuth(entry) { auditEntries.push(entry); },
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
deps.auditEntries = auditEntries;
|
||||||
|
return deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(deps) {
|
||||||
|
const app = express();
|
||||||
|
const service = createForwardAuthService(deps);
|
||||||
|
app.use('/network/forward-auth', forwardAuthRoutes({ service }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: one request with the usual headers.
|
||||||
|
function hit(app, { ip = '10.53.0.7', host = 'plex.sami', method = 'get', session = false } = {}) {
|
||||||
|
const req = request(app)[method]('/network/forward-auth/')
|
||||||
|
.set('X-Forwarded-For', ip)
|
||||||
|
.set('X-Forwarded-Host', host);
|
||||||
|
if (session) req.set('Cookie', 'dc_session=valid');
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── service: decision matrix ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DC-154 forward_auth service (createForwardAuthService.check)', () => {
|
||||||
|
test('unknown device → 401 { error: "unknown-device" } + WWW-Authenticate', async () => {
|
||||||
|
const svc = createForwardAuthService(buildDeps());
|
||||||
|
const d = await svc.check({ sourceIp: '10.99.99.99', host: 'plex.sami', requestPath: '/' });
|
||||||
|
expect(d.status).toBe(401);
|
||||||
|
expect(d.body).toEqual({ error: 'unknown-device' });
|
||||||
|
expect(d.headers['WWW-Authenticate']).toMatch(/ForwardAuth/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whois throwing is treated as unknown device, not a 500', async () => {
|
||||||
|
const deps = buildDeps({
|
||||||
|
provider: { whois() { return Promise.reject(new Error('cli down')); } },
|
||||||
|
});
|
||||||
|
const svc = createForwardAuthService(deps);
|
||||||
|
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
|
||||||
|
expect(d.status).toBe(401);
|
||||||
|
expect(d.body).toEqual({ error: 'unknown-device' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmatched host → 404 { error: "unknown-resource" }', async () => {
|
||||||
|
const svc = createForwardAuthService(buildDeps());
|
||||||
|
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'nope.sami', requestPath: '/' });
|
||||||
|
expect(d.status).toBe(404);
|
||||||
|
expect(d.body).toEqual({ error: 'unknown-resource' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('denied by access map → 403 { error: "forbidden", resource, personId }', async () => {
|
||||||
|
const svc = createForwardAuthService(buildDeps());
|
||||||
|
// guest (10.53.0.9) is not granted vault
|
||||||
|
const d = await svc.check({ sourceIp: '10.53.0.9', host: 'vault.sami', requestPath: '/' });
|
||||||
|
expect(d.status).toBe(403);
|
||||||
|
expect(d.body).toEqual({ error: 'forbidden', resource: 'vault', personId: OTHER_PERSON });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowed non-sensitive → 200 tailnet identity + X-Forwarded headers', async () => {
|
||||||
|
const svc = createForwardAuthService(buildDeps());
|
||||||
|
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
|
||||||
|
expect(d.status).toBe(200);
|
||||||
|
expect(d.body).toEqual({ authenticated: 'tailnet', personId: PERSON, deviceId: DEVICE });
|
||||||
|
expect(d.headers['X-Forwarded-User']).toBe(PERSON);
|
||||||
|
expect(d.headers['X-Forwarded-Person']).toBe(PERSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowed sensitive without session → 401 { error: "step-up-required", loginUrl }', async () => {
|
||||||
|
const svc = createForwardAuthService(buildDeps());
|
||||||
|
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
|
||||||
|
expect(d.status).toBe(401);
|
||||||
|
expect(d.body).toEqual({ error: 'step-up-required', loginUrl: '/auth/login' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowed sensitive with valid session → 200 { authenticated: "step-up" } + headers', async () => {
|
||||||
|
const deps = buildDeps({ sessionValidator: () => true });
|
||||||
|
const svc = createForwardAuthService(deps);
|
||||||
|
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
|
||||||
|
expect(d.status).toBe(200);
|
||||||
|
expect(d.body).toEqual({ authenticated: 'step-up', personId: PERSON });
|
||||||
|
expect(d.headers['X-Forwarded-User']).toBe(PERSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-label resource names match by leading-subdomain prefix (plex.internal)', async () => {
|
||||||
|
const deps = buildDeps({
|
||||||
|
resourcesStore: {
|
||||||
|
listResources() {
|
||||||
|
return [
|
||||||
|
{ name: 'plex', sensitive: false },
|
||||||
|
{ name: 'plex.internal', sensitive: false },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const svc = createForwardAuthService(deps);
|
||||||
|
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.internal.sami', requestPath: '/' });
|
||||||
|
expect(d.status).toBe(200);
|
||||||
|
// longest matching name wins — not the shorter 'plex'
|
||||||
|
const audit = deps.auditEntries.find(e => e.event === 'forward_auth_allow');
|
||||||
|
expect(audit.resource).toBe('plex.internal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── service: audit entries ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DC-154 audit entries per outcome', () => {
|
||||||
|
test('allow → { event: "forward_auth_allow", personId, resource, host }', async () => {
|
||||||
|
const deps = buildDeps();
|
||||||
|
const svc = createForwardAuthService(deps);
|
||||||
|
await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
|
||||||
|
expect(deps.auditEntries).toHaveLength(1);
|
||||||
|
expect(deps.auditEntries[0]).toEqual(expect.objectContaining({
|
||||||
|
event: 'forward_auth_allow',
|
||||||
|
personId: PERSON,
|
||||||
|
resource: 'plex',
|
||||||
|
host: 'plex.sami',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deny (forbidden) → forward_auth_deny with resource + personId', async () => {
|
||||||
|
const deps = buildDeps();
|
||||||
|
const svc = createForwardAuthService(deps);
|
||||||
|
await svc.check({ sourceIp: '10.53.0.9', host: 'vault.sami', requestPath: '/' });
|
||||||
|
expect(deps.auditEntries[0]).toEqual(expect.objectContaining({
|
||||||
|
event: 'forward_auth_deny',
|
||||||
|
personId: OTHER_PERSON,
|
||||||
|
resource: 'vault',
|
||||||
|
host: 'vault.sami',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deny (unknown resource) → forward_auth_deny with null resource', async () => {
|
||||||
|
const deps = buildDeps();
|
||||||
|
const svc = createForwardAuthService(deps);
|
||||||
|
await svc.check({ sourceIp: '10.53.0.7', host: 'nope.sami', requestPath: '/' });
|
||||||
|
expect(deps.auditEntries[0].event).toBe('forward_auth_deny');
|
||||||
|
expect(deps.auditEntries[0].resource).toBeNull();
|
||||||
|
expect(deps.auditEntries[0].personId).toBe(PERSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sensitive step-up both outcomes → forward_auth_step_up', async () => {
|
||||||
|
const deps = buildDeps({ sessionValidator: () => false });
|
||||||
|
const svc = createForwardAuthService(deps);
|
||||||
|
await svc.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
|
||||||
|
expect(deps.auditEntries[0].event).toBe('forward_auth_step_up');
|
||||||
|
expect(deps.auditEntries[0].outcome).toBe('required');
|
||||||
|
|
||||||
|
deps.auditEntries.length = 0;
|
||||||
|
const okDeps = buildDeps({ sessionValidator: () => true });
|
||||||
|
const svc2 = createForwardAuthService(okDeps);
|
||||||
|
await svc2.check({ sourceIp: '10.53.0.7', host: 'vault.sami', requestPath: '/', req: {} });
|
||||||
|
expect(okDeps.auditEntries[0]).toEqual(expect.objectContaining({
|
||||||
|
event: 'forward_auth_step_up',
|
||||||
|
outcome: 'granted',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit failures never break the auth decision', async () => {
|
||||||
|
const deps = buildDeps({
|
||||||
|
auditLog: { logForwardAuth() { throw new Error('audit sink down'); } },
|
||||||
|
});
|
||||||
|
const svc = createForwardAuthService(deps);
|
||||||
|
const d = await svc.check({ sourceIp: '10.53.0.7', host: 'plex.sami', requestPath: '/' });
|
||||||
|
expect(d.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── route: HTTP surface ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DC-154 route (GET/POST /network/forward-auth/)', () => {
|
||||||
|
test('GET unknown device → 401 + WWW-Authenticate header', async () => {
|
||||||
|
const app = buildApp(buildDeps());
|
||||||
|
const res = await hit(app, { ip: '10.99.99.99' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: 'unknown-device' });
|
||||||
|
expect(res.headers['www-authenticate']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET unknown resource → 404', async () => {
|
||||||
|
const app = buildApp(buildDeps());
|
||||||
|
const res = await hit(app, { host: 'nope.sami' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toEqual({ error: 'unknown-resource' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET denied → 403 with resource + personId', async () => {
|
||||||
|
const app = buildApp(buildDeps());
|
||||||
|
const res = await hit(app, { ip: '10.53.0.9', host: 'vault.sami' });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toBe('forbidden');
|
||||||
|
expect(res.body.resource).toBe('vault');
|
||||||
|
expect(res.body.personId).toBe(OTHER_PERSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET allowed non-sensitive → 200 tailnet + X-Forwarded-User/Person headers', async () => {
|
||||||
|
const app = buildApp(buildDeps());
|
||||||
|
const res = await hit(app, {});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ authenticated: 'tailnet', personId: PERSON, deviceId: DEVICE });
|
||||||
|
expect(res.headers['x-forwarded-user']).toBe(PERSON);
|
||||||
|
expect(res.headers['x-forwarded-person']).toBe(PERSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST works identically (Caddy may use either verb)', async () => {
|
||||||
|
const app = buildApp(buildDeps());
|
||||||
|
const res = await hit(app, { method: 'post' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.authenticated).toBe('tailnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sensitive without session → 401 step-up-required + loginUrl', async () => {
|
||||||
|
const app = buildApp(buildDeps());
|
||||||
|
const res = await hit(app, { host: 'vault.sami' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: 'step-up-required', loginUrl: '/auth/login' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sensitive with valid session → 200 step-up', async () => {
|
||||||
|
const app = buildApp(buildDeps({ sessionValidator: (req) => !!(req && req.headers) }));
|
||||||
|
const res = await hit(app, { host: 'vault.sami' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ authenticated: 'step-up', personId: PERSON });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Host header used when X-Forwarded-Host absent', async () => {
|
||||||
|
const app = buildApp(buildDeps());
|
||||||
|
const res = await request(app).get('/network/forward-auth/')
|
||||||
|
.set('X-Forwarded-For', '10.53.0.7')
|
||||||
|
.set('Host', 'plex.sami:8080'); // express maps Host → req.headers.host
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.authenticated).toBe('tailnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to req.ip when X-Forwarded-For absent', async () => {
|
||||||
|
const app = buildApp(buildDeps());
|
||||||
|
const res = await request(app).get('/network/forward-auth/')
|
||||||
|
.set('X-Forwarded-Host', 'plex.sami');
|
||||||
|
// supertest connects from 127.0.0.1 — an unknown device → 401, proving
|
||||||
|
// the fallback consulted req.ip rather than crashing on a missing header.
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: 'unknown-device' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no provider active → 503 { error: "network provider not active" } (lazy mount semantics)', async () => {
|
||||||
|
// Mirror of the app.js p7ForwardAuthLazy stub: provider absent → 503
|
||||||
|
// before the router is ever built. Verified here by mounting the same
|
||||||
|
// lazy wrapper shape the app uses.
|
||||||
|
const app = express();
|
||||||
|
const lazy = (req, res, next) => {
|
||||||
|
const router = null; // registry returns no router when no provider active
|
||||||
|
if (!router) {
|
||||||
|
const prov = null; // registry returns null when no provider active
|
||||||
|
if (!prov) return res.status(503).json({ error: 'network provider not active' });
|
||||||
|
}
|
||||||
|
return router(req, res, next);
|
||||||
|
};
|
||||||
|
app.use('/network/forward-auth', lazy);
|
||||||
|
const res = await request(app).get('/network/forward-auth/')
|
||||||
|
.set('X-Forwarded-For', '10.53.0.7');
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body).toEqual({ error: 'network provider not active' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── X-Forwarded-For parsing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DC-154 parseForwardedFor', () => {
|
||||||
|
test('single entry', () => {
|
||||||
|
expect(parseForwardedFor('203.0.113.7')).toEqual(['203.0.113.7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chained entries (client first) preserve order', () => {
|
||||||
|
expect(parseForwardedFor('203.0.113.7, 10.53.0.1, 10.53.0.2'))
|
||||||
|
.toEqual(['203.0.113.7', '10.53.0.1', '10.53.0.2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing header → []', () => {
|
||||||
|
expect(parseForwardedFor(undefined)).toEqual([]);
|
||||||
|
expect(parseForwardedFor('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('port-bearing and ipv6 entries are normalized', () => {
|
||||||
|
expect(parseForwardedFor('203.0.113.7:54321')).toEqual(['203.0.113.7']);
|
||||||
|
expect(parseForwardedFor('[fd00::1]:443')).toEqual(['fd00::1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route uses the FIRST chained entry as the client IP', async () => {
|
||||||
|
const deps = buildDeps();
|
||||||
|
const app = buildApp(deps);
|
||||||
|
// 10.53.0.7 = PERSON; middle hop 10.53.0.1 is unknown — if the route
|
||||||
|
// picked the wrong hop the whois would 401 instead of allowing.
|
||||||
|
const res = await request(app).get('/network/forward-auth/')
|
||||||
|
.set('X-Forwarded-For', '10.53.0.7, 10.53.0.1')
|
||||||
|
.set('X-Forwarded-Host', 'plex.sami');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.personId).toBe(PERSON);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -808,6 +808,57 @@ async function createApp() {
|
|||||||
return p7WgRedeemRouter(req, res, next);
|
return p7WgRedeemRouter(req, res, next);
|
||||||
};
|
};
|
||||||
apiRouter.use('/network/wg', p7WgRedeemLazy);
|
apiRouter.use('/network/wg', p7WgRedeemLazy);
|
||||||
|
|
||||||
|
// forward_auth enforcement (DC-154, spec §4 Layer 2). Same lazy-provider
|
||||||
|
// pattern as p7WgRedeemLazy: the check service needs an active provider
|
||||||
|
// (any id — whois is part of the shared provider contract); without one,
|
||||||
|
// 503 { error: 'network provider not active' }. NO auth middleware in
|
||||||
|
// front — this route IS the auth check, called by Caddy forward_auth.
|
||||||
|
// Caddy passes the client IP via X-Forwarded-For; the route honors it.
|
||||||
|
const networkForwardAuthRoutes = require('../routes/network-forward-auth'); // eslint-disable-line global-require
|
||||||
|
const { createForwardAuthService } = require('./network/forward-auth'); // eslint-disable-line global-require
|
||||||
|
let p7ForwardAuthRouter = null;
|
||||||
|
const p7ForwardAuthLazy = (req, res, next) => {
|
||||||
|
if (!p7ForwardAuthRouter) {
|
||||||
|
const prov = getActiveProvider();
|
||||||
|
if (!prov) {
|
||||||
|
return res.status(503).json({ error: 'network provider not active' });
|
||||||
|
}
|
||||||
|
const service = createForwardAuthService({
|
||||||
|
provider: prov,
|
||||||
|
accessStore: p7AccessStore,
|
||||||
|
resourcesStore: p7ResourcesStore,
|
||||||
|
peopleStore: p7PeopleStore,
|
||||||
|
// Reuse the cookie-only session validation from middleware.js (the
|
||||||
|
// same isSessionValid the TOTP gate uses) — never a reimplementation.
|
||||||
|
sessionValidator: (r) => Promise.resolve(middlewareResult.isSessionValid(r)),
|
||||||
|
logger: ctx.log,
|
||||||
|
auditLog: {
|
||||||
|
logForwardAuth: (entry) => ctx.auditLog
|
||||||
|
? ctx.auditLog.log({
|
||||||
|
action: 'network.' + entry.event,
|
||||||
|
resource: entry.resource || entry.host || '',
|
||||||
|
details: {
|
||||||
|
event: entry.event,
|
||||||
|
personId: entry.personId,
|
||||||
|
resource: entry.resource,
|
||||||
|
host: entry.host,
|
||||||
|
outcome: entry.outcome,
|
||||||
|
},
|
||||||
|
outcome: entry.outcome || 'unknown',
|
||||||
|
})
|
||||||
|
: Promise.resolve(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
p7ForwardAuthRouter = networkForwardAuthRoutes({
|
||||||
|
service,
|
||||||
|
asyncHandler: ctx.asyncHandler,
|
||||||
|
log: ctx.log,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return p7ForwardAuthRouter(req, res, next);
|
||||||
|
};
|
||||||
|
apiRouter.use('/network/forward-auth', p7ForwardAuthLazy);
|
||||||
// ── end P7 mounts ───────────────────────────────────────────────────────
|
// ── end P7 mounts ───────────────────────────────────────────────────────
|
||||||
apiRouter.use(sitesRoutes({
|
apiRouter.use(sitesRoutes({
|
||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* forward_auth enforcement service — DC-154 (spec §4 Layer 2).
|
||||||
|
*
|
||||||
|
* Caddy's `forward_auth` directive points every protected request at this
|
||||||
|
* module before it reaches the upstream app. The check is fully stateless:
|
||||||
|
* the connecting IP's identity IS the credential.
|
||||||
|
*
|
||||||
|
* Flow (spec §4 "Layer 2: app (forward_auth)"):
|
||||||
|
* 1. provider.whois(sourceIp) → { personId, deviceId }. Identity follows
|
||||||
|
* the DEVICE (shared-device semantics): whoever owns the connecting
|
||||||
|
* device gets that person's access. Unknown device → 401
|
||||||
|
* { error: 'unknown-device' } + WWW-Authenticate header.
|
||||||
|
* 2. Host → resource: match the leading subdomain of the request host
|
||||||
|
* against resource names via resourcesStore. Unmatched → 404
|
||||||
|
* { error: 'unknown-resource' }.
|
||||||
|
* 3. accessStore.effectiveAccess({ type:'person', id, groups }) →
|
||||||
|
* person overrides beat group union. Denied → 403
|
||||||
|
* { error:'forbidden', resource, personId }.
|
||||||
|
* 4. Allowed + resource.sensitive → step-up: a valid DashCaddy session
|
||||||
|
* (TOTP login) via sessionValidator. Valid → 200
|
||||||
|
* { authenticated:'step-up', personId }; missing/invalid → 401
|
||||||
|
* { error:'step-up-required', loginUrl:'/auth/login' }.
|
||||||
|
* 5. Allowed + non-sensitive → 200 { authenticated:'tailnet', personId,
|
||||||
|
* deviceId } — tailnet users get in with no separate login.
|
||||||
|
*
|
||||||
|
* Allowed responses carry X-Forwarded-User + X-Forwarded-Person — the
|
||||||
|
* headers Caddy's forward_auth copies to the upstream request.
|
||||||
|
*
|
||||||
|
* Every outcome is audited:
|
||||||
|
* { event:'forward_auth_allow'|'forward_auth_deny'|'forward_auth_step_up',
|
||||||
|
* personId, resource, host }
|
||||||
|
* 'forward_auth_step_up' is used for BOTH the 200 step-up success and the
|
||||||
|
* 401 step-up-required challenge (outcome field distinguishes them).
|
||||||
|
*
|
||||||
|
* Group membership for effectiveAccess comes from the people store —
|
||||||
|
* supplied by the caller as peopleStore so this module stays store-agnostic
|
||||||
|
* (the route factory wires the real one; tests inject a stub).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const DEFAULT_LOGIN_URL = '/auth/login';
|
||||||
|
|
||||||
|
/** Audit one outcome. Failures are swallowed — audit must never break authz. */
|
||||||
|
function audit(auditLog, entry) {
|
||||||
|
if (!auditLog || typeof auditLog.logForwardAuth !== 'function') return;
|
||||||
|
try {
|
||||||
|
const maybePromise = auditLog.logForwardAuth(entry);
|
||||||
|
if (maybePromise && typeof maybePromise.catch === 'function') {
|
||||||
|
maybePromise.catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (_) { /* non-fatal by contract */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the forward_auth check service.
|
||||||
|
*
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {object} deps.provider - NetworkProvider with whois(ip)
|
||||||
|
* @param {object} deps.accessStore - network-access-store (effectiveAccess)
|
||||||
|
* @param {object} deps.resourcesStore - network-resources (listResources)
|
||||||
|
* @param {object} [deps.peopleStore] - network-people-store (getPerson) —
|
||||||
|
* supplies the person's groups for effectiveAccess. Optional; without it
|
||||||
|
* group grants are simply not consulted (person overrides still apply).
|
||||||
|
* @param {Function} [deps.sessionValidator] - async (req) => boolean for the
|
||||||
|
* sensitive step-up (prod wiring reuses middleware's isSessionValid).
|
||||||
|
* Default: always false (no session can be valid).
|
||||||
|
* @param {object} [deps.logger] - { info, warn, error } no-op default.
|
||||||
|
* @param {object} [deps.auditLog] - audit sink; when present must expose
|
||||||
|
* logForwardAuth(entry). Default: no-op.
|
||||||
|
*/
|
||||||
|
function createForwardAuthService({
|
||||||
|
provider,
|
||||||
|
accessStore,
|
||||||
|
resourcesStore,
|
||||||
|
peopleStore = null,
|
||||||
|
sessionValidator = () => Promise.resolve(false),
|
||||||
|
logger = { info() {}, warn() {}, error() {} },
|
||||||
|
auditLog = null,
|
||||||
|
} = {}) {
|
||||||
|
if (!provider || typeof provider.whois !== 'function') {
|
||||||
|
throw new Error('forward-auth service requires a provider with whois()');
|
||||||
|
}
|
||||||
|
if (!accessStore || typeof accessStore.effectiveAccess !== 'function') {
|
||||||
|
throw new Error('forward-auth service requires an accessStore with effectiveAccess()');
|
||||||
|
}
|
||||||
|
if (!resourcesStore || typeof resourcesStore.listResources !== 'function') {
|
||||||
|
throw new Error('forward-auth service requires a resourcesStore with listResources()');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a request host to a resource by leading subdomain.
|
||||||
|
* `plex.internal` (resource name) ← host `plex.internal.sami` or
|
||||||
|
* `plex.sami` / bare `plex` — the first label(s) up to the private-zone
|
||||||
|
* suffix. Implemented as: longest resource name whose labels are a
|
||||||
|
* prefix of the host's labels. */
|
||||||
|
async function resolveResource(host) {
|
||||||
|
const resources = await resourcesStore.listResources();
|
||||||
|
if (!Array.isArray(resources) || resources.length === 0) return null;
|
||||||
|
const labels = String(host || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^[.]+/, '')
|
||||||
|
.replace(/[.]+$/, '')
|
||||||
|
.split('.');
|
||||||
|
if (!labels[0]) return null;
|
||||||
|
let best = null;
|
||||||
|
for (const r of resources) {
|
||||||
|
if (!r || typeof r.name !== 'string' || !r.name) continue;
|
||||||
|
const nameLabels = r.name.toLowerCase().split('.');
|
||||||
|
// resource name labels must be a leading prefix of the host labels
|
||||||
|
if (nameLabels.length > labels.length) continue;
|
||||||
|
let match = true;
|
||||||
|
for (let i = 0; i < nameLabels.length; i += 1) {
|
||||||
|
if (nameLabels[i] !== labels[i]) { match = false; break; }
|
||||||
|
}
|
||||||
|
if (match && (!best || r.name.length > best.name.length)) best = r;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** whois + normalize; any failure/unknown resolves to null identity. */
|
||||||
|
async function whoisIdentity(sourceIp) {
|
||||||
|
let who = null;
|
||||||
|
try {
|
||||||
|
who = await provider.whois(sourceIp);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn && logger.warn('network:forward-auth', 'whois threw', {
|
||||||
|
code: (err && err.code) || 'unknown',
|
||||||
|
});
|
||||||
|
who = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
personId: who && typeof who.personId === 'string' && who.personId ? who.personId : null,
|
||||||
|
deviceId: who && typeof who.deviceId === 'string' && who.deviceId ? who.deviceId : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sensitive-resource step-up (spec §4): sessionValidator decides. */
|
||||||
|
async function stepUpDecision(req, personId, resource, hostName) {
|
||||||
|
let sessionValid = false;
|
||||||
|
try {
|
||||||
|
sessionValid = !!(await sessionValidator(req));
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn && logger.warn('network:forward-auth', 'sessionValidator threw', {
|
||||||
|
err: err && err.message,
|
||||||
|
});
|
||||||
|
sessionValid = false;
|
||||||
|
}
|
||||||
|
if (sessionValid) {
|
||||||
|
audit(auditLog, {
|
||||||
|
event: 'forward_auth_step_up',
|
||||||
|
personId,
|
||||||
|
resource: resource.name,
|
||||||
|
host: hostName,
|
||||||
|
outcome: 'granted',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'X-Forwarded-User': personId,
|
||||||
|
'X-Forwarded-Person': personId,
|
||||||
|
},
|
||||||
|
body: { authenticated: 'step-up', personId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
audit(auditLog, {
|
||||||
|
event: 'forward_auth_step_up',
|
||||||
|
personId,
|
||||||
|
resource: resource.name,
|
||||||
|
host: hostName,
|
||||||
|
outcome: 'required',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
headers: {},
|
||||||
|
body: { error: 'step-up-required', loginUrl: DEFAULT_LOGIN_URL },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The check. Returns a plain decision object; the route layer maps it to
|
||||||
|
* HTTP (status + headers + body). Kept transport-free for unit tests.
|
||||||
|
*
|
||||||
|
* @param {object} input { sourceIp, host, requestPath, req? }
|
||||||
|
* `req` (optional, raw Express request) is forwarded to
|
||||||
|
* sessionValidator so it can read cookies off the real request.
|
||||||
|
*/
|
||||||
|
async function check({ sourceIp, host, requestPath: _requestPath, req = null }) {
|
||||||
|
// 1. Identity follows the device: whois the connecting IP.
|
||||||
|
const identity = await whoisIdentity(sourceIp);
|
||||||
|
if (!identity.personId) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'WWW-Authenticate': 'ForwardAuth realm="dashcaddy-private-network"' },
|
||||||
|
body: { error: 'unknown-device' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { personId, deviceId } = identity;
|
||||||
|
|
||||||
|
// 2. Host → resource (leading-subdomain match).
|
||||||
|
const hostName = String(host || '').split(':')[0].trim();
|
||||||
|
const resource = await resolveResource(hostName);
|
||||||
|
if (!resource) {
|
||||||
|
audit(auditLog, {
|
||||||
|
event: 'forward_auth_deny',
|
||||||
|
personId,
|
||||||
|
resource: null,
|
||||||
|
host: hostName,
|
||||||
|
outcome: 'unknown-resource',
|
||||||
|
});
|
||||||
|
return { status: 404, headers: {}, body: { error: 'unknown-resource' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Access map: person overrides over their groups' union.
|
||||||
|
let groups = [];
|
||||||
|
if (peopleStore && typeof peopleStore.getPerson === 'function') {
|
||||||
|
const person = await peopleStore.getPerson(personId);
|
||||||
|
if (person && Array.isArray(person.groups)) groups = person.groups;
|
||||||
|
}
|
||||||
|
const eff = await accessStore.effectiveAccess({ type: 'person', id: personId, groups });
|
||||||
|
const allowed = !!(eff && eff.ok && eff.resources && eff.resources[resource.name] === true);
|
||||||
|
if (!allowed) {
|
||||||
|
audit(auditLog, {
|
||||||
|
event: 'forward_auth_deny',
|
||||||
|
personId,
|
||||||
|
resource: resource.name,
|
||||||
|
host: hostName,
|
||||||
|
outcome: 'forbidden',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
headers: {},
|
||||||
|
body: { error: 'forbidden', resource: resource.name, personId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sensitive resources: DashCaddy login + TOTP step-up (spec §4).
|
||||||
|
if (resource.sensitive === true) {
|
||||||
|
return stepUpDecision(req, personId, resource, hostName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Non-sensitive allowed: tailnet identity, no separate login.
|
||||||
|
audit(auditLog, {
|
||||||
|
event: 'forward_auth_allow',
|
||||||
|
personId,
|
||||||
|
resource: resource.name,
|
||||||
|
host: hostName,
|
||||||
|
outcome: 'granted',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'X-Forwarded-User': personId,
|
||||||
|
'X-Forwarded-Person': personId,
|
||||||
|
},
|
||||||
|
body: { authenticated: 'tailnet', personId, deviceId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { check, resolveResource };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createForwardAuthService, DEFAULT_LOGIN_URL };
|
||||||
@@ -195,7 +195,10 @@ function csrfValidationMiddleware(req, res, next) {
|
|||||||
return new RegExp(pattern).test(req.path);
|
return new RegExp(pattern).test(req.path);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}) || req.path.startsWith('/api/v1/auth/gate/');
|
}) || req.path.startsWith('/api/v1/auth/gate/')
|
||||||
|
// DC-154: Caddy forward_auth fires on every protected request including
|
||||||
|
// POSTs — it presents a device identity (whois), never a CSRF token.
|
||||||
|
|| req.path.startsWith('/api/v1/network/forward-auth');
|
||||||
|
|
||||||
if (isExcluded) {
|
if (isExcluded) {
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -397,6 +397,12 @@ module.exports = function configureMiddleware(app, {
|
|||||||
{ path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' },
|
{ path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' },
|
||||||
{ path: '/api/v1/totp/check-session', exact: true },
|
{ path: '/api/v1/totp/check-session', exact: true },
|
||||||
{ path: '/api/v1/auth/gate/', prefix: true },
|
{ path: '/api/v1/auth/gate/', prefix: true },
|
||||||
|
// DC-154: network forward_auth target — called by Caddy on EVERY
|
||||||
|
// protected request, stateless, no cookie/CSRF token to present.
|
||||||
|
// The route itself is the auth decision; mounting it behind the TOTP
|
||||||
|
// gate would deadlock every protected site. Prefix (trailing slash)
|
||||||
|
// covers GET + POST on the canonical /network/forward-auth/ URI.
|
||||||
|
{ path: '/api/v1/network/forward-auth/', prefix: true },
|
||||||
{ path: '/api/v1/auth/app-token/', prefix: true },
|
{ path: '/api/v1/auth/app-token/', prefix: true },
|
||||||
{ path: '/api/v1/auth/login-page', exact: true, method: 'GET' },
|
{ path: '/api/v1/auth/login-page', exact: true, method: 'GET' },
|
||||||
// Must be public: a fresh cross-subdomain visitor has no session yet by
|
// Must be public: a fresh cross-subdomain visitor has no session yet by
|
||||||
|
|||||||
Reference in new issue
Block a user