@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/audit.d.ts +4 -0
- package/dist/audit.js +116 -10
- package/dist/dns-handler.d.ts +9 -0
- package/dist/dns-handler.js +307 -35
- package/dist/dns-service.d.ts +0 -1
- package/dist/dns-service.js +8 -17
- package/dist/doh-dot.test.d.ts +1 -0
- package/dist/doh-dot.test.js +101 -0
- package/dist/firewall.d.ts +2 -0
- package/dist/firewall.js +67 -8
- package/dist/firewall.test.d.ts +1 -0
- package/dist/firewall.test.js +165 -0
- package/dist/http-body-forwarding-integration.test.js +15 -0
- package/dist/http-handler.d.ts +3 -1
- package/dist/http-handler.js +174 -134
- package/dist/http-proxy.d.ts +1 -2
- package/dist/http-proxy.js +5 -7
- package/dist/rate-limiter.d.ts +5 -0
- package/dist/rate-limiter.js +30 -11
- package/dist/schema.js +15 -6
- package/dist/sni-proxy.d.ts +1 -0
- package/dist/sni-proxy.js +66 -16
- package/dist/sni-proxy.test.d.ts +1 -0
- package/dist/sni-proxy.test.js +69 -0
- package/dist/types.d.ts +10 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
|
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",
|
|
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,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
clientSocket.pipe(
|
|
97
|
-
|
|
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(
|
|
100
|
-
|
|
101
|
-
|
|
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,
|
|
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
|
-
|
|
132
|
-
|
|
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
|
+
"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
|
+
}
|