@opensecurity/zonzon-core 0.1.4 → 0.1.6

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();
11
+ idleTimeoutMs;
10
12
  constructor(config, port) {
11
13
  this.config = config;
12
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,6 +100,7 @@ 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
106
  audit.http(clientIp, "TLS", "UNKNOWN", `:${this.port}`, 400, "Dropped: No SNI detected");
@@ -87,18 +117,28 @@ 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
126
  audit.http(clientIp, "TLS-SNI", sni, `:${this.port}`, 200, `Tunneled to ${targetIp}`);
94
- const srvSocket = net.connect(443, targetIp, () => {
95
- srvSocket.write(buffer);
96
- clientSocket.pipe(srvSocket);
97
- srvSocket.pipe(clientSocket);
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();
@@ -111,6 +151,16 @@ export class SniProxyService {
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
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it } from "node:test";
2
- import assert from "assert";
2
+ import assert from "node:assert";
3
3
  import { DevDnsServer } from "./dns-service.js";
4
4
  import { DNS_TYPES, DNS_RCODE } from "./types.js";
5
5
  function buildQuery(name, type) {
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "node:test";
2
- import assert from "assert";
3
- import * as net from "net";
2
+ import assert from "node:assert";
3
+ import * as net from "node:net";
4
4
  let PORT_BASE = 61500;
5
5
  function nextPort() {
6
6
  return PORT_BASE++;
package/dist/types.d.ts CHANGED
@@ -64,12 +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;
71
76
  httpPort?: number;
72
77
  httpsPort?: number;
78
+ tls?: TlsConfig;
79
+ dotPort?: number;
80
+ dohPort?: number;
73
81
  fallbackDns?: string;
74
82
  firewall?: FirewallConfig;
75
83
  controlPlane?: ControlPlaneConfig;
@@ -1,5 +1,5 @@
1
1
  import { describe, it } from "node:test";
2
- import assert from "assert";
2
+ import assert from "node:assert";
3
3
  import { DevDnsServer } from "./dns-service.js";
4
4
  import { DNS_TYPES } from "./types.js";
5
5
  function buildQuery(name, type) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensecurity/zonzon-core",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
+ }