Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ console.log('proxy listening on :443');
| `http2` | `boolean` | `false` | Advertise `h2` in ALPN so clients negotiate HTTP/2. Raw H2 frames flow through to the upstream unchanged. Requires `terminateTls: true`. |
| `sourceAddressHeader` | `'proxyProtocol' \| 'proxyProtocolV2' \| 'xForwardedFor' \| 'none'` | `'proxyProtocol'` for UDS, `'none'` for TCP | How the real client IP is forwarded to the upstream. See [Source address forwarding](#source-address-forwarding). |
| `forwardFingerprint` | `'ja3' \| 'ja4' \| 'none'` | `'none'` | Forward the client TLS fingerprint downstream. See [Forwarding the fingerprint](#forwarding-the-fingerprint-downstream). |
| `protocol` | `'http' \| 'opaque'` | `'opaque'` | The route's application protocol. Required to be `'http'` before `sourceAddressHeader: 'xForwardedFor'` or a header-carried `forwardFingerprint` is accepted — see [Source address forwarding](#source-address-forwarding). |

### `Upstream`

Expand Down Expand Up @@ -355,9 +356,32 @@ Use `sourceAddressHeader` on a route to control how the real client IP is commun
|---|---|
| `'proxyProtocol'` | Sends a PROXY protocol v1 (text) header (`PROXY TCP4 <src-ip> <dst-ip> <src-port> 0\r\n`) before any application data. Default for UDS upstreams. |
| `'proxyProtocolV2'` | Sends a PROXY protocol v2 (binary) header before any application data. v2 adds a TLV section — the carrier for `forwardFingerprint` below and for [mTLS client cert forwarding](#forwarding-mtls-client-certificates). Keep it opt-in: the consumer must speak v2 (nginx/HAProxy do; Harper core's UDS reader parses v1 only before Harper 5.2). |
| `'xForwardedFor'` | Reads the first chunk of the HTTP request, inserts an `X-Forwarded-For` header after the request line, then copies the rest verbatim. No per-request parsing overhead for keep-alive connections. Default for TCP upstreams (disabled). |
| `'xForwardedFor'` | Reads and rewrites every request on the connection (not just the first), inserting an `X-Forwarded-For` header after the request line — a pipelined or keep-alive request must be parsed too, or a later request could smuggle a spoofed header past the first-request-only rewrite. Default for TCP upstreams (disabled). Requires `protocol: 'http'` on the route (below). |
| `'none'` | Does not forward source address information. Default for TCP upstreams. |

### Declaring the route protocol

`sourceAddressHeader: 'xForwardedFor'`, and `forwardFingerprint` under any mode other than `'proxyProtocolV2'`, rewrite an HTTP/1 request — they need a route that says it actually carries HTTP. Set `protocol: 'http'` to opt in:

```typescript
{
sni: 'app.example.com',
upstreams: [{ kind: 'uds', path: '/run/app/worker.sock' }],
terminateTls: true,
cert: { certChain, privateKey },
sourceAddressHeader: 'xForwardedFor',
protocol: 'http', // required — omitting this is a config error, not a silent no-op
}
```

`protocol` defaults to `'opaque'` — a route for a non-HTTP application protocol (MQTT, or any other raw TCP/TLS protocol), limited to the PROXY-protocol carriers, which work on any byte stream. The declaration exists because ALPN can't stand in for it: a native protocol that negotiates no ALPN (MQTT does not) is indistinguishable at the TLS layer from an HTTPS client that simply didn't offer one. A route that requests a header-injection mode without declaring `protocol: 'http'` is rejected with a descriptive error — it never silently stops injecting the header, since a backend that silently sees the wrong (or no) client IP is worse than a route that refuses to serve traffic at all.

This declaration only helps when a header could actually be injected in the first place. A **passthrough** route (`terminateTls: false`) never decrypts the stream, so a header-carried mode has no carrier at all regardless of `protocol` — declaring `'http'` on a passthrough route wouldn't make it work, and is rejected with a distinct "no carrier" error instead of steering you toward a declaration that can't help. Use `sourceAddressHeader: 'proxyProtocolV2'` there instead (it works on any byte stream, passthrough included).

Like a cert that fails to build, a route rejected for either reason is isolated to just that route: it's dropped (its SNI resolves to nothing) rather than failing `new SymphonyProxy()` or `updateConfig()` for every other route on the same port-set — one tenant's config mistake doesn't take the rest down with it. The rejection is still loud: it's logged (`symphony: skipping route '<sni>': ...`), and on a hot-swap the route's last-good version (if any) keeps serving until the config is fixed.

This is a breaking change for a hand-written route that already uses `sourceAddressHeader: 'xForwardedFor'` (or a header-carried `forwardFingerprint`) without `protocol: 'http'` — add the declaration when upgrading, or that route will stop serving traffic (silently, aside from the log line) rather than failing loudly at startup.

### PROXY protocol (default for UDS)

Most backends that consume PROXY protocol (nginx, HAProxy, HarperDB) read the header once per connection before parsing application data.
Expand All @@ -374,7 +398,7 @@ Most backends that consume PROXY protocol (nginx, HAProxy, HarperDB) read the he

### X-Forwarded-For (for Bun and other HTTP backends)

Bun's built-in HTTP server does not support PROXY protocol. Use `'xForwardedFor'` instead — symphony injects the header into the first HTTP request of each connection:
Bun's built-in HTTP server does not support PROXY protocol. Use `'xForwardedFor'` instead — symphony injects the header into every HTTP request on the connection, not just the first:

```typescript
{
Expand All @@ -383,6 +407,7 @@ Bun's built-in HTTP server does not support PROXY protocol. Use `'xForwardedFor'
terminateTls: true,
cert: { certChain, privateKey },
sourceAddressHeader: 'xForwardedFor',
protocol: 'http',
}
```

Expand Down Expand Up @@ -410,10 +435,10 @@ symphony computes the client's JA3/JA4 fingerprint from the ClientHello (the sam

The **carrier depends on `sourceAddressHeader`**:

- With `'proxyProtocolV2'`, the fingerprint rides a PROXY v2 **TLV** — type `0xE0` for JA3, `0xE1` for JA4 (in HAProxy's `0xE0–0xEF` private range). This works even in passthrough (`terminateTls: false`), since the header prefixes the raw TLS bytes.
- Otherwise, symphony injects an **`X-JA3` / `X-JA4` HTTP header**. This requires a plaintext HTTP/1 upstream (`terminateTls: true` and not `http2`); it is skipped for passthrough or HTTP/2 upstreams (use `'proxyProtocolV2'` there). Any client-supplied `X-JA3`/`X-JA4` is stripped so the injected value is authoritative and can't be spoofed.
- With `'proxyProtocolV2'`, the fingerprint rides a PROXY v2 **TLV** — type `0xE0` for JA3, `0xE1` for JA4 (in HAProxy's `0xE0–0xEF` private range). This works even in passthrough (`terminateTls: false`), since the header prefixes the raw TLS bytes. No `protocol` declaration is needed — the TLV carries it regardless of the route's application protocol.
- Otherwise, symphony injects an **`X-JA3` / `X-JA4` HTTP header**. This requires `protocol: 'http'` on the route (see [Declaring the route protocol](#declaring-the-route-protocol)) and a plaintext HTTP/1 upstream (`terminateTls: true` and not `http2`); it is skipped for HTTP/2 upstreams (use `'proxyProtocolV2'` there). For that HTTP/1 case, any client-supplied `X-JA3`/`X-JA4` is stripped so the injected value is authoritative and can't be spoofed — **this guarantee does not extend to an h2-negotiated connection on an `http2: true` route**: injection and stripping are both skipped there, so a client-supplied `X-JA3`/`X-JA4` reaches the upstream unmodified. Use `'proxyProtocolV2'` wherever h2 is possible.

A config that requests `forwardFingerprint` with no viable carrier — passthrough (`terminateTls: false`) without `sourceAddressHeader: 'proxyProtocolV2'`, where there's neither an HTTP request to inject a header into nor a v2 TLV — logs a startup warning rather than silently dropping the signal.
A config that requests a header-carried `forwardFingerprint` without declaring `protocol: 'http'` is rejected (and isolated to just that route) — the same fail-loud rule as `sourceAddressHeader: 'xForwardedFor'`. A config that requests `forwardFingerprint` with no viable carrier at all — passthrough (`terminateTls: false`) without `sourceAddressHeader: 'proxyProtocolV2'`, where there's neither an HTTP request to inject a header into nor a v2 TLV — is also rejected, with a distinct "no carrier" error: no `protocol` declaration could fix a passthrough route's inability to inject a header, so it isn't steered toward one. A route that could have carried the header but silently won't for some connections — `http2: true`, where ALPN negotiation is per-connection and some clients may still land on HTTP/1 — logs a startup warning instead, since that outcome isn't guaranteed.

```typescript
// TLV carrier — works for any upstream that speaks PROXY v2, including passthrough
Expand All @@ -434,6 +459,7 @@ A config that requests `forwardFingerprint` with no viable carrier — passthrou
cert: { certChain, privateKey },
sourceAddressHeader: 'xForwardedFor',
forwardFingerprint: 'ja3', // upstream reads X-JA3 alongside X-Forwarded-For
protocol: 'http',
}
```

Expand Down
68 changes: 68 additions & 0 deletions __test__/fixtures/crash-boundary-repro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Standalone repro spawned as a real child process by suspended.spec.ts's "crash-boundary"
* test — must run out-of-process because it deliberately triggers a throwing `'error'`
* listener via a real resolveConnection() validation failure delivered through the napi
* threadsafe-function callback (not a direct, in-process `proxy.emit('error', ...)` call,
* which never exercises that boundary at all).
*
* Success criterion: stdout contains "CAUGHT:" (uncaughtException fired and was handled)
* and the process exits 0 — a throw reached through the real native callback still surfaces
* as an ordinary, catchable JS exception rather than hanging or killing the process by signal.
*/
import { SymphonyProxy } from '../../ts/proxy.js';
import { generateSelfSignedCert, getFreePort, sleep } from '../util.js';
import * as tls from 'node:tls';

async function main() {
process.on('uncaughtException', (err) => {
console.log(`CAUGHT: ${err.message}`);
process.exit(0);
});

const cert = generateSelfSignedCert('localhost');
const proxyPort = await getFreePort();
const proxy = new SymphonyProxy({
listeners: [{ host: '127.0.0.1', port: proxyPort }],
routes: [
{
sni: 'localhost',
upstreams: [],
terminateTls: true,
cert: { certChain: cert.cert, privateKey: cert.key },
suspended: true,
suspendTimeoutMs: 5000,
},
],
});

// A deliberately buggy consumer 'error' handler — this is exactly the scenario the
// review flagged: if this throws inside the tsfn callback rather than on a deferred
// tick, it crashes uncatchably instead of surfacing here.
proxy.on('error', () => {
throw new Error('deliberately buggy error listener');
});

proxy.on('suspended', (conn) => {
// Undeclared xForwardedFor — rejected by parse_resolve_spec, emits 'error'.
proxy.resolveConnection(conn.id, {
upstream: { kind: 'tcp', host: '127.0.0.1', port: 1 },
terminateTls: true,
sourceAddressHeader: 'xForwardedFor',
});
});

await proxy.start();
await sleep(50);

const socket = tls.connect({ port: proxyPort, host: '127.0.0.1', servername: 'localhost', ca: cert.cert, rejectUnauthorized: false });
socket.on('error', () => {});

// If nothing crashed and nothing was caught within this window, the fix (or the
// deliberately-throwing listener) didn't do what this repro expects — fail loudly
// rather than let the test hang.
await sleep(2000);
console.log('NEVER_THREW');
process.exit(1);
}

main();
2 changes: 2 additions & 0 deletions __test__/h2-dispatch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ describe('SymphonyProxy – h2 upstream config validation', () => {
terminateTls: true,
http2: true,
sourceAddressHeader: 'xForwardedFor',
protocol: 'http',
cert: { certChain: cert.cert, privateKey: cert.key },
},
],
Expand Down Expand Up @@ -236,6 +237,7 @@ describe('SymphonyProxy – h2 upstream config validation', () => {
terminateTls: true,
http2: true,
sourceAddressHeader: 'xForwardedFor',
protocol: 'http',
cert: { certChain: cert.cert, privateKey: cert.key },
},
],
Expand Down
45 changes: 22 additions & 23 deletions __test__/proxy-protocol-v2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,7 @@ import assert from 'node:assert/strict';
import * as tls from 'node:tls';
import { after, before, describe, it } from 'node:test';
import { SymphonyProxy } from '../ts/proxy.js';
import {
generateSelfSignedCert,
getFreePort,
startCaptureServer,
startTlsEchoServer,
tlsRoundTrip,
sleep,
} from './util.js';
import { generateSelfSignedCert, getFreePort, startCaptureServer, tlsRoundTrip, sleep } from './util.js';

const PROXY_V2_SIGNATURE = Buffer.from([0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a]);
const PP2_TYPE_JA3 = 0xe0;
Expand Down Expand Up @@ -150,6 +143,7 @@ describe('PROXY protocol v2 + fingerprint forwarding', () => {
cert: { certChain: cert.cert, privateKey: cert.key },
sourceAddressHeader: 'xForwardedFor',
forwardFingerprint: 'ja3',
protocol: 'http',
},
],
});
Expand All @@ -168,37 +162,38 @@ describe('PROXY protocol v2 + fingerprint forwarding', () => {
await capture.close();
});

// Passthrough forwards raw TLS bytes to a TLS upstream. A header carrier must be a no-op here:
// splicing X-JA3 into the ClientHello ciphertext would break the upstream handshake. A working
// end-to-end round-trip proves nothing was injected.
it('does not inject a fingerprint header in passthrough mode', async () => {
const upstream = await startTlsEchoServer(cert.cert, cert.key);
// Passthrough forwards raw TLS bytes to a TLS upstream — there's no decrypted HTTP request to
// splice a header into, and a header-carried fingerprint mode has no carrier at all here
// regardless of `protocol`. This used to build successfully and silently forward nothing; it
// now rejects the route with a "no carrier" error instead (see route-protocol.spec.ts for
// focused coverage of the rejection itself), so a passthrough + header-carried forwardFingerprint
// config can no longer look "working" while quietly forwarding no fingerprint. Isolated per-route
// (build_route, router.rs) rather than failing the whole construction — a bad route's SNI simply
// resolves to nothing, same as a bad cert.
it('rejects a header-carried fingerprint on a passthrough route by dropping it (no carrier, not a silent no-op)', async () => {
const proxyPort = await getFreePort();
const proxy = new SymphonyProxy({
listeners: [{ host: '127.0.0.1', port: proxyPort }],
routes: [
{
sni: 'localhost',
upstreams: [{ kind: 'tcp', host: '127.0.0.1', port: upstream.port }],
upstreams: [{ kind: 'tcp', host: '127.0.0.1', port: 1 }],
terminateTls: false,
forwardFingerprint: 'ja3',
protocol: 'http',
},
],
});
await proxy.start();
await sleep(50);

const payload = Buffer.from('passthrough-ok');
const response = await tlsRoundTrip({
port: proxyPort,
servername: 'localhost',
caCert: cert.cert,
data: payload,
});
assert.deepEqual(response, payload, 'end-to-end TLS round-trip intact (no injected header)');
await assert.rejects(
tlsRoundTrip({ port: proxyPort, servername: 'localhost', caCert: cert.cert, data: 'x' }),
/timeout|ECONNRESET|EPROTO|socket hang up|closed/i,
'passthrough + header-carried forwardFingerprint has no carrier — the route must be dropped, not silently built to forward no fingerprint'
);

await proxy.stop();
await upstream.close();
});

// Finding 1 (Critical — Slowloris): a client that completes the TLS handshake and then stalls
Expand All @@ -217,6 +212,7 @@ describe('PROXY protocol v2 + fingerprint forwarding', () => {
cert: { certChain: cert.cert, privateKey: cert.key },
sourceAddressHeader: 'xForwardedFor',
forwardFingerprint: 'ja3',
protocol: 'http',
},
],
});
Expand Down Expand Up @@ -261,6 +257,7 @@ describe('PROXY protocol v2 + fingerprint forwarding', () => {
cert: { certChain: cert.cert, privateKey: cert.key },
sourceAddressHeader: 'xForwardedFor',
forwardFingerprint: 'ja3',
protocol: 'http',
},
],
});
Expand Down Expand Up @@ -299,6 +296,7 @@ describe('PROXY protocol v2 + fingerprint forwarding', () => {
cert: { certChain: cert.cert, privateKey: cert.key },
sourceAddressHeader: 'xForwardedFor',
forwardFingerprint: 'ja3',
protocol: 'http',
},
],
});
Expand Down Expand Up @@ -339,6 +337,7 @@ describe('PROXY protocol v2 + fingerprint forwarding', () => {
http2: true,
sourceAddressHeader: 'none',
forwardFingerprint: 'ja3',
protocol: 'http',
},
],
});
Expand Down
Loading
Loading