Skip to content

PROXY protocol v2 + downstream JA3/JA4 fingerprint forwarding#23

Open
kriszyp wants to merge 4 commits into
kris/ja4-12from
kris/proxy-v2-fingerprint-9
Open

PROXY protocol v2 + downstream JA3/JA4 fingerprint forwarding#23
kriszyp wants to merge 4 commits into
kris/ja4-12from
kris/proxy-v2-fingerprint-9

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 7, 2026

Copy link
Copy Markdown
Member

Closes #9. Adds the two edge-proxy capabilities that issue tracks, bundled because PROXY v2's TLV is the natural carrier for the forwarded fingerprint.

Stacked on #20 (kris/ja4-12) so this PR's diff is just my two commits — it carries JA3 and JA4 (per direction to ship both together). GitHub will auto-retarget to main when #20 merges.

What it does

  1. PROXY protocol v2 emit — a binary v2 header encoder (write_proxy_v2_header) beside the v1 text one, selected per route via sourceAddressHeader: 'proxyProtocolV2'. v2 adds the TLV section used below. Stays opt-in; UDS default remains v1 (Harper core's own UDS reader parses v1 only).
  2. Forward the fingerprintforwardFingerprint: 'ja3' | 'ja4' | 'none' hands symphony's computed ClientHello fingerprint to the upstream. Carrier follows the mode: a PROXY v2 TLV (0xE0=JA3 / 0xE1=JA4, HAProxy private range) under proxyProtocolV2 — works in passthrough since it prefixes the raw bytes — otherwise an injected X-JA3/X-JA4 header for terminated HTTP/1 upstreams. Motivating use case: Automattic wants the edge fingerprint passed through to their own app servers behind us.

Where to look

  • src/upstream.rswrite_proxy_v2_header (byte layout: 12-byte sig + 0x21 + family/proto + BE length + address block + TLV). Cross-model review verified it against the HAProxy 2.8 spec; unit tests cover IPv4+TLV, empty-TLV skip, IPv4-mapped-IPv6 collapse, IPv6.
  • src/proxy_conn.rsapply_source_header gates all HTTP-header injection on l7_http1 (terminated TLS and non-h2 ALPN), so text is never spliced into passthrough TLS ciphertext or an HTTP/2 frame stream — a no-op otherwise. inject_request_headers strips any client-supplied copy of an injected header (anti-spoof, so a client can't pre-seed X-JA3).

Notes for the reviewer

  • Bundled behavior change (intentional, kept in this PR): toJsRoute/resolveConnection in ts/proxy.ts never forwarded the http2 field — it was declared in types and parsed in Rust but silently dropped by the JS wrapper, so http2: true was a no-op on that path. Fixed here (it's adjacent, and it's what makes the h2 carrier path reachable to test). Note the effect: any existing route carrying http2: true will now genuinely negotiate h2. Confirmed with Kris that it stays bundled here rather than splitting out.
  • Cross-model review caveat: ran Codex + the Harper-domain pass; all three findings they surfaced (passthrough/h2 stream corruption, fingerprint spoofing) are fixed in commit 2 with tests. The Gemini/agy leg hung both attempts, so there's no second-model perf/maintainability pass this run.

Tests

Rust unit tests for the v2 encoder + header strip; integration tests for v2-TLV forwarding, no-TLV v2, X-JA3 injection, passthrough no-op, and h2 negotiation + no-injection. Full suite green (Rust 22, Node 39).

Generated by an LLM (Claude Opus 4.8).

@kriszyp kriszyp requested review from DavidCockerill and heskew July 7, 2026 04:53

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces support for PROXY protocol v2 and downstream TLS fingerprint forwarding (JA3/JA4). Fingerprints are forwarded either via a PROXY v2 TLV or as injected HTTP headers (X-JA3/X-JA4) for plaintext HTTP/1 upstreams, with client-supplied headers stripped to prevent spoofing. Comprehensive integration tests and documentation updates are also included. Feedback is provided regarding a potential silent truncation issue in write_proxy_v2_header when casting the TLV length to u16, where using try_into() with proper error handling is recommended to ensure safety.

Comment thread src/upstream.rs
@kriszyp

kriszyp commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

CI status: all 5 platform builds + every unit-test job (ubuntu Node 20/22, macOS-14 Node 20) are green. Two checks are red/cancelled for reasons unrelated to this change:

  • Integration Test – Harper UDS proxy — pre-existing red on the base branch: the harper dev-dependency's own sources (analytics/profile.ts) can't resolve their ../core/* submodule paths (TS2307). Not symphony code, not a regression here.
  • Test – macos-13 Node 20 — cancelled; that runner image is retired (removed by Fix CI: check out core submodule, drop retired macos-13 runner #18).

Nothing to action on those two. — KrAIs (Claude Opus 4.8)

@kriszyp kriszyp marked this pull request as ready for review July 8, 2026 20:57
kriszyp and others added 4 commits July 8, 2026 16:00
Adds two related edge-proxy capabilities (issue #9):

1. PROXY protocol v2 (binary, TLV-capable) header emission alongside the
   existing v1 text encoder, selectable per route via
   sourceAddressHeader: 'proxyProtocolV2'. v1 stays the UDS default since
   Harper core's own UDS reader currently parses v1 only.

2. forwardFingerprint ('ja3' | 'ja4' | 'none') forwards the ClientHello
   fingerprint symphony already computes to the upstream so backends can act
   on it. Carrier follows the mode: a PROXY v2 TLV (0xE0=JA3 / 0xE1=JA4, in
   HAProxy's private range) under proxyProtocolV2 — which works in passthrough
   since it prefixes the raw TLS bytes — otherwise an injected X-JA3/X-JA4 HTTP
   header for terminated L7 upstreams. Fingerprint-agnostic: JA4 rides the same
   path as JA3 (built on the JA4 branch, #2/#20).

proxy_conn.rs threads the fingerprint + the connection's local_addr (the v2
destination) into apply_source_header; inject_x_forwarded_for generalized to
inject_request_headers for XFF + fingerprint headers in one request rewrite.

Drive-by: toJsRoute/resolveConnection dropped the http2 field entirely — it was
declared in types + parsed in Rust but never forwarded by the JS wrapper; fixed
alongside forwardFingerprint. Also unstuck a stale server.spec version assertion
(0.4.0 → 0.5.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… client copies

Cross-model review (Codex + domain pass) surfaced three issues in the header
carrier, all fixed by enforcing the invariant that HTTP-header injection is only
valid for a plaintext HTTP/1 upstream and injected trust-signal headers must be
authoritative:

- Passthrough / h2 corruption: injecting X-JA3/X-Forwarded-For into raw TLS
  ciphertext (terminateTls:false) or an HTTP/2 frame stream broke the upstream.
  apply_source_header now gates all header injection on l7_http1 (terminated TLS
  AND non-h2 negotiated ALPN) and no-ops otherwise; the PROXY v2 TLV carrier is
  unaffected (it prefixes the bytes). This also fixes the pre-existing latent
  X-Forwarded-For-in-passthrough corruption symmetrically.
- Spoofing: inject_request_headers now strips any client-supplied copy of an
  injected header (case-insensitive) before writing symphony's value, so a
  client can't pre-seed X-JA3/X-JA4 (or X-Forwarded-For).

Tests: passthrough no-op (end-to-end round-trip intact), h2 negotiation + no
injection into the h2 preface, client-header strip unit test. Broadened the
config warning to cover http2 routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per gemini-code-assist review: refuse (io::Error) an oversized TLV value or
total header instead of silently truncating the u16 length field. Never hit by
JA3/JA4 (short), but the encoder is generic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kriszyp kriszyp force-pushed the kris/proxy-v2-fingerprint-9 branch from 73c04c0 to 18bea07 Compare July 8, 2026 22:01
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