From aac2343df3877d6ec6982e6f70208cb73b419649 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 22:30:23 -0700 Subject: [PATCH] DC-154: forward_auth enforcement endpoint (whois + access map + sensitive step-up) + tests --- .../__tests__/network-forward-auth.test.js | 360 ++++++++++++++++++ dashcaddy-api/routes/network-forward-auth.js | 106 ++++++ dashcaddy-api/src/app.js | 51 +++ dashcaddy-api/src/network/forward-auth.js | 262 +++++++++++++ dashcaddy-api/src/security/csrf-protection.js | 5 +- dashcaddy-api/src/utilities/middleware.js | 6 + 6 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 dashcaddy-api/__tests__/network-forward-auth.test.js create mode 100644 dashcaddy-api/routes/network-forward-auth.js create mode 100644 dashcaddy-api/src/network/forward-auth.js diff --git a/dashcaddy-api/__tests__/network-forward-auth.test.js b/dashcaddy-api/__tests__/network-forward-auth.test.js new file mode 100644 index 0000000..4261e0a --- /dev/null +++ b/dashcaddy-api/__tests__/network-forward-auth.test.js @@ -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); + }); +}); diff --git a/dashcaddy-api/routes/network-forward-auth.js b/dashcaddy-api/routes/network-forward-auth.js new file mode 100644 index 0000000..85720fc --- /dev/null +++ b/dashcaddy-api/routes/network-forward-auth.js @@ -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; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index a2bd434..745819f 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -808,6 +808,57 @@ async function createApp() { return p7WgRedeemRouter(req, res, next); }; 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 ─────────────────────────────────────────────────────── apiRouter.use(sitesRoutes({ asyncHandler: ctx.asyncHandler, diff --git a/dashcaddy-api/src/network/forward-auth.js b/dashcaddy-api/src/network/forward-auth.js new file mode 100644 index 0000000..a396805 --- /dev/null +++ b/dashcaddy-api/src/network/forward-auth.js @@ -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 }; diff --git a/dashcaddy-api/src/security/csrf-protection.js b/dashcaddy-api/src/security/csrf-protection.js index ad7581f..7b1fda2 100644 --- a/dashcaddy-api/src/security/csrf-protection.js +++ b/dashcaddy-api/src/security/csrf-protection.js @@ -195,7 +195,10 @@ function csrfValidationMiddleware(req, res, next) { return new RegExp(pattern).test(req.path); } 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) { return next(); diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index d4a778e..6f7c1ea 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -397,6 +397,12 @@ module.exports = function configureMiddleware(app, { { path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' }, { path: '/api/v1/totp/check-session', exact: 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/login-page', exact: true, method: 'GET' }, // Must be public: a fresh cross-subdomain visitor has no session yet by