@opensecurity/zonzon-core 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sni-proxy.js CHANGED
@@ -2,14 +2,17 @@ import * as net from "net";
2
2
  import * as dns from "dns/promises";
3
3
  import { firewallEngine } from "./firewall.js";
4
4
  import { audit } from "./audit.js";
5
+ const MAX_CLIENT_HELLO_SIZE = 16384;
5
6
  export class SniProxyService {
6
7
  port;
7
8
  config;
8
9
  server = null;
9
10
  activeConnections = new Set();
10
- constructor(config, port = 443) {
11
+ idleTimeoutMs;
12
+ constructor(config, port) {
11
13
  this.config = config;
12
- this.port = port;
14
+ this.port = config.httpsPort ?? port ?? 443;
15
+ this.idleTimeoutMs = config.tcpIdleTimeoutMs ?? 30000;
13
16
  }
14
17
  extractSNI(data) {
15
18
  try {
@@ -23,13 +26,15 @@ export class SniProxyService {
23
26
  offset += 1 + sessionIdLength;
24
27
  if (offset >= data.length)
25
28
  return null;
29
+ if (offset + 2 > data.length)
30
+ return null;
26
31
  const cipherSuitesLength = data.readUInt16BE(offset);
27
32
  offset += 2 + cipherSuitesLength;
28
33
  if (offset >= data.length)
29
34
  return null;
30
35
  const compressionMethodsLength = data[offset];
31
36
  offset += 1 + compressionMethodsLength;
32
- if (offset >= data.length)
37
+ if (offset + 2 > data.length)
33
38
  return null;
34
39
  const extensionsLength = data.readUInt16BE(offset);
35
40
  offset += 2;
@@ -41,11 +46,17 @@ export class SniProxyService {
41
46
  if (extType === 0x0000) {
42
47
  let sniOffset = offset;
43
48
  sniOffset += 2;
49
+ if (sniOffset >= data.length)
50
+ return null;
44
51
  const nameType = data[sniOffset];
45
52
  if (nameType === 0) {
46
53
  sniOffset += 1;
54
+ if (sniOffset + 2 > data.length)
55
+ return null;
47
56
  const nameLength = data.readUInt16BE(sniOffset);
48
57
  sniOffset += 2;
58
+ if (sniOffset + nameLength > data.length)
59
+ return null;
49
60
  return data.toString("utf8", sniOffset, sniOffset + nameLength);
50
61
  }
51
62
  }
@@ -61,9 +72,27 @@ export class SniProxyService {
61
72
  const clientIp = clientSocket.remoteAddress || "unknown";
62
73
  let buffer = Buffer.alloc(0);
63
74
  let isHandled = false;
75
+ let upstreamSocket = null;
76
+ clientSocket.setTimeout(this.idleTimeoutMs);
77
+ clientSocket.on("timeout", () => {
78
+ audit.error(`SNI Client tunnel idle timeout reached for ${clientIp}`);
79
+ clientSocket.destroy();
80
+ });
81
+ const absoluteHandshakeTimeout = setTimeout(() => {
82
+ if (!isHandled && !clientSocket.destroyed) {
83
+ audit.http(clientIp, "TLS", "UNKNOWN", `:${this.port}`, 408, "Dropped: ClientHello absolute timeout (Slowloris)");
84
+ clientSocket.destroy();
85
+ }
86
+ }, 5000);
64
87
  clientSocket.on("data", async (chunk) => {
65
88
  if (isHandled)
66
89
  return;
90
+ if (buffer.length + chunk.length > MAX_CLIENT_HELLO_SIZE) {
91
+ audit.http(clientIp, "TLS", "UNKNOWN", `:${this.port}`, 413, "Dropped: ClientHello exceeded maximum permitted size");
92
+ clientSocket.destroy();
93
+ clearTimeout(absoluteHandshakeTimeout);
94
+ return;
95
+ }
67
96
  buffer = Buffer.concat([buffer, chunk]);
68
97
  if (buffer.length < 5)
69
98
  return;
@@ -71,9 +100,10 @@ export class SniProxyService {
71
100
  if (buffer.length < 5 + recordLength)
72
101
  return;
73
102
  isHandled = true;
103
+ clearTimeout(absoluteHandshakeTimeout);
74
104
  const sni = this.extractSNI(buffer);
75
105
  if (!sni) {
76
- audit.http(clientIp, "TLS", "UNKNOWN", ":443", 400, "Dropped: No SNI detected");
106
+ audit.http(clientIp, "TLS", "UNKNOWN", `:${this.port}`, 400, "Dropped: No SNI detected");
77
107
  clientSocket.destroy();
78
108
  return;
79
109
  }
@@ -87,30 +117,50 @@ export class SniProxyService {
87
117
  throw new Error("NXDOMAIN on upstream resolution");
88
118
  }
89
119
  const targetIp = targetIps[0];
120
+ if (firewallEngine.isRestrictedOutbound(targetIp)) {
121
+ throw new Error(`Target IP ${targetIp} blocked by Strict SSRF proxy policy`);
122
+ }
90
123
  if (firewallEngine.evaluateIp(targetIp, this.config.firewall) === "DENY") {
91
124
  throw new Error(`Target IP ${targetIp} blocked by Firewall policy`);
92
125
  }
93
- audit.http(clientIp, "TLS-SNI", sni, ":443", 200, `Tunneled to ${targetIp}`);
94
- const srvSocket = net.connect(443, targetIp, () => {
95
- srvSocket.write(buffer);
96
- clientSocket.pipe(srvSocket);
97
- srvSocket.pipe(clientSocket);
126
+ audit.http(clientIp, "TLS-SNI", sni, `:${this.port}`, 200, `Tunneled to ${targetIp}`);
127
+ upstreamSocket = net.connect(443, targetIp, () => {
128
+ upstreamSocket.write(buffer);
129
+ clientSocket.pipe(upstreamSocket);
130
+ upstreamSocket.pipe(clientSocket);
131
+ });
132
+ upstreamSocket.setTimeout(this.idleTimeoutMs);
133
+ upstreamSocket.on("timeout", () => {
134
+ audit.error(`SNI Upstream tunnel idle timeout reached for ${sni}:${targetIp}`);
135
+ upstreamSocket.destroy();
98
136
  });
99
- this.activeConnections.add(srvSocket);
100
- srvSocket.on("close", () => this.activeConnections.delete(srvSocket));
101
- srvSocket.on("error", (err) => {
137
+ this.activeConnections.add(upstreamSocket);
138
+ upstreamSocket.on("close", () => {
139
+ this.activeConnections.delete(upstreamSocket);
140
+ });
141
+ upstreamSocket.on("error", (err) => {
102
142
  audit.error(`Upstream tunnel fault on ${sni}:443 - ${err.message}`);
103
143
  if (!clientSocket.destroyed)
104
144
  clientSocket.destroy();
105
145
  });
106
146
  }
107
147
  catch (err) {
108
- audit.http(clientIp, "TLS-SNI", sni, ":443", 403, `Blocked: ${err.message}`);
148
+ audit.http(clientIp, "TLS-SNI", sni, `:${this.port}`, 403, `Blocked: ${err.message}`);
109
149
  clientSocket.destroy();
110
150
  }
111
151
  });
112
152
  clientSocket.on("error", (err) => {
113
153
  audit.error(`Client tunnel fault from ${clientIp} - ${err.message}`);
154
+ if (upstreamSocket && !upstreamSocket.destroyed) {
155
+ upstreamSocket.destroy();
156
+ }
157
+ clearTimeout(absoluteHandshakeTimeout);
158
+ });
159
+ clientSocket.on("close", () => {
160
+ if (upstreamSocket && !upstreamSocket.destroyed) {
161
+ upstreamSocket.destroy();
162
+ }
163
+ clearTimeout(absoluteHandshakeTimeout);
114
164
  });
115
165
  }
116
166
  start() {
@@ -128,13 +178,13 @@ export class SniProxyService {
128
178
  }
129
179
  async stop() {
130
180
  if (this.server) {
131
- for (const socket of this.activeConnections) {
132
- socket.destroy();
181
+ if ('closeIdleConnections' in this.server) {
182
+ this.server.closeIdleConnections();
133
183
  }
134
- this.activeConnections.clear();
135
184
  await new Promise((resolve) => {
136
185
  this.server.close(() => resolve());
137
186
  });
187
+ this.activeConnections.clear();
138
188
  this.server = null;
139
189
  }
140
190
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { SniProxyService } from "./sni-proxy.js";
4
+ function buildClientHello(sni) {
5
+ const sniBuffer = Buffer.from(sni, "utf8");
6
+ const extLength = sniBuffer.length + 9;
7
+ const buf = Buffer.alloc(100 + extLength);
8
+ buf[0] = 0x16;
9
+ buf[5] = 0x01;
10
+ buf[43] = 0x00;
11
+ buf.writeUInt16BE(0x0002, 44);
12
+ buf[46] = 0x00;
13
+ buf[47] = 0x00;
14
+ buf[48] = 0x01;
15
+ buf[49] = 0x00;
16
+ buf.writeUInt16BE(extLength, 50);
17
+ buf.writeUInt16BE(0x0000, 52);
18
+ buf.writeUInt16BE(sniBuffer.length + 5, 54);
19
+ buf.writeUInt16BE(sniBuffer.length + 3, 56);
20
+ buf[58] = 0x00;
21
+ buf.writeUInt16BE(sniBuffer.length, 59);
22
+ sniBuffer.copy(buf, 61);
23
+ return buf;
24
+ }
25
+ describe("SniProxyService - Protocol Extraction", () => {
26
+ const dummyConfig = {
27
+ port: 53,
28
+ hosts: {}
29
+ };
30
+ it("extracts SNI from valid TLS ClientHello structure", () => {
31
+ const service = new SniProxyService(dummyConfig);
32
+ const packet = buildClientHello("secure.internal.loop");
33
+ const extracted = service.extractSNI(packet);
34
+ assert.strictEqual(extracted, "secure.internal.loop");
35
+ });
36
+ it("returns null for non-TLS packets", () => {
37
+ const service = new SniProxyService(dummyConfig);
38
+ const packet = Buffer.from("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n");
39
+ const extracted = service.extractSNI(packet);
40
+ assert.strictEqual(extracted, null);
41
+ });
42
+ it("returns null for TLS packets without ServerName extension", () => {
43
+ const service = new SniProxyService(dummyConfig);
44
+ const buf = Buffer.alloc(100);
45
+ buf[0] = 0x16;
46
+ buf[5] = 0x01;
47
+ buf[43] = 0x00;
48
+ buf.writeUInt16BE(0x0002, 44);
49
+ buf[48] = 0x01;
50
+ buf.writeUInt16BE(0x0004, 50);
51
+ buf.writeUInt16BE(0x000A, 52);
52
+ buf.writeUInt16BE(0x0000, 54);
53
+ const extracted = service.extractSNI(buf);
54
+ assert.strictEqual(extracted, null);
55
+ });
56
+ it("gracefully handles truncated packets without throwing", () => {
57
+ const service = new SniProxyService(dummyConfig);
58
+ const packet = buildClientHello("secure.internal.loop").subarray(0, 50);
59
+ const extracted = service.extractSNI(packet);
60
+ assert.strictEqual(extracted, null);
61
+ });
62
+ it("gracefully handles corrupt length markers without throwing", () => {
63
+ const service = new SniProxyService(dummyConfig);
64
+ const packet = buildClientHello("secure.internal.loop");
65
+ packet.writeUInt16BE(0xFFFF, 44);
66
+ const extracted = service.extractSNI(packet);
67
+ assert.strictEqual(extracted, null);
68
+ });
69
+ });
package/dist/types.d.ts CHANGED
@@ -64,10 +64,20 @@ export interface FirewallConfig {
64
64
  export interface ControlPlaneConfig {
65
65
  enabled?: boolean;
66
66
  port?: number;
67
+ socketPath?: string;
67
68
  apiKey?: string;
68
69
  }
70
+ export interface TlsConfig {
71
+ cert: string;
72
+ key: string;
73
+ }
69
74
  export interface ServerConfig {
70
75
  port: number;
76
+ httpPort?: number;
77
+ httpsPort?: number;
78
+ tls?: TlsConfig;
79
+ dotPort?: number;
80
+ dohPort?: number;
71
81
  fallbackDns?: string;
72
82
  firewall?: FirewallConfig;
73
83
  controlPlane?: ControlPlaneConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensecurity/zonzon-core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "core routing, dns, and firewall engine",
5
5
  "type": "module",
6
6
  "author": "Lucian BLETAN <neuraluc@gmail.com>",
@@ -37,4 +37,4 @@
37
37
  "dependencies": {
38
38
  "zod": "^3.24.2"
39
39
  }
40
- }
40
+ }