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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The server also **watches the cert/key files referenced by the config** (grouped

```
TCP accept (SO_REUSEPORT per worker thread)
└─ sni.rs peek() — 1 syscall, 512-byte stack buf → PeekInfo { sni, ja3 }
└─ sni.rs peek() — 1 syscall, 512-byte stack buf → PeekInfo { sni, ja3, ja4 }
└─ protection.rs check() → Block (emit 'blocked', drop) | Allow
└─ router.rs RouteTable.resolve(sni) → Route
└─ [suspended.rs register, emit 'suspended', await oneshot]
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ libc = "0.2"
socket2 = { version = "0.5", features = ["all"] }
ring = "0.17"
md-5 = "0.10"
sha2 = "0.10"
zeroize = "1"

[build-dependencies]
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ Both fields accept PEM-encoded strings or `Buffer`. The cert chain may include i
| `allowlist` | `string[]` | `[]` | CIDRs that bypass all checks |
| `blocklist` | `string[]` | `[]` | CIDRs that are always blocked |
| `ja3Blocklist` | `string[]` | `[]` | JA3 MD5 hex fingerprints to block (32 chars each) |
| `ja4Blocklist` | `string[]` | `[]` | JA4 TLS fingerprints to block (36-char strings; see [JA4 blocking](#ja4-blocking)) |
| `tlsHandshakeTimeoutMs` | `number` | `10000` | Abort slow TLS handshakes |
| `requireSni` | `boolean` | `false` | Reject connections without an SNI extension |

Expand Down Expand Up @@ -409,14 +410,34 @@ protection: {

### JA3 blocking

Collect JA3 fingerprints from your logs (the `ja3` field is available in future log integrations) and add known-bad clients:
JA3 fingerprints the TLS ClientHello by hashing a canonical string of the version, cipher suites, extensions, elliptic curves, and EC point formats using MD5. That MD5 is by specification, not a security claim — it allows lists of known-bad fingerprints to be compared cheaply. Collect JA3 fingerprints from your logs (available in the `blocked` event `ja3` field) and add known-bad clients:

```typescript
ja3Blocklist: [
'e7d705a3286e19ea42f587b344ee6865', // example known-bad scanner
]
```

**Limitation:** Chrome and other modern browsers randomize the order of ClientHello extensions on each connection, so a single browser can produce many different JA3 hashes. This makes per-browser JA3 blocking unreliable. Use `ja4Blocklist` where extension-order randomization is a concern.

**Upgrade note:** earlier versions filtered only one of the 16 GREASE values when computing JA3, so hashes for clients that send other GREASE values (e.g. Chrome) were nonstandard and unstable. JA3 values collected from earlier symphony versions may no longer match and should be re-collected.

### JA4 blocking

JA4 is the randomization-resistant successor to JA3. It sorts the cipher and extension lists before hashing, so it produces a stable fingerprint regardless of ClientHello field ordering. Fingerprints are 36-char lowercase ASCII strings in the form `t<ver><sni><cc><ec><alpn>_<sha256/12>_<sha256/12>`.

Collect JA4 values from the `blocked` event `ja4` field and configure them:

```typescript
ja4Blocklist: [
't13d1516h2_8daaf6152771_b186095e22b6', // example Chrome fingerprint
]
```

Matching is case-insensitive. JA4 fingerprints are always emitted as lowercase.

**License scope:** Symphony implements **core JA4** (TLS client fingerprinting) only. Core JA4 is BSD-licensed. The JA4+ suite of variants (JA4S, JA4H, JA4SSH, etc.) carries a separate FoxIO proprietary license and is **not implemented** here.

### Hot-swapping protection config

Protection config is per-listener and not currently hot-swappable via `updateConfig` (listeners would need to restart). To update protection, restart with a new config. Route changes do not require listener restarts.
Expand Down
117 changes: 116 additions & 1 deletion __test__/protection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Tests for the protection layer: rate limiting, blockedIps().
* Tests for the protection layer: rate limiting, blockedIps(), JA4 blocking.
*/

import assert from 'node:assert/strict';
import * as net from 'node:net';
import * as tls from 'node:tls';
import { after, before, describe, it } from 'node:test';
import { SymphonyProxy } from '../ts/proxy.js';
import { generateSelfSignedCert, getFreePort, startEchoServer, sleep } from './util.js';
Expand Down Expand Up @@ -125,3 +126,117 @@ describe('Protection – CIDR blocklist', () => {
assert.ok(Array.isArray(info.concurrencyLimited));
});
});

describe('Protection – JA4 blocklist', () => {
const cert = generateSelfSignedCert('localhost');
let echoPort: number;
let echoServer: net.Server;

before(async () => {
// Plain TCP echo: the proxy runs in passthrough mode so the upstream
// never needs to speak TLS — only the peek path (ClientHello) matters.
await new Promise<void>((resolve) => {
echoServer = net.createServer((s) => s.pipe(s));
echoServer.listen(0, '127.0.0.1', () => {
echoPort = (echoServer.address() as net.AddressInfo).port;
resolve();
});
});
});

after(async () => {
await new Promise<void>((res) => echoServer.close(() => res()));
});

it('blocks TLS connections matching the ja4Blocklist', async () => {
// Phase 1: discover the Node.js TLS client JA4 by using a CIDR-blocked proxy
// that emits a 'blocked' event including the ja4 field.
const probePort = await getFreePort();
let discoveredJa4 = '';

const probeProxy = new SymphonyProxy({
listeners: [{
host: '127.0.0.1',
port: probePort,
protection: { blocklist: ['127.0.0.1/32'] },
}],
routes: [{
sni: 'localhost',
upstreams: [{ kind: 'tcp', host: '127.0.0.1', port: echoPort }],
terminateTls: false,
}],
});

probeProxy.on('blocked', (e: { ja4: string }) => {
if (e.ja4) discoveredJa4 = e.ja4;
});

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

// TLS connect → proxy peeks ClientHello → CIDR-blocked (peek still runs) → RST.
await new Promise<void>((resolve) => {
const s = tls.connect(
{ port: probePort, host: '127.0.0.1', servername: 'localhost', rejectUnauthorized: false },
() => { s.end(); },
);
s.on('error', () => resolve());
s.on('close', () => resolve());
setTimeout(resolve, 3000);
});

await sleep(200);
await probeProxy.stop();

assert.ok(
discoveredJa4.length > 0,
'should have captured a JA4 fingerprint from the blocked event',
);
// Validate JA4 format: t<2 digits><d|i><2 digits><2 digits><2 chars>_<12hex>_<12hex>
assert.match(
discoveredJa4,
/^t[0-9]{2}[di][0-9]{4}[a-z0-9]{2}_[0-9a-f]{12}_[0-9a-f]{12}$/,
`JA4 format unexpected: ${discoveredJa4}`,
);

// Phase 2: configure a new proxy with the observed JA4 in the blocklist and
// verify the TLS connection is blocked with reason 'ja4_blocked'.
const testPort = await getFreePort();
let blockedReason = '';

const testProxy = new SymphonyProxy({
listeners: [{
host: '127.0.0.1',
port: testPort,
protection: { ja4Blocklist: [discoveredJa4] },
}],
routes: [{
sni: 'localhost',
upstreams: [{ kind: 'tcp', host: '127.0.0.1', port: echoPort }],
terminateTls: false,
}],
});

testProxy.on('blocked', (e: { reason: string }) => {
blockedReason = e.reason;
});

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

await new Promise<void>((resolve) => {
const s = tls.connect(
{ port: testPort, host: '127.0.0.1', servername: 'localhost', rejectUnauthorized: false },
() => { s.end(); },
);
s.on('error', () => resolve());
s.on('close', () => resolve());
setTimeout(resolve, 3000);
});

await sleep(200);
await testProxy.stop();

assert.equal(blockedReason, 'ja4_blocked', `Expected ja4_blocked, got: ${blockedReason}`);
});
});
12 changes: 12 additions & 0 deletions src/protection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub struct ProtectionConfig {
pub max_concurrent_per_ip: u32,
/// JA3 fingerprints to block (raw 16-byte MD5)
pub ja3_blocklist: HashSet<[u8; 16]>,
/// JA4 fingerprints to block. Values are stored lowercase; comparison is case-insensitive.
/// JA4 strings are 36-char lowercase ASCII: t<ver><sni><cc><ec><alpn>_<12hex>_<12hex>.
pub ja4_blocklist: HashSet<Box<str>>,
/// Max ms for TLS handshake (0 = use default 10000)
pub tls_handshake_timeout_ms: u64,
/// Reject connections without SNI
Expand Down Expand Up @@ -84,6 +87,7 @@ impl IpState {
pub enum BlockReason {
CidrBlocked,
Ja3Blocked,
Ja4Blocked,
NoSni,
RateLimited,
TooManyConnections,
Expand All @@ -94,6 +98,7 @@ impl BlockReason {
match self {
Self::CidrBlocked => "cidr_blocked",
Self::Ja3Blocked => "ja3_blocked",
Self::Ja4Blocked => "ja4_blocked",
Self::NoSni => "no_sni",
Self::RateLimited => "rate_limited",
Self::TooManyConnections => "too_many_connections",
Expand Down Expand Up @@ -161,6 +166,13 @@ impl ProtectionState {
}
}

// 3b. JA4 blocklist
if !cfg.ja4_blocklist.is_empty() && !peek_info.ja4.is_empty() {
if cfg.ja4_blocklist.contains(peek_info.ja4.as_str()) {
return Decision::Block(BlockReason::Ja4Blocked);
}
}

// 4. Require SNI
if cfg.require_sni && peek_info.sni.is_none() {
return Decision::Block(BlockReason::NoSni);
Expand Down
14 changes: 13 additions & 1 deletion src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ pub struct JsProtectionConfig {
pub allowlist: Option<Vec<String>>,
pub blocklist: Option<Vec<String>>,
pub ja3_blocklist: Option<Vec<String>>,
/// JA4 fingerprints to block. Each value is the full 36-char JA4 string
/// (t<ver><sni><cc><ec><alpn>_<12hex>_<12hex>); case-insensitive match.
pub ja4_blocklist: Option<Vec<String>>,
pub tls_handshake_timeout_ms: Option<f64>,
pub require_sni: Option<bool>,
}
Expand Down Expand Up @@ -282,11 +285,13 @@ impl SymphonyProxyWrap {
let env = ctx.env;
let mut obj = env.create_object()?;
match event {
JsEvent::Blocked { ip, reason, listener } => {
JsEvent::Blocked { ip, reason, listener, ja3, ja4 } => {
obj.set("type", "blocked")?;
obj.set("ip", ip)?;
obj.set("reason", reason)?;
obj.set("listener", listener)?;
obj.set("ja3", ja3)?;
obj.set("ja4", ja4)?;
}
JsEvent::Suspended { id, sni, peer_ip, peer_port, listener } => {
obj.set("type", "suspended")?;
Expand Down Expand Up @@ -610,6 +615,13 @@ fn parse_protection_config(
}
}

if let Some(ja4s) = &prot.ja4_blocklist {
for s in ja4s {
// Normalize to lowercase for case-insensitive matching.
cfg.ja4_blocklist.insert(s.to_lowercase().into_boxed_str());
}
}

cfg.tls_handshake_timeout_ms = prot.tls_handshake_timeout_ms.unwrap_or(0.0) as u64;
cfg.require_sni = prot.require_sni.unwrap_or(false);

Expand Down
6 changes: 6 additions & 0 deletions src/proxy_conn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ pub enum JsEvent {
ip: String,
reason: String,
listener: String,
/// JA3 fingerprint (32-char hex) if parsed; empty string otherwise.
ja3: String,
/// JA4 fingerprint if parsed; empty string otherwise.
ja4: String,
},
Suspended {
id: String,
Expand Down Expand Up @@ -68,6 +72,8 @@ pub async fn handle(stream: TcpStream, peer_addr: SocketAddr, ctx: Arc<ConnConte
ip: peer_ip.to_string(),
reason: reason.as_str().to_string(),
listener: ctx.listener_addr.clone(),
ja3: peek_info.ja3.clone(),
ja4: peek_info.ja4.clone(),
});
return;
}
Expand Down
Loading
Loading