From ef6dc6ff6fbb2d1ce4cab6651dc43d32a3c9f5aa Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 28 Jul 2026 16:38:32 -0600 Subject: [PATCH 1/2] docs(http): document request.connectionInfo (forwarded PROXY v2 TLS facts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to harper#1985 — documents the new request.connectionInfo accessor (ALPN, SNI authority, TLS version/cipher, JA3/JA4 fingerprint, verified mTLS client cert chain, forwarded over PROXY protocol v2 by a TLS-terminating proxy) on the Request reference, plus the clarified request.protocol behavior behind such a proxy. Includes the ConnectionInfo shape, a JA4 usage example, and the trust/mTLS-verified caveat. Co-Authored-By: Claude Opus 4.8 --- reference/http/api.md | 58 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/reference/http/api.md b/reference/http/api.md index 3bc9d3217..0d41b9c9b 100644 --- a/reference/http/api.md +++ b/reference/http/api.md @@ -78,17 +78,53 @@ A `Request` object is passed to HTTP middleware handlers and direct static REST ### Properties -| Property | Type | Description | -| ---------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `url` | string | The request target (path + query string), e.g. `/path?query=string` | -| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | -| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | -| `pathname` | string | Path portion of the URL, without query string | -| `protocol` | string | `http` or `https` | -| `data` | any | Deserialized body, based on `Content-Type` header | -| `ip` | string | Remote IP address of the client (or last proxy) | -| `host` | string | Host from the request headers | -| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | +| Property | Type | Description | +| ---------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `url` | string | The request target (path + query string), e.g. `/path?query=string` | +| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | +| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | +| `pathname` | string | Path portion of the URL, without query string | +| `protocol` | string | `http` or `https`. Behind a TLS-terminating proxy that forwards PROXY protocol v2, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext. | +| `data` | any | Deserialized body, based on `Content-Type` header | +| `ip` | string | Remote IP address of the client (or last proxy) | +| `host` | string | Host from the request headers | +| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | +| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the verified mTLS client certificate chain. `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | + +### Connection info + + + +When Harper runs behind a TLS-terminating proxy (for example, Harper Fabric's Symphony edge), the TLS handshake happens at the proxy, so facts about the client's TLS connection — its fingerprint, negotiated protocol, and any client certificate — are otherwise invisible to your application. When the proxy is configured to forward them over PROXY protocol v2, Harper decodes them and exposes them on `request.connectionInfo`: + +```ts +interface ConnectionInfo { + alpn?: string; // negotiated ALPN protocol, e.g. "h2" + authority?: string; // SNI hostname from the ClientHello + tls?: { + version?: string; // e.g. "TLSv1.3" + cipher?: string; // negotiated cipher suite + verified?: boolean; // a client certificate was presented and the proxy verified it + }; + ja3?: string; // JA3 fingerprint (32-char MD5 hex) + ja4?: string; // JA4 fingerprint + clientCertChain?: Buffer[]; // verified mTLS client certificate chain (DER, leaf first) +} +``` + +`request.connectionInfo` is `undefined` unless a PROXY v2 header was decoded; each field is present only when the proxy actually forwarded it. Reading the client's JA4 fingerprint — e.g. to make a bot or abuse decision — is simply: + +```javascript +class Origin { + get(request) { + const ja4 = request.connectionInfo?.ja4; + if (ja4 && abuseList.has(ja4)) throw new Error('blocked'); + return fetch(request); + } +} +``` + +**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so a client cannot forge it. `tls.verified` reflects the proxy's verify bit: it can be `true` even when `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). For gating on a verified mTLS client identity, use the parsed [`request.peerCertificate`](../security/certificate-verification.md) and the connection's authenticated user, not `connectionInfo.tls.verified` alone. ### Methods From 1bbffdfd85eb0cca32f8520cea26c407f24e052f Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 29 Jul 2026 12:39:44 -0600 Subject: [PATCH 2/2] docs(http): address review feedback on connectionInfo docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add to the protocol row, matching the file's convention for documenting behavior changes to existing surface (Gemini + Ethan Arrowood, PR #614). - Fix the peerCertificate link: certificate-verification.md is about CRL/OCSP revocation and never mentions peerCertificate or the authenticated-user flow (Ethan Arrowood). - Tighten the connectionInfo undefined/runtime caveat: it's undefined whenever no TLS-bearing TLV decoded (not just "no v2 header"), and it's Node-runtime only — Bun and uWebSockets never populate it (Ethan Arrowood). - Flag alpn/authority/ja3/ja4 as string-decoded from raw, unvalidated TLV payloads with no grammar/length validation, matching the harper source docblock (server/serverHelpers/proxyProtocol.ts) that introduces these fields, and warn against placing them unsanitized in headers, logs, or cache keys. Switch the JA4 example's denial from a generic `throw new Error` (reads as a 500) to the repo's established `new Response(..., { status: 403 })` policy-denial convention (see reference/resources/resource-api.md). - Correct the verified-mTLS gating guidance: point at `request.authorized` (the chain-gated boolean the proxy layer populates with TLSSocket semantics — proxyProtocol.ts's own comment: "For the standard 'verified mTLS client' gate use request.authorized") instead of `request.peerCertificate` + "the authenticated user". Note it's Node-runtime only, same as connectionInfo. - Fix a real accuracy bug surfaced by independent review: `clientCertChain` was documented as "verified," but proxyProtocol.ts attaches the chain whenever the client presented a certificate, independent of the proxy's verify result — `tls.verified` and `clientCertChain` presence are two different signals, and treating chain presence as proof of verification accepts unverified clients. Corrected the property table, the ConnectionInfo interface comment, and the trust paragraph to say so explicitly. - Narrow the `protocol` row to Node-runtime SSL-TLV behavior; Bun and uWebSockets derive the scheme from X-Forwarded-Proto/the listener's secure flag instead. - Scope PROXY v2 decoding to Harper Fabric's proxy-protocol UDS mirrors specifically, not ordinary http.port/securePort listeners. The static-vs-instance-method suggestion on the get() example was deliberately left as-is: the file's other Resource example (line 148) also uses an instance method, and Ethan confirmed no such rule exists in AGENTS.md/CONTRIBUTING.md. Refs #614 Co-Authored-By: Claude Sonnet 5 --- reference/http/api.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/reference/http/api.md b/reference/http/api.md index 0d41b9c9b..babe8a961 100644 --- a/reference/http/api.md +++ b/reference/http/api.md @@ -78,53 +78,53 @@ A `Request` object is passed to HTTP middleware handlers and direct static REST ### Properties -| Property | Type | Description | -| ---------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `url` | string | The request target (path + query string), e.g. `/path?query=string` | -| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | -| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | -| `pathname` | string | Path portion of the URL, without query string | -| `protocol` | string | `http` or `https`. Behind a TLS-terminating proxy that forwards PROXY protocol v2, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext. | -| `data` | any | Deserialized body, based on `Content-Type` header | -| `ip` | string | Remote IP address of the client (or last proxy) | -| `host` | string | Host from the request headers | -| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | -| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the verified mTLS client certificate chain. `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | +| Property | Type | Description | +| ---------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `url` | string | The request target (path + query string), e.g. `/path?query=string` | +| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, etc. | +| `headers` | [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | Request headers | +| `pathname` | string | Path portion of the URL, without query string | +| `protocol` | string | `http` or `https`. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as `http`. Bun and uWebSockets derive this from `X-Forwarded-Proto`/the listener's secure flag instead, independent of the SSL TLV. | +| `data` | any | Deserialized body, based on `Content-Type` header | +| `ip` | string | Remote IP address of the client (or last proxy) | +| `host` | string | Host from the request headers | +| `session` | object | Current cookie-based session (a `Table` record instance). Update with `request.session.update({ key: value })`. A cookie is set automatically the first time a session is updated or a login occurs. | +| `connectionInfo` | object \| undefined | TLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). `undefined` for a direct connection or one without a v2 header. See [Connection info](#connection-info). | ### Connection info -When Harper runs behind a TLS-terminating proxy (for example, Harper Fabric's Symphony edge), the TLS handshake happens at the proxy, so facts about the client's TLS connection — its fingerprint, negotiated protocol, and any client certificate — are otherwise invisible to your application. When the proxy is configured to forward them over PROXY protocol v2, Harper decodes them and exposes them on `request.connectionInfo`: +When Harper runs behind a TLS-terminating proxy (for example, Harper Fabric's Symphony edge), the TLS handshake happens at the proxy, so facts about the client's TLS connection — its fingerprint, negotiated protocol, and any client certificate — are otherwise invisible to your application. Harper Fabric's proxy-protocol Unix domain socket mirrors decode this from a forwarded PROXY protocol v2 header and expose it on `request.connectionInfo`; a PROXY v2 header sent to an ordinary `http.port`/`securePort` listener isn't decoded (and isn't valid HTTP, so the connection fails). ```ts interface ConnectionInfo { - alpn?: string; // negotiated ALPN protocol, e.g. "h2" - authority?: string; // SNI hostname from the ClientHello + alpn?: string; // negotiated ALPN protocol, e.g. "h2" — raw, unvalidated + authority?: string; // SNI hostname from the ClientHello — raw, unvalidated tls?: { version?: string; // e.g. "TLSv1.3" cipher?: string; // negotiated cipher suite verified?: boolean; // a client certificate was presented and the proxy verified it }; - ja3?: string; // JA3 fingerprint (32-char MD5 hex) - ja4?: string; // JA4 fingerprint - clientCertChain?: Buffer[]; // verified mTLS client certificate chain (DER, leaf first) + ja3?: string; // JA3 fingerprint (32-char MD5 hex) — raw, unvalidated + ja4?: string; // JA4 fingerprint — raw, unvalidated + clientCertChain?: Buffer[]; // client certificate chain (DER, leaf first) — presented, not necessarily verified; check tls.verified } ``` -`request.connectionInfo` is `undefined` unless a PROXY v2 header was decoded; each field is present only when the proxy actually forwarded it. Reading the client's JA4 fingerprint — e.g. to make a bot or abuse decision — is simply: +`request.connectionInfo` is `undefined` unless a PROXY v2 header carrying TLS facts was decoded; each field is present only when the proxy actually forwarded it. On the Bun and uWebSockets runtimes it is always `undefined`. `alpn`, `authority`, `ja3`, and `ja4` are string-decoded from the TLV payload with no grammar or length validation beyond the header's own bounds, so validate them before placing them in response headers, logs, cache keys, or other security-sensitive contexts. Reading the client's JA4 fingerprint — e.g. to make a bot or abuse decision — is simply: ```javascript class Origin { get(request) { const ja4 = request.connectionInfo?.ja4; - if (ja4 && abuseList.has(ja4)) throw new Error('blocked'); + if (ja4 && abuseList.has(ja4)) return new Response('blocked', { status: 403 }); return fetch(request); } } ``` -**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so a client cannot forge it. `tls.verified` reflects the proxy's verify bit: it can be `true` even when `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). For gating on a verified mTLS client identity, use the parsed [`request.peerCertificate`](../security/certificate-verification.md) and the connection's authenticated user, not `connectionInfo.tls.verified` alone. +**Trust and mTLS.** `connectionInfo` is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its _contents_ are client-controlled, per above). `clientCertChain` and `tls.verified` are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and `tls.verified` can be `true` while `clientCertChain` is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use `request.authorized` — a chain-gated boolean the proxy layer populates with the same semantics as a directly-terminated TLS connection — rather than `connectionInfo.tls.verified` or `clientCertChain` presence alone. Like `connectionInfo`, this is Node-runtime only: `request.authorized` is always `undefined` on Bun and uWebSockets. ### Methods