Skip to content

feat(tunnel): opt-in per-tunnel credential passthrough — closes #530#555

Merged
melvincarvalho merged 5 commits into
gh-pagesfrom
issue-530-tunnel-passthrough
Jun 11, 2026
Merged

feat(tunnel): opt-in per-tunnel credential passthrough — closes #530#555
melvincarvalho merged 5 commits into
gh-pagesfrom
issue-530-tunnel-passthrough

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Closes #530.

What

The tunnel proxy strips Cookie/Authorization inbound and Set-Cookie outbound 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:

 { "type": "register", "name": "phone", "passthrough": true }
 { "type": "registered", "name": "phone", "url": "/tunnel/phone/", "passthrough": true }

With passthrough, the proxy forwards Cookie + Authorization to the tunnel client and Set-Cookie back to the visitor. Off by default; the ack echoes the mode the relay actually applied.

Security shape

Property Implementation
Per-tunnel, owner-asserted opt-in passthrough accepted only at registration, by the WebID-authenticated registrant — the party receiving the credentials
Strict opt-in parsing msg.passthrough === true"true"/1 do not enable forwarding (tested)
Proxy-Authorization never forwarded Addressed to the relay, not the tunnelled service — stays stripped in both modes (tested)
Default behaviour pinned The previously-untested strip-everything default now has its own test, so passthrough work can't silently regress the security default

The 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-Cookie Path attributes 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-value Set-Cookie survives as an array through JSON → Fastify emits one header per entry (tested).

Tests

4 new cases in test/tunnel.test.js (reusing the existing connectTunnel/waitMsg harness): default strips both directions, passthrough forwards all three headers (multi-cookie array included — asserted via headers.get('set-cookie'), deliberately not getSetCookie() per the Node-18 floor discussion on #554), Proxy-Authorization never forwarded, non-boolean opt-in rejected.

Full suite: 952/952 passing.

Refs

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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: true and echo the applied mode back in the registered ack.
  • Conditionally forward inbound Cookie/Authorization and outbound Set-Cookie only when passthrough is enabled; always keep Proxy-Authorization stripped.
  • Add tests covering the default stripping behavior, passthrough behavior, strict boolean parsing, and the Proxy-Authorization invariant.

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.

Comment thread test/tunnel.test.js Outdated
Comment on lines +271 to +275
// 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}`);

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.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread test/tunnel.test.js Outdated
Comment on lines +243 to +244
assert.strictEqual(res.headers.get('set-cookie'), null,
'set-cookie from the tunnel client must NOT reach the visitor by default');

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.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread src/tunnel/index.js
Comment on lines +189 to +194
// 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']);

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.

Comment thread src/tunnel/index.js

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread src/tunnel/index.js
Comment on lines 120 to 123
// Close old tunnel with same name from same user
if (existing) {
existing.socket.close();
}
Comment thread src/tunnel/index.js Outdated
Comment on lines +13 to +14
* → { 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread src/tunnel/index.js
Comment on lines +59 to +71
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('; ');
}

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.

Fixed in aee9646stripRelayCookies 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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-Cookie headers from the tunnelled service to the visitor unchanged. Because the relay’s own IdP cookies are also on Path=/ (and the relay already strips _session*/_interaction* from inbound Cookie), 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 outbound Set-Cookie in 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);
      }
    }

@melvincarvalho
melvincarvalho merged commit 7d1c9f3 into gh-pages Jun 11, 2026
2 checks passed
@melvincarvalho
melvincarvalho deleted the issue-530-tunnel-passthrough branch June 11, 2026 05:20
melvincarvalho added a commit that referenced this pull request Jun 11, 2026
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tunnel: optional credential passthrough (Cookie / Authorization / Set-Cookie) so authenticated access & login work through the tunnel

2 participants