diff --git a/src/tunnel/index.js b/src/tunnel/index.js index 73392c25..90d12273 100644 --- a/src/tunnel/index.js +++ b/src/tunnel/index.js @@ -10,13 +10,70 @@ * 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: true|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. + * + * 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) { + // 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) + .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'; @@ -109,9 +166,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,12 +230,27 @@ 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; + 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) { @@ -201,8 +279,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..5d833db3 100644 --- a/test/tunnel.test.js +++ b/test/tunnel.test.js @@ -199,4 +199,170 @@ 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'); + // 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)); + }); + + 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#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}`); + if (hasGetSetCookie) { + assert.ok(setCookie.includes('csrf=xyz'), `csrf cookie must survive; got: ${setCookie}`); + } + + ws.close(); + 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)); + + 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)); + }); + }); });