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
105 changes: 95 additions & 10 deletions src/tunnel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<uuid>", method: "GET", path: "/api/hello", headers: {...}, body: "..." }
Comment thread
Copilot marked this conversation as resolved.
* → { type: "response", id: "<uuid>", 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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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']);
Comment on lines +233 to +238

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real find — fixed in 7dfa236. Even in passthrough mode the proxy now strips the relay's own oidc cookies (_session*, _interaction*, matched at a ./_ boundary so a tunnelled service's unrelated _sessionsLeft-style cookie isn't caught) from the forwarded Cookie header. The visitor's cookies for the tunnelled service pass through; the relay's session cookies never reach the tunnel client, so there's nothing to replay against /idp/*. Chose tunnel-layer stripping over re-pathing the IdP cookies to /idp — it's defense-in-depth that holds regardless of cookie-path config or whether subdomain routing is added later, and it doesn't touch the OIDC flow's cookie semantics. New test asserts a mixed Cookie header forwards only the app cookie. 12/12.

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) {
Expand Down Expand Up @@ -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);
Expand Down
166 changes: 166 additions & 0 deletions test/tunnel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});