feat(tunnel): opt-in per-tunnel credential passthrough — closes #530#555
Conversation
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/<name>/ 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.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in, per-tunnel “credential passthrough” mode to the tunnel relay so authenticated traffic can traverse a tunnel when explicitly requested at registration time (while preserving the existing default of stripping credentials for public-only tunneling).
Changes:
- Extend the tunnel registration protocol with
passthrough: trueand echo the applied mode back in theregisteredack. - Conditionally forward inbound
Cookie/Authorizationand outboundSet-Cookieonly when passthrough is enabled; always keepProxy-Authorizationstripped. - Add tests covering the default stripping behavior, passthrough behavior, strict boolean parsing, and the
Proxy-Authorizationinvariant.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/tunnel/index.js |
Implements per-tunnel passthrough flag and conditional header forwarding/stripping, with updated protocol doc. |
test/tunnel.test.js |
Adds coverage for default credential stripping and opt-in passthrough behavior (including strict parsing and Proxy-Authorization handling). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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}`); |
There was a problem hiding this comment.
Fixed in dc3a28c — feature-detect: when Headers#getSetCookie exists (Node ≥18.15), join its array and assert BOTH cookies; on older runtimes degrade to get('set-cookie') and assert only the first cookie, so the test is non-flaky on every Node the engines field admits (floor bump tracked in #541). Same approach as the #554 resolution, scoped to the assertion rather than a whole-test skip since the tunnel itself runs fine on older Node.
…feature-detect
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.
| assert.strictEqual(res.headers.get('set-cookie'), null, | ||
| 'set-cookie from the tunnel client must NOT reach the visitor by default'); |
There was a problem hiding this comment.
Fixed in 9824bc2 — feature-detect on the strip side too: when getSetCookie() exists, assert it returns an empty array; otherwise fall back to the get('set-cookie') === null check. Good catch that this one was sharper than pass-1's: a vacuous pass here would have weakened the security-default pin rather than just flaking. Both directions of the Set-Cookie assertions now use the supported API when present.
… available
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.
| // 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']); |
There was a problem hiding this comment.
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.
| // Close old tunnel with same name from same user | ||
| if (existing) { | ||
| existing.socket.close(); | ||
| } |
| * → { type: "register", name: "myapp", passthrough?: true } | ||
| * ← { type: "registered", name: "myapp", url: "/tunnel/myapp/", passthrough: false } |
…rough mode 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.
| 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('; '); | ||
| } |
There was a problem hiding this comment.
Fixed in aee9646 — stripRelayCookies now normalizes an array Cookie value (join with '; ') before filtering, so passthrough doesn't drop every cookie if Fastify surfaces string[]. Defensive in practice (Node folds duplicate Cookie headers into one string) but cheap and correct.
…eaders 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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/tunnel/index.js:295
- When passthrough is enabled, the relay forwards
Set-Cookieheaders from the tunnelled service to the visitor unchanged. Because the relay’s own IdP cookies are also onPath=/(and the relay already strips_session*/_interaction*from inboundCookie), allowing a tunnelled service to set those cookie names can poison/override relay IdP cookies for the visitor and potentially break or interfere with/idp/*flows. For defense-in-depth and consistency with inbound filtering, filter out relay-reserved cookie names from outboundSet-Cookiein passthrough mode (dropping the header entirely if nothing remains).
for (const [k, v] of Object.entries(res.headers)) {
if (!hopHeaders.has(k.toLowerCase())) {
reply.header(k, v);
}
}
Eight PRs merged since 0.0.206 — IdP hardening, protocol conformance, and the de-Googled-phone (#46) arc: IdP / auth - #558 passkey login degrades cleanly on stale WebViews / insecure contexts instead of crashing — drops a redundant browser crypto.randomUUID and guards both ceremonies on secure-context + WebAuthn (closes #556) - #554 IdP login-form error now survives the POST→redirect→GET cycle (rode in a non-persisted field that Interaction.save() dropped); failed sign-ins show the error instead of a silent re-render (closes #514) - #551 RFC 9207 'iss' authorization-response param normalized to match the discovery issuer, so strict OIDC clients (solid-oidc) complete sign-in (closes #524) Tunnel - #555 opt-in, per-tunnel credential passthrough (Cookie / Authorization / Set-Cookie) so authenticated access works through a tunnel; the relay's own IdP session cookies are isolated from the tunnel client (closes #530) Content negotiation / git - #553 HEAD now mirrors GET's negotiated Content-Type / Content-Length / Cache-Control for files (RFC 9110 §9.3.2 parity) (closes #552) - #550 git WAC preHandler 401/402/403 responses carry the git CORS headers, so browser git clients see the status, not a CORS error (closes #548) - #549 first HTTP-contract coverage for the git handler + fixes a DATA_ROOT test-pollution bug (closes #375) Docs / metadata - #547 README tagline + npm keywords surface the agentic positioning (closes #406)
Closes #530.
What
The tunnel proxy strips
Cookie/Authorizationinbound andSet-Cookieoutbound by design — tunnels expose public content only, and no authenticated access (bearer/DPoP or cookie sessions, including interactive OIDC login) can work through one. This adds the issue's proposed opt-in:With passthrough, the proxy forwards
Cookie+Authorizationto the tunnel client andSet-Cookieback to the visitor. Off by default; the ack echoes the mode the relay actually applied.Security shape
passthroughaccepted only at registration, by the WebID-authenticated registrant — the party receiving the credentialsmsg.passthrough === true—"true"/1do not enable forwarding (tested)Proxy-Authorizationnever forwardedThe protocol doc header (the wire spec) documents the feature and the trust assertion ("safe exactly when the registrant owns the tunnelled service — the normal my-pod-through-my-relay case").
Known limitation (from the issue, on record)
Passthrough alone doesn't make path-based login flows fully work —
Set-CookiePathattributes and redirects assume root-hosting (/tunnel/<name>/is the classic reverse-proxy-at-a-subpath problem). What ships today: authenticated bearer/DPoP API access through the tunnel, plus the prerequisite for the subdomain-routing endgame the issue sketches. Multi-valueSet-Cookiesurvives as an array through JSON → Fastify emits one header per entry (tested).Tests
4 new cases in
test/tunnel.test.js(reusing the existingconnectTunnel/waitMsgharness): default strips both directions, passthrough forwards all three headers (multi-cookie array included — asserted viaheaders.get('set-cookie'), deliberately notgetSetCookie()per the Node-18 floor discussion on #554),Proxy-Authorizationnever forwarded, non-boolean opt-in rejected.Full suite: 952/952 passing.
Refs
--tunnel-connect); will register withpassthroughwhen exposing an authenticated pod?token=WS auth this builds on