Skip to content

Decode PROXY protocol v2 with forwarded mTLS client certificates on UDS mirrors#1858

Open
kriszyp wants to merge 4 commits into
mainfrom
kris/pp2-mtls
Open

Decode PROXY protocol v2 with forwarded mTLS client certificates on UDS mirrors#1858
kriszyp wants to merge 4 commits into
mainfrom
kris/pp2-mtls

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 18, 2026

Copy link
Copy Markdown
Member

Summary

Symphony can now terminate and verify mTLS and forward the client certificate chain over PROXY protocol v2 TLVs (symphony#27). Harper's per-worker UDS mirrors only understood PROXY v1 text headers, so client identity was invisible behind the proxy's TLS termination. This PR is the consuming half:

  • New server/serverHelpers/proxyProtocol.ts — a shared v1+v2 decoder replacing the two previously-inline v1 parsers (enableProxyProtocol and createH2CProxyFront in server/http.ts). v2 carries the client source address plus, when symphony verified an mTLS client cert, the DER chain (SSL TLV 0x20 + one 0xE0 TLV per cert, leaf first).
  • TLSSocket semantics on plaintext sockets — a forwarded verified chain sets socket.authorized and a lazy, memoized socket.getPeerCertificate() (built with crypto.X509Certificate, issuerCertificate links wired so certificateVerification's chain walk and OCSP/CRL checks work unchanged). Existing HTTP (security/auth.ts) and MQTT mTLS auth paths need zero changes. Sockets with no forwarded cert get no-client-cert TLS defaults (getPeerCertificate() → {}, authorized: false).
  • mtlsConfig mirroring — the UDS mirror servers (h1 and the h2c front) now carry the secure server's mtlsConfig/verifiesClientCerts, and the raw-socket mirror path sets mtlsRequired (threadServer.js).
  • Metadata advertisement — the sockets-dir yaml gains mtls: true / mtlsRequired: true so host-manager can configure symphony to request client certs and forward them (suppressed for the uWS mirror, which can't decode PROXY headers and uses X-Forwarded-For).

Where to look

  • The trust boundary: a forwarded chain is only honored where PROXY protocol was already trusted for source-address override (filesystem-permissioned UDS paths). Whether verify==0 from the proxy should map directly to socket.authorized = true is the key design judgment.
  • Behavior change: Request.peerCertificate on UDS-fronted requests now returns {} (TLS no-cert semantics) instead of null — this is what makes auth.ts's request.peerCertificate.subject guards safe once mtlsConfig exists on UDS servers.
  • The refactored v1 path in createH2CProxyFront (pre-handoff readable loop) — existing unit tests all pass, but it's the riskiest surface of the refactor.

Open items from cross-model review

  • Codex flagged (and this PR fixes) two yaml-advertisement gaps: raw-socket mirrors never set mtlsRequired, and the uWS mirror would have advertised forwarding it can't decode. Both addressed; worth a second look at the uWS gating (!process.env.HARPER_UWS_UDS at the writeMetadata call site).
  • Gemini suggested a shared frozen {} for the empty-cert return; kept per-call allocation deliberately (a shared object could leak mutations across connections — Node also returns a fresh object).

Testing

  • 30 new unit tests (unitTests/server/serverHelpers/proxyProtocol.test.js + extensions to udsMirror.test.js): v1/v2 decode incl. split-across-chunks buffering, malformed TLVs, LOCAL command, IPv6 compression, cert-chain synthesis consumed by extractCertificateChain, yaml flags. All 53 tests in the touched suites pass; full test:unit:main fails identically on clean main locally (env issue) — relying on CI.
  • End-to-end against a symphony PP2 stream is covered by symphony#27's integration tests on the emitting side; a joint integration test is a follow-up once both merge.

Generated by an LLM (Claude Fable 5) pairing with Kris.

🤖 Generated with Claude Code

@kriszyp
kriszyp requested review from Ethan-Arrowood and heskew July 18, 2026 03:01
@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request adds support for decoding PROXY protocol v2 (binary) headers, enabling plaintext Unix Domain Socket (UDS) mirrors to receive forwarded client source addresses and mTLS client certificate chains from a fronting proxy. The changes mock TLSSocket semantics on plaintext sockets so that downstream HTTP and MQTT mTLS authentication paths work seamlessly. The review feedback identifies critical security gaps where the mtlsRequired configuration is not mirrored to the HTTP/1 and HTTP/2 UDS servers, which could lead to security bypasses. Additionally, the feedback points out that the mocked getPeerCertificate implementation should respect Node's native detailed parameter, and notes a minor typo in the design documentation.

Comment thread server/http.ts
Comment thread server/http.ts
Comment thread server/serverHelpers/proxyProtocol.ts Outdated
Comment thread server/DESIGN.md Outdated
@kriszyp
kriszyp marked this pull request as ready for review July 18, 2026 03:21
@kriszyp
kriszyp requested review from Devin-Holland and removed request for Ethan-Arrowood July 18, 2026 12:16
@kriszyp

kriszyp commented Jul 18, 2026

Copy link
Copy Markdown
Member Author

Two follow-up commits since the initial reviews:

  1. 3baa1c4 — MQTT mTLS actually works now: enableProxyProtocol's data-path interception applies the forwarded identity on the first data event, but MQTT checks socket.authorized at connection time — before the PROXY header bytes arrive. New withProxyProtocol(listener) consumes the header pre-handoff (shared consumeProxyHeader loop, also adopted by createH2CProxyFront), and the threadServer raw-socket UDS mirror now uses it. Tests assert the identity is visible synchronously at listener time.
  2. Cert-chain TLV renumbered 0xE00xE2 to coexist with symphony#23's allocation (0xE0 = JA3, 0xE1 = JA4); symphony#27 is now stacked on 500 Accessing Fastify route for newly deployed extension #23 and emits 0xE2.

🤖 Claude Fable 5, on Kris's behalf

kriszyp and others added 4 commits July 22, 2026 18:02
…DS mirrors

Symphony (the fronting TLS proxy) can now terminate and verify mTLS and
forward the client certificate chain via PROXY v2 TLVs (SSL TLV 0x20 +
custom 0xE0, one DER per cert, leaf first). Harper's UDS mirrors only
spoke PROXY v1, so that identity was invisible behind termination.

- New server/serverHelpers/proxyProtocol.ts: v1+v2 decoder shared by
  enableProxyProtocol and createH2CProxyFront (previously two inline v1
  parsers), plus synthesis of a Node getPeerCertificate(true)-shaped
  object from the forwarded DER chain (issuerCertificate links wired for
  certificateVerification's chain walk and OCSP/CRL checks).
- Forwarded verified chains get TLSSocket semantics on the plaintext
  socket (authorized, getPeerCertificate) so HTTP auth (security/auth.ts)
  and MQTT mTLS work unchanged; sockets without a forwarded cert get
  no-client-cert TLS defaults ({} / authorized=false).
- UDS mirror servers mirror the secure server's mtlsConfig; the metadata
  yaml advertises mtls/mtlsRequired so the proxy can request client certs
  (suppressed for the uWS mirror, which cannot decode PROXY headers).
- IPv6 source addresses are compressed to match net.Socket remoteAddress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ificate(detailed), DESIGN.md typo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… to 0xE2

MQTT (the primary mTLS consumer) reads socket.authorized and
getPeerCertificate at connection time, but enableProxyProtocol only
applies the forwarded identity when the first data event fires — after
the listener has already checked. New withProxyProtocol(listener)
consumes the header pre-handoff (shared consumeProxyHeader loop, also
now used by createH2CProxyFront) and the threadServer raw-socket UDS
mirror uses it instead of data-path interception.

Cert chain TLV moves 0xE0 -> 0xE2 to coexist with symphony PR #23's
allocation (0xE0 = JA3, 0xE1 = JA4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread server/DESIGN.md
| `onRequest()` | Thin alias of `httpServer({requestOnly: true})`. |
| `onUpgrade()` / `upgradeListeners` (const) | Register HTTP upgrade listener; underlying list. |
| `onWebSocket()` / `websocketListeners` (const) | Register WebSocket listener; auto-adds default upgrade handler the first time it runs for a port. Underlying list of registrations. |
| `enableProxyProtocol()` | PROXY v1/v2 stripping on UDS mirrors (Node 24+-compatible workaround). Decoding lives in `serverHelpers/proxyProtocol.ts`; v2 TLVs forward the client source address and the mTLS client cert chain that a fronting proxy (symphony) verified — exposed with TLSSocket semantics (`authorized`, `getPeerCertificate()`) so HTTP/MQTT mTLS auth works unchanged. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion (non-blocking): This row still attributes forwarded mTLS identity for "HTTP/MQTT" to enableProxyProtocol(), but this PR's own threadServer.js change moves MQTT (and other raw-protocol UDS mirrors) off enableProxyProtocol onto the new withProxyProtocol()/consumeProxyHeader() pre-handoff path precisely because the data-interception approach applies identity too late for MQTT. Worth updating this line (and/or adding a withProxyProtocol() row) so the design map doesn't point future readers at the wrong function for MQTT's identity forwarding.

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.

1 participant