From 0fc13b705f531e7bd96c9de258dd4e72a61b95bf Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 11 Jun 2026 02:03:08 +0200 Subject: [PATCH 1/5] feat(tunnel): opt-in per-tunnel credential passthrough (#530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tunnel proxy strips Cookie / Authorization inbound and Set-Cookie outbound by design, so a tunnel exposes PUBLIC content only — no bearer/DPoP access, and no cookie-session flows (interactive OIDC login can never complete) through a tunnelled pod. This adds the opt-in: `{ type: 'register', name, passthrough: true }`. When the registering client opts in, the proxy forwards Cookie and Authorization to the tunnel client and Set-Cookie back to the visitor. Off by default; the ack echoes the applied mode so the client knows what the relay actually did. Security shape (documented in the protocol header): - Opt-in is per-tunnel and owner-asserted — forwarding means visitor credentials bound for the tunnelled service are handed to the registered client, which is safe exactly when the registrant owns that service (the normal my-pod-through-my-relay case). - Strict `=== true` so a truthy-but-wrong value ('true', 1) can't silently enable credential forwarding. - Proxy-Authorization is ALWAYS stripped — it is addressed to the relay, never to the tunnelled service. - Set-Cookie array values survive JSON serialization and Fastify emits one header per entry. Known limitation, per the issue: passthrough alone doesn't make path-based login flows fully work (/tunnel// cookie-Path and redirect rewriting — the reverse-proxy-at-a-subpath problem). It delivers authenticated API access today and is the prerequisite for the subdomain-routing endgame. Tests (4 new, including pinning the previously-untested default): default strips both ways, passthrough forwards all three headers (multi-value Set-Cookie included), Proxy-Authorization never forwarded, non-boolean opt-in rejected. 952/952 passing. Closes #530. --- src/tunnel/index.js | 50 ++++++++++++++++--- test/tunnel.test.js | 117 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 8 deletions(-) diff --git a/src/tunnel/index.js b/src/tunnel/index.js index 73392c25..bd168542 100644 --- a/src/tunnel/index.js +++ b/src/tunnel/index.js @@ -10,11 +10,24 @@ * Public URL: https://your.pod/tunnel/{name}/path * * Tunnel client protocol (JSON over WebSocket): - * → { type: "register", name: "myapp" } - * ← { type: "registered", name: "myapp", url: "/tunnel/myapp/" } + * → { type: "register", name: "myapp", passthrough?: true } + * ← { type: "registered", name: "myapp", url: "/tunnel/myapp/", passthrough: false } * ← { type: "request", id: "", method: "GET", path: "/api/hello", headers: {...}, body: "..." } * → { type: "response", id: "", status: 200, headers: {...}, body: "..." } * ← { type: "error", message: "..." } + * + * Credential passthrough (#530): by default the proxy strips + * `Cookie` / `Authorization` from inbound requests and `Set-Cookie` + * from outbound responses, so a tunnel exposes PUBLIC content only. + * A client may opt in per-registration with `passthrough: true`, + * which forwards those three headers and makes authenticated access + * (bearer/DPoP, cookie sessions) work through the tunnel. Opting in + * means visitor credentials bound for the tunnelled service are + * handed to the registered client — safe exactly when the registrant + * owns the tunnelled service (the normal "my own pod through my own + * relay" case), which is why it is per-tunnel, owner-asserted, and + * off by default. `Proxy-Authorization` is always stripped — it is + * addressed to this relay, never to the tunnelled service. */ import websocket from '@fastify/websocket'; @@ -109,9 +122,15 @@ export async function tunnelPlugin(fastify, options = {}) { existing.socket.close(); } + // Per-tunnel credential passthrough (#530) — strict boolean so a + // truthy-but-wrong value ("false", 1) can't silently enable + // credential forwarding. Echoed in the ack so the client knows + // which mode the relay actually applied. + const passthrough = msg.passthrough === true; + tunnelName = name; - tunnels.set(name, { socket, webId }); - socket.send(JSON.stringify({ type: 'registered', name, url: `/tunnel/${name}/` })); + tunnels.set(name, { socket, webId, passthrough }); + socket.send(JSON.stringify({ type: 'registered', name, url: `/tunnel/${name}/`, passthrough })); } else if (msg.type === 'response') { // Tunnel client returning an HTTP response @@ -167,8 +186,16 @@ export async function tunnelPlugin(fastify, options = {}) { tunnelReq.method = request.method; tunnelReq.path = fullPath; tunnelReq.headers = Object.create(null); - // Forward relevant headers (skip hop-by-hop) - const skipHeaders = new Set(['host', 'connection', 'upgrade', 'transfer-encoding', 'cookie', 'authorization', 'proxy-authorization']); + // Forward relevant headers. Hop-by-hop headers are always skipped; + // credentials (cookie / authorization) are skipped UNLESS the tunnel + // registered with passthrough (#530 — owner opted in to receive + // visitor credentials). Proxy-Authorization is always stripped: it + // is addressed to this relay, never to the tunnelled service. + const skipHeaders = new Set(['host', 'connection', 'upgrade', 'transfer-encoding', 'proxy-authorization']); + if (!tunnel.passthrough) { + skipHeaders.add('cookie'); + skipHeaders.add('authorization'); + } for (const [k, v] of Object.entries(request.headers)) { if (!skipHeaders.has(k.toLowerCase())) { tunnelReq.headers[k] = v; @@ -201,8 +228,15 @@ export async function tunnelPlugin(fastify, options = {}) { const res = await responsePromise; - // Set response headers - const hopHeaders = new Set(['connection', 'transfer-encoding', 'keep-alive', 'set-cookie']); + // Set response headers. Set-Cookie is stripped by default so a + // tunnelled service can't set cookies on the relay's origin; with + // passthrough (#530) the owner opted in and session flows (e.g. + // OIDC login cookies) must survive the proxy. Fastify accepts an + // array value for set-cookie, which JSON serialization preserves. + const hopHeaders = new Set(['connection', 'transfer-encoding', 'keep-alive']); + if (!tunnel.passthrough) { + hopHeaders.add('set-cookie'); + } for (const [k, v] of Object.entries(res.headers)) { if (!hopHeaders.has(k.toLowerCase())) { reply.header(k, v); diff --git a/test/tunnel.test.js b/test/tunnel.test.js index ec64dae8..f2ecaf6d 100644 --- a/test/tunnel.test.js +++ b/test/tunnel.test.js @@ -199,4 +199,121 @@ describe('Tunnel Proxy', () => { await new Promise(r => setTimeout(r, 50)); }); }); + + describe('Credential passthrough (#530)', () => { + // Register a tunnel, echo each request's received headers back in + // the response body, and attach the given response headers — lets + // tests assert both directions of the credential flow. + function echoTunnel(ws, name, { passthrough, responseHeaders = {} } = {}) { + ws.send(JSON.stringify({ type: 'register', name, ...(passthrough !== undefined && { passthrough }) })); + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'request') { + ws.send(JSON.stringify({ + type: 'response', + id: msg.id, + status: 200, + headers: { 'content-type': 'application/json', ...responseHeaders }, + body: JSON.stringify({ receivedHeaders: msg.headers }) + })); + } + }); + return waitMsg(ws, 'registered'); + } + + it('default registration strips credentials both ways (the security default)', async () => { + const ws = connectTunnel(); + await new Promise(r => ws.on('open', r)); + + const ack = await echoTunnel(ws, 'noauth', { + responseHeaders: { 'set-cookie': 'leak=1; Path=/' } + }); + assert.strictEqual(ack.passthrough, false, + 'ack must state passthrough is off'); + + const res = await fetch(`${baseUrl}/tunnel/noauth/`, { + headers: { Cookie: 'session=secret', Authorization: 'Bearer visitor-token' } + }); + assert.strictEqual(res.status, 200); + const { receivedHeaders } = await res.json(); + assert.strictEqual(receivedHeaders.cookie, undefined, + 'cookie must NOT reach the tunnel client by default'); + assert.strictEqual(receivedHeaders.authorization, undefined, + 'authorization must NOT reach the tunnel client by default'); + assert.strictEqual(res.headers.get('set-cookie'), null, + 'set-cookie from the tunnel client must NOT reach the visitor by default'); + + ws.close(); + await new Promise(r => setTimeout(r, 50)); + }); + + it('passthrough registration forwards Cookie/Authorization in and Set-Cookie out', async () => { + const ws = connectTunnel(); + await new Promise(r => ws.on('open', r)); + + const ack = await echoTunnel(ws, 'authapp', { + passthrough: true, + // Array value: JSON serialization preserves it and Fastify + // emits one Set-Cookie header per entry. + responseHeaders: { 'set-cookie': ['sess=abc; Path=/', 'csrf=xyz; Path=/'] } + }); + assert.strictEqual(ack.passthrough, true, 'ack must confirm passthrough'); + + const res = await fetch(`${baseUrl}/tunnel/authapp/`, { + headers: { Cookie: 'session=secret', Authorization: 'Bearer visitor-token' } + }); + assert.strictEqual(res.status, 200); + const { receivedHeaders } = await res.json(); + assert.strictEqual(receivedHeaders.cookie, 'session=secret', + 'cookie must reach the tunnel client with passthrough'); + assert.strictEqual(receivedHeaders.authorization, 'Bearer visitor-token', + 'authorization must reach the tunnel client with passthrough'); + // headers.get('set-cookie') joins multiple values — assert both + // cookies survived without relying on getSetCookie() (Node 18.15+). + const setCookie = res.headers.get('set-cookie') || ''; + assert.ok(setCookie.includes('sess=abc'), `sess cookie must survive; got: ${setCookie}`); + assert.ok(setCookie.includes('csrf=xyz'), `csrf cookie must survive; got: ${setCookie}`); + + ws.close(); + await new Promise(r => setTimeout(r, 50)); + }); + + it('passthrough never forwards Proxy-Authorization (relay-directed credential)', async () => { + const ws = connectTunnel(); + await new Promise(r => ws.on('open', r)); + + await echoTunnel(ws, 'proxyauth', { passthrough: true }); + + const res = await fetch(`${baseUrl}/tunnel/proxyauth/`, { + headers: { 'Proxy-Authorization': 'Basic cmVsYXk6c2VjcmV0' } + }); + assert.strictEqual(res.status, 200); + const { receivedHeaders } = await res.json(); + assert.strictEqual(receivedHeaders['proxy-authorization'], undefined, + 'proxy-authorization is addressed to the relay and must never be forwarded'); + + ws.close(); + await new Promise(r => setTimeout(r, 50)); + }); + + it('non-boolean passthrough values do not enable forwarding (strict === true)', async () => { + const ws = connectTunnel(); + await new Promise(r => ws.on('open', r)); + + // "true" (string) must not opt a tunnel into credential forwarding. + const ack = await echoTunnel(ws, 'stringy', { passthrough: 'true' }); + assert.strictEqual(ack.passthrough, false, + 'string "true" must not enable passthrough'); + + const res = await fetch(`${baseUrl}/tunnel/stringy/`, { + headers: { Cookie: 'session=secret' } + }); + const { receivedHeaders } = await res.json(); + assert.strictEqual(receivedHeaders.cookie, undefined, + 'credentials stay stripped for non-boolean opt-in values'); + + ws.close(); + await new Promise(r => setTimeout(r, 50)); + }); + }); }); From dc3a28c5a94037c9550213df0745a6f7d81fc2e4 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 11 Jun 2026 02:12:15 +0200 Subject: [PATCH 2/5] review fix (#555): read multi-value Set-Cookie via getSetCookie with feature-detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot: get('set-cookie') behaviour varies by undici version and may expose only one value; Headers#getSetCookie() (Node ≥18.15) is the supported multi-value API. Feature-detect: assert the full cookie pair on the real API, degrade to a single-cookie assertion on older runtimes (engines floor bump tracked in #541). Non-flaky on every Node the engines field admits. --- test/tunnel.test.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/tunnel.test.js b/test/tunnel.test.js index f2ecaf6d..47f1f1e3 100644 --- a/test/tunnel.test.js +++ b/test/tunnel.test.js @@ -268,11 +268,20 @@ describe('Tunnel Proxy', () => { 'cookie must reach the tunnel client with passthrough'); assert.strictEqual(receivedHeaders.authorization, 'Bearer visitor-token', 'authorization must reach the tunnel client with passthrough'); - // headers.get('set-cookie') joins multiple values — assert both - // cookies survived without relying on getSetCookie() (Node 18.15+). - const setCookie = res.headers.get('set-cookie') || ''; + // Headers#getSetCookie() (Node ≥18.15) is the supported way to + // read multiple Set-Cookie values; get('set-cookie') behaviour + // varies by undici version and may expose only one value. Feature- + // detect: assert the full pair on the real API, degrade to a + // single-cookie assertion on older runtimes (engines floor bump + // tracked in #541). + const hasGetSetCookie = typeof res.headers.getSetCookie === 'function'; + const setCookie = hasGetSetCookie + ? res.headers.getSetCookie().join('; ') + : (res.headers.get('set-cookie') || ''); assert.ok(setCookie.includes('sess=abc'), `sess cookie must survive; got: ${setCookie}`); - assert.ok(setCookie.includes('csrf=xyz'), `csrf cookie must survive; got: ${setCookie}`); + if (hasGetSetCookie) { + assert.ok(setCookie.includes('csrf=xyz'), `csrf cookie must survive; got: ${setCookie}`); + } ws.close(); await new Promise(r => setTimeout(r, 50)); From 9824bc2cd82713575645b29521d28eb933ed57f2 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 11 Jun 2026 02:17:25 +0200 Subject: [PATCH 3/5] review pass-2 fix (#555): strip-mode assertion uses getSetCookie when available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot: on some undici versions get('set-cookie') returns null even when Set-Cookie headers ARE present (only readable via getSetCookie), making the default-strip assertion vacuously pass — a false positive that could mask a regression in the security default. Feature-detect: assert getSetCookie() returns [] when the API exists, fall back to the null check otherwise. Mirror of the pass-1 fix, applied to the strip side. --- test/tunnel.test.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/tunnel.test.js b/test/tunnel.test.js index 47f1f1e3..039648cf 100644 --- a/test/tunnel.test.js +++ b/test/tunnel.test.js @@ -240,8 +240,17 @@ describe('Tunnel Proxy', () => { 'cookie must NOT reach the tunnel client by default'); assert.strictEqual(receivedHeaders.authorization, undefined, 'authorization must NOT reach the tunnel client by default'); - assert.strictEqual(res.headers.get('set-cookie'), null, - 'set-cookie from the tunnel client must NOT reach the visitor by default'); + // Feature-detect here too: on some undici versions get('set-cookie') + // is null even when Set-Cookie headers ARE present (only readable + // via getSetCookie), which would make a null assertion vacuous and + // could mask a regression in the default strip mode. + if (typeof res.headers.getSetCookie === 'function') { + assert.deepStrictEqual(res.headers.getSetCookie(), [], + 'set-cookie from the tunnel client must NOT reach the visitor by default'); + } else { + assert.strictEqual(res.headers.get('set-cookie'), null, + 'set-cookie from the tunnel client must NOT reach the visitor by default'); + } ws.close(); await new Promise(r => setTimeout(r, 50)); From 7dfa2364c6ad3f247fb346531d53ce92e06a6cac Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 11 Jun 2026 02:30:29 +0200 Subject: [PATCH 4/5] review pass-3 fix (#555): strip the relay's own IdP cookies in passthrough mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot found a real session-hijack vector. oidc-provider sets the relay's _session / _interaction cookies with path=/, so a browser attaches them to /tunnel/... requests too. With passthrough on, the proxy forwarded the FULL Cookie header — handing a visitor's RELAY session cookies to the tunnel client, which could replay them against the relay's /idp/* endpoints (session hijack / cross-app credential leak). The tunnel owner opted in to receive credentials for THEIR service, not the relay's own session. Fix: even in passthrough mode, strip the relay's own IdP cookies (_session*, _interaction* — matched at a / boundary so a tunnelled service's unrelated cookie like survives) from the forwarded Cookie header. The visitor's cookies bound for the tunnelled service pass through; the relay's do not. Defense-in-depth that holds regardless of cookie-path config or subdomain routing. Also: corrected the protocol-header ack example from to (the relay echoes the applied mode — Copilot's second comment). New test: visitor carries both an app cookie and the relay's _session/_session.sig/_interaction cookies; only the app cookie is forwarded. 12/12 tunnel, full suite green. --- src/tunnel/index.js | 53 ++++++++++++++++++++++++++++++++++++++++++--- test/tunnel.test.js | 31 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/tunnel/index.js b/src/tunnel/index.js index bd168542..cf375902 100644 --- a/src/tunnel/index.js +++ b/src/tunnel/index.js @@ -11,7 +11,7 @@ * * Tunnel client protocol (JSON over WebSocket): * → { type: "register", name: "myapp", passthrough?: true } - * ← { type: "registered", name: "myapp", url: "/tunnel/myapp/", passthrough: false } + * ← { type: "registered", name: "myapp", url: "/tunnel/myapp/", passthrough: true|false } * ← { type: "request", id: "", method: "GET", path: "/api/hello", headers: {...}, body: "..." } * → { type: "response", id: "", status: 200, headers: {...}, body: "..." } * ← { type: "error", message: "..." } @@ -28,8 +28,48 @@ * relay" case), which is why it is per-tunnel, owner-asserted, and * off by default. `Proxy-Authorization` is always stripped — it is * addressed to this relay, never to the tunnelled service. + * + * Even with passthrough on, the relay's OWN IdP session/interaction + * cookies are stripped from the forwarded Cookie header. oidc-provider + * sets them with `path: '/'`, so a browser attaches them to + * `/tunnel/...` requests too — but they authenticate the visitor to + * THE RELAY, not to the tunnelled service. Forwarding them would let a + * tunnel client capture a visitor's relay `_session` and replay it + * against the relay's `/idp/*` endpoints (session hijack). See #530. */ +// Cookie names reserved by the relay's own oidc-provider IdP +// (`_session`, `_session.sig`, `_session.legacy[.sig]`, `_interaction`, +// `_interaction.sig`, `_interaction_resume[.sig]`). They share two +// prefixes; we match the prefix plus a `.`/`_` boundary so a tunnelled +// service's unrelated cookie (e.g. `_sessionsLeft`) isn't stripped. +const RELAY_COOKIE_PREFIXES = ['_session', '_interaction']; + +function isRelayCookieName(name) { + return RELAY_COOKIE_PREFIXES.some( + (p) => name === p || name.startsWith(`${p}.`) || name.startsWith(`${p}_`), + ); +} + +/** + * Remove the relay's own IdP cookies from a forwarded Cookie header, + * keeping the visitor's cookies bound for the tunnelled service. + * Returns '' when nothing remains (caller then drops the header). + */ +function stripRelayCookies(cookieHeader) { + if (typeof cookieHeader !== 'string') return ''; + return cookieHeader + .split(';') + .map((s) => s.trim()) + .filter(Boolean) + .filter((pair) => { + const eq = pair.indexOf('='); + const name = (eq === -1 ? pair : pair.slice(0, eq)).trim(); + return !isRelayCookieName(name); + }) + .join('; '); +} + import websocket from '@fastify/websocket'; import { getWebIdFromRequestAsync } from '../auth/token.js'; import { randomUUID } from 'crypto'; @@ -197,9 +237,16 @@ export async function tunnelPlugin(fastify, options = {}) { skipHeaders.add('authorization'); } for (const [k, v] of Object.entries(request.headers)) { - if (!skipHeaders.has(k.toLowerCase())) { - tunnelReq.headers[k] = v; + const lower = k.toLowerCase(); + if (skipHeaders.has(lower)) continue; + if (lower === 'cookie' && tunnel.passthrough) { + // Forward the visitor's cookies for the tunnelled service, but + // never the relay's own IdP session cookies (#530 security). + const filtered = stripRelayCookies(v); + if (filtered) tunnelReq.headers[k] = filtered; + continue; } + tunnelReq.headers[k] = v; } // Forward body if present if (request.body) { diff --git a/test/tunnel.test.js b/test/tunnel.test.js index 039648cf..5d833db3 100644 --- a/test/tunnel.test.js +++ b/test/tunnel.test.js @@ -296,6 +296,37 @@ describe('Tunnel Proxy', () => { await new Promise(r => setTimeout(r, 50)); }); + it('passthrough strips the relay\'s own IdP cookies but keeps the service cookies (#530 session-hijack guard)', async () => { + const ws = connectTunnel(); + await new Promise(r => ws.on('open', r)); + + await echoTunnel(ws, 'mixedcookies', { passthrough: true }); + + // Visitor carries BOTH a cookie for the tunnelled app AND the + // relay's own oidc session cookies (path=/, so a real browser + // would attach them on /tunnel/... too). The relay cookies must + // never reach the tunnel client; the app cookie must. + const res = await fetch(`${baseUrl}/tunnel/mixedcookies/`, { + headers: { + // `appsid` deliberately avoids the `_session` substring so the + // assertions below can test cookie *values* without collision. + Cookie: 'appsid=keep; _session=relay-secret; _session.sig=relay-sig; _interaction=flow', + }, + }); + assert.strictEqual(res.status, 200); + const { receivedHeaders } = await res.json(); + const fwd = receivedHeaders.cookie || ''; + assert.ok(fwd.includes('appsid=keep'), + `tunnelled-service cookie must survive; got: ${fwd}`); + assert.ok(!fwd.includes('relay-secret') && !fwd.includes('relay-sig'), + `relay _session cookies must NOT be forwarded; got: ${fwd}`); + assert.ok(!fwd.includes('_interaction'), + `relay _interaction cookies must NOT be forwarded; got: ${fwd}`); + + ws.close(); + await new Promise(r => setTimeout(r, 50)); + }); + it('passthrough never forwards Proxy-Authorization (relay-directed credential)', async () => { const ws = connectTunnel(); await new Promise(r => ws.on('open', r)); From aee9646e005a7f3d3230ef724aff90b4e49795e2 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 11 Jun 2026 07:04:51 +0200 Subject: [PATCH 5/5] review pass-4 fix (#555): stripRelayCookies normalizes array Cookie headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot: header values can be string | string[] in Node/Fastify (folded duplicate Cookie headers). The helper returned '' for a non-string input, which would drop ALL cookies in passthrough mode if an array ever arrives. Normalize array → join with '; ' before filtering. Defensive (Node usually folds Cookie to a string) but cheap. --- src/tunnel/index.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tunnel/index.js b/src/tunnel/index.js index cf375902..90d12273 100644 --- a/src/tunnel/index.js +++ b/src/tunnel/index.js @@ -57,8 +57,12 @@ function isRelayCookieName(name) { * Returns '' when nothing remains (caller then drops the header). */ function stripRelayCookies(cookieHeader) { - if (typeof cookieHeader !== 'string') return ''; - return cookieHeader + // Node folds duplicate Cookie headers into one string, but Fastify + // can surface string[] — normalize so passthrough doesn't drop every + // cookie when an array arrives. Cookies join with '; '. + const raw = Array.isArray(cookieHeader) ? cookieHeader.join('; ') : cookieHeader; + if (typeof raw !== 'string') return ''; + return raw .split(';') .map((s) => s.trim()) .filter(Boolean)