Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/services/caddyConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,7 @@ function buildCaddyConfig(injectedRoutes, options = {}) {
match: [{ path: ['/.well-known/acme-challenge/*'] }],
handle: [],
},
// (1) Internal sources → portal (remote_ip = real TCP source; header strip/set unchanged).
// (1) Internal sources → portal (remote_ip = real TCP source; reverse_proxy SETS the identity header).
{
match: [{ remote_ip: { ranges: INTERNAL_ONLY_RANGES } }],
handle: [
Expand All @@ -748,9 +748,12 @@ function buildCaddyConfig(injectedRoutes, options = {}) {
upstreams: [{ dial: `127.0.0.1:${config.app.port}` }],
headers: {
request: {
// Delete first: prevent any client-supplied copy from reaching Node.
delete: ['X-GC-Portal-Peer-IP'],
// Set from real TCP source — Caddy resolves this before XFF processing.
// SET ONLY — do NOT also `delete` this header here. Caddy's header
// handler applies `delete` AFTER `set`, so a `delete` + `set` on the
// SAME header nukes the value we just set, and Node never receives the
// identity header (portal then shows no per-device data). `set` alone is
// already forgery-safe: it REPLACES any client-supplied copy with the
// real TCP source, so a VPN client cannot inject a foreign peer IP.
set: { 'X-GC-Portal-Peer-IP': ['{http.request.remote.host}'] },
},
},
Expand Down
37 changes: 27 additions & 10 deletions tests/portal_dns_caddy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,35 @@ test('renderHostsContent includes a home.<domain> A-record at the gateway IP', (
'home A-record FQDN missing');
});

// ─── 2. Caddy site with reserved-header handling ─────────────────────────
test('buildCaddyConfig adds an internal home.<domain> site with strip+set of reserved header', () => {
// ─── 2. Caddy site SETS the reserved identity header (set-only, never delete+set) ──
test('portal gate route SETS X-GC-Portal-Peer-IP from the real TCP source and does NOT also delete it', () => {
const cfg = caddyConfigMod.buildCaddyConfig();
const wantHost = `home.${config.dns.domain}`;
const json = JSON.stringify(cfg);

assert.ok(json.includes(wantHost),
`home.<domain> site missing from Caddy config (looked for ${wantHost})`);
assert.ok(json.includes('X-GC-Portal-Peer-IP'),
'reserved header X-GC-Portal-Peer-IP handling missing from Caddy config');
assert.ok(json.includes('{http.request.remote.host}'),
'real-IP placeholder {http.request.remote.host} missing from Caddy config');
const serverRoutes = cfg?.apps?.http?.servers?.srv0?.routes || [];

// Drill: outer host route → subroute → the remote_ip gate route → its reverse_proxy.
const homeRoute = serverRoutes.find(r =>
Array.isArray(r.match) && r.match.some(m => Array.isArray(m.host) && m.host.includes(wantHost)));
assert.ok(homeRoute, `home.<domain> route not found (looked for ${wantHost})`);
const innerRoutes = ((homeRoute.handle || []).find(h => h.handler === 'subroute') || {}).routes || [];
const gateRoute = innerRoutes.find(r =>
Array.isArray(r.match) && r.match.some(m => m.remote_ip));
assert.ok(gateRoute, 'remote_ip gate route missing inside the portal subroute');
const rp = (gateRoute.handle || []).find(h => h.handler === 'reverse_proxy');
assert.ok(rp, 'portal gate route has no reverse_proxy handler');

const reqHdr = (rp.headers && rp.headers.request) || {};
const setVals = (reqHdr.set && reqHdr.set['X-GC-Portal-Peer-IP']) || [];
assert.ok(setVals.includes('{http.request.remote.host}'),
'gate route must SET X-GC-Portal-Peer-IP from {http.request.remote.host} (the real TCP source)');

// REGRESSION GUARD: Caddy applies `delete` AFTER `set`, so a delete + set on the SAME
// header wipes the value we just set and Node never sees the identity header (portal
// shows no per-device data). `set` alone is already forgery-safe (it replaces any
// client-supplied copy). The gate route must therefore NOT delete this header.
const delVals = reqHdr.delete || [];
assert.ok(!delVals.includes('X-GC-Portal-Peer-IP'),
'gate route must NOT delete X-GC-Portal-Peer-IP — delete-after-set nukes the value Node needs');
});

// ─── 3. Internal-only (remote_ip gate in inner subroute + 404 fallback) ──────────
Expand Down
Loading