@opensecurity/zonzon-core 0.1.4 → 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 +172 -132
- 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 +13 -6
- package/dist/sni-proxy.d.ts +1 -0
- package/dist/sni-proxy.js +61 -11
- package/dist/sni-proxy.test.d.ts +1 -0
- package/dist/sni-proxy.test.js +69 -0
- package/dist/types.d.ts +8 -0
- package/package.json +2 -2
package/dist/http-handler.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as http from "http";
|
|
2
|
+
import * as https from "https";
|
|
2
3
|
import * as net from "net";
|
|
3
4
|
import * as dns from "dns/promises";
|
|
4
5
|
import { HttpProxyService } from "./http-proxy.js";
|
|
@@ -58,14 +59,22 @@ export class HttpHandler {
|
|
|
58
59
|
server = null;
|
|
59
60
|
circuitBreakers = new Map();
|
|
60
61
|
activeConnections = new Set();
|
|
62
|
+
idleTimeoutMs;
|
|
61
63
|
constructor(dnsServer, config, port) {
|
|
62
64
|
this.dnsServer = dnsServer;
|
|
63
65
|
this.proxyService = new HttpProxyService();
|
|
64
66
|
this.config = config;
|
|
65
67
|
this.port = config.httpPort ?? port ?? 80;
|
|
68
|
+
this.idleTimeoutMs = config.tcpIdleTimeoutMs ?? 30000;
|
|
66
69
|
}
|
|
67
70
|
getCircuitBreaker(upstream) {
|
|
68
71
|
if (!this.circuitBreakers.has(upstream)) {
|
|
72
|
+
if (this.circuitBreakers.size >= 10000) {
|
|
73
|
+
const firstKey = this.circuitBreakers.keys().next().value;
|
|
74
|
+
if (firstKey !== undefined) {
|
|
75
|
+
this.circuitBreakers.delete(firstKey);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
69
78
|
this.circuitBreakers.set(upstream, new ProxyCircuitBreaker());
|
|
70
79
|
}
|
|
71
80
|
return this.circuitBreakers.get(upstream);
|
|
@@ -82,25 +91,21 @@ export class HttpHandler {
|
|
|
82
91
|
}
|
|
83
92
|
return undefined;
|
|
84
93
|
}
|
|
85
|
-
injectCustomHeaders(responseHeaders, hostConfig) {
|
|
86
|
-
if (!hostConfig.http_proxy?.enabled)
|
|
87
|
-
return;
|
|
88
|
-
for (const [key, value] of Object.entries(hostConfig.http_proxy.headers)) {
|
|
89
|
-
const sanitized = this.proxyService.sanitizeHeader(value);
|
|
90
|
-
if (sanitized) {
|
|
91
|
-
responseHeaders.set(key, sanitized);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
94
|
async handleConnect(req, clientSocket, head) {
|
|
96
95
|
const clientIp = req.socket.remoteAddress || "unknown";
|
|
97
96
|
const targetUrl = req.url || "";
|
|
98
97
|
const [hostname, portStr] = targetUrl.split(':');
|
|
99
98
|
const port = portStr ? parseInt(portStr, 10) : 443;
|
|
100
99
|
try {
|
|
101
|
-
await this.proxyService.validateTargetFirewall(`https://${hostname}:${port}`, this.config.firewall);
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
const validatedIps = await this.proxyService.validateTargetFirewall(`https://${hostname}:${port}`, this.config.firewall);
|
|
101
|
+
const targetIp = validatedIps[0];
|
|
102
|
+
audit.http(clientIp, "CONNECT", hostname, `:${port}`, 200, `TCP Tunnel Established -> ${targetIp}`);
|
|
103
|
+
clientSocket.setTimeout(this.idleTimeoutMs);
|
|
104
|
+
clientSocket.on("timeout", () => {
|
|
105
|
+
audit.error(`CONNECT Client tunnel idle timeout reached for ${clientIp}`);
|
|
106
|
+
clientSocket.destroy();
|
|
107
|
+
});
|
|
108
|
+
const srvSocket = net.connect(port, targetIp, () => {
|
|
104
109
|
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
105
110
|
if (head && head.length > 0) {
|
|
106
111
|
srvSocket.write(head);
|
|
@@ -108,6 +113,11 @@ export class HttpHandler {
|
|
|
108
113
|
srvSocket.pipe(clientSocket);
|
|
109
114
|
clientSocket.pipe(srvSocket);
|
|
110
115
|
});
|
|
116
|
+
srvSocket.setTimeout(this.idleTimeoutMs);
|
|
117
|
+
srvSocket.on("timeout", () => {
|
|
118
|
+
audit.error(`CONNECT Upstream tunnel idle timeout reached for ${hostname}:${port}`);
|
|
119
|
+
srvSocket.destroy();
|
|
120
|
+
});
|
|
111
121
|
this.activeConnections.add(srvSocket);
|
|
112
122
|
srvSocket.on('close', () => this.activeConnections.delete(srvSocket));
|
|
113
123
|
srvSocket.on('error', (err) => {
|
|
@@ -128,14 +138,86 @@ export class HttpHandler {
|
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
}
|
|
141
|
+
async doHttpProxy(targetUrl, targetIp, hostname, reqMethod, reqHeaders, bodyBuffer, clientIp, res, breaker, auditUrl, customReqHeaders = {}, customResHeaders = {}) {
|
|
142
|
+
const hopByHop = this.proxyService.getHopByHopHeaders();
|
|
143
|
+
const outReqHeaders = {};
|
|
144
|
+
for (const [k, v] of Object.entries(reqHeaders)) {
|
|
145
|
+
if (!hopByHop.includes(k.toLowerCase()) && v !== undefined) {
|
|
146
|
+
outReqHeaders[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
outReqHeaders["Host"] = hostname;
|
|
150
|
+
if (bodyBuffer) {
|
|
151
|
+
outReqHeaders["content-length"] = String(bodyBuffer.length);
|
|
152
|
+
}
|
|
153
|
+
else if (reqMethod !== "GET" && reqMethod !== "HEAD") {
|
|
154
|
+
outReqHeaders["content-length"] = "0";
|
|
155
|
+
}
|
|
156
|
+
for (const [k, v] of Object.entries(customReqHeaders)) {
|
|
157
|
+
outReqHeaders[k] = v;
|
|
158
|
+
}
|
|
159
|
+
const reqOptions = {
|
|
160
|
+
hostname: targetIp,
|
|
161
|
+
port: targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80),
|
|
162
|
+
path: targetUrl.pathname + targetUrl.search,
|
|
163
|
+
method: reqMethod,
|
|
164
|
+
headers: outReqHeaders,
|
|
165
|
+
timeout: this.idleTimeoutMs,
|
|
166
|
+
servername: targetUrl.protocol === "https:" ? hostname : undefined
|
|
167
|
+
};
|
|
168
|
+
const requestModule = targetUrl.protocol === "https:" ? https : http;
|
|
169
|
+
const proxyResp = await breaker.execute(() => new Promise((resolve, reject) => {
|
|
170
|
+
const proxyReq = requestModule.request(reqOptions, resolve);
|
|
171
|
+
proxyReq.on("error", reject);
|
|
172
|
+
proxyReq.on("timeout", () => { proxyReq.destroy(); reject(new Error("TimeoutError")); });
|
|
173
|
+
if (bodyBuffer) {
|
|
174
|
+
proxyReq.write(bodyBuffer);
|
|
175
|
+
}
|
|
176
|
+
proxyReq.end();
|
|
177
|
+
}));
|
|
178
|
+
const outResHeaders = {};
|
|
179
|
+
for (const [k, v] of Object.entries(proxyResp.headers)) {
|
|
180
|
+
if (!hopByHop.includes(k.toLowerCase()) && v !== undefined) {
|
|
181
|
+
outResHeaders[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const [k, v] of Object.entries(customResHeaders)) {
|
|
185
|
+
outResHeaders[k] = v;
|
|
186
|
+
}
|
|
187
|
+
audit.http(clientIp, reqMethod, hostname, targetUrl.pathname, proxyResp.statusCode || 502, auditUrl);
|
|
188
|
+
res.writeHead(proxyResp.statusCode || 502, outResHeaders);
|
|
189
|
+
proxyResp.pipe(res);
|
|
190
|
+
}
|
|
191
|
+
async readRequestBodySafe(req, maxBytes, clientIp) {
|
|
192
|
+
const chunks = [];
|
|
193
|
+
let totalSize = 0;
|
|
194
|
+
const absoluteTimeout = setTimeout(() => {
|
|
195
|
+
req.destroy(new Error("Read absolute timeout exceeded (Slowloris Mitigation)"));
|
|
196
|
+
}, 10000);
|
|
197
|
+
try {
|
|
198
|
+
for await (const chunk of req) {
|
|
199
|
+
totalSize += chunk.length;
|
|
200
|
+
if (totalSize > maxBytes) {
|
|
201
|
+
clearTimeout(absoluteTimeout);
|
|
202
|
+
throw new Error("Payload size limit exceeded");
|
|
203
|
+
}
|
|
204
|
+
chunks.push(chunk);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
clearTimeout(absoluteTimeout);
|
|
209
|
+
}
|
|
210
|
+
return chunks.length > 0 ? Buffer.concat(chunks) : undefined;
|
|
211
|
+
}
|
|
131
212
|
async handleForwardProxy(req, res) {
|
|
132
213
|
const clientIp = req.socket.remoteAddress || "unknown";
|
|
133
214
|
const reqMethod = req.method || "GET";
|
|
134
215
|
const reqUrl = req.url || "/";
|
|
135
216
|
const targetUrl = new URL(reqUrl);
|
|
136
217
|
const hostname = targetUrl.hostname;
|
|
218
|
+
let validatedIps = [];
|
|
137
219
|
try {
|
|
138
|
-
await this.proxyService.validateTargetFirewall(targetUrl.toString(), this.config.firewall);
|
|
220
|
+
validatedIps = await this.proxyService.validateTargetFirewall(targetUrl.toString(), this.config.firewall);
|
|
139
221
|
}
|
|
140
222
|
catch (fwErr) {
|
|
141
223
|
audit.http(clientIp, reqMethod, hostname, targetUrl.pathname, 403, "Blocked by L3/L7 Firewall");
|
|
@@ -143,63 +225,34 @@ export class HttpHandler {
|
|
|
143
225
|
res.end(`<h1>403 Forbidden</h1><p>${fwErr.message}</p>`);
|
|
144
226
|
return;
|
|
145
227
|
}
|
|
146
|
-
const
|
|
147
|
-
for (const [k, v] of Object.entries(req.headers)) {
|
|
148
|
-
if (Array.isArray(v)) {
|
|
149
|
-
headers.set(k, v.join(", "));
|
|
150
|
-
}
|
|
151
|
-
else if (typeof v === "string") {
|
|
152
|
-
headers.set(k, v);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
const hopByHop = this.proxyService.getHopByHopHeaders();
|
|
156
|
-
for (const h of hopByHop) {
|
|
157
|
-
headers.delete(h);
|
|
158
|
-
}
|
|
228
|
+
const targetIp = validatedIps[0];
|
|
159
229
|
const maxBodyBytes = 5 * 1024 * 1024;
|
|
160
230
|
let bodyBuffer = undefined;
|
|
161
231
|
if (reqMethod !== "GET" && reqMethod !== "HEAD") {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
for await (const chunk of req) {
|
|
165
|
-
totalSize += chunk.length;
|
|
166
|
-
if (totalSize > maxBodyBytes) {
|
|
167
|
-
audit.http(clientIp, reqMethod, hostname, reqUrl, 413, targetUrl.hostname);
|
|
168
|
-
res.writeHead(413, { "Content-Type": "text/html" });
|
|
169
|
-
res.end("<h1>413 Payload Too Large</h1>");
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
chunks.push(chunk);
|
|
232
|
+
try {
|
|
233
|
+
bodyBuffer = await this.readRequestBodySafe(req, maxBodyBytes, clientIp);
|
|
173
234
|
}
|
|
174
|
-
|
|
175
|
-
|
|
235
|
+
catch (readErr) {
|
|
236
|
+
audit.http(clientIp, reqMethod, hostname, reqUrl, 413, targetUrl.hostname);
|
|
237
|
+
res.writeHead(413, { "Content-Type": "text/html", "Connection": "close" });
|
|
238
|
+
res.end("<h1>413 Payload Too Large / Read Fault</h1>");
|
|
239
|
+
if (!req.destroyed)
|
|
240
|
+
req.destroy();
|
|
241
|
+
return;
|
|
176
242
|
}
|
|
177
243
|
}
|
|
178
|
-
const
|
|
179
|
-
method: reqMethod,
|
|
180
|
-
headers,
|
|
181
|
-
};
|
|
182
|
-
if (bodyBuffer) {
|
|
183
|
-
proxiedReqInit.body = bodyBuffer;
|
|
184
|
-
proxiedReqInit.duplex = "half";
|
|
185
|
-
}
|
|
186
|
-
const proxiedReq = new Request(targetUrl.toString(), proxiedReqInit);
|
|
187
|
-
const breaker = this.getCircuitBreaker(targetUrl.hostname);
|
|
244
|
+
const breaker = this.getCircuitBreaker(hostname);
|
|
188
245
|
try {
|
|
189
|
-
|
|
190
|
-
const outHeaders = {};
|
|
191
|
-
proxyResp.headers.forEach((value, key) => {
|
|
192
|
-
if (!hopByHop.includes(key.toLowerCase())) {
|
|
193
|
-
outHeaders[key] = value;
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
audit.http(clientIp, reqMethod, hostname, targetUrl.pathname, proxyResp.status, targetUrl.toString());
|
|
197
|
-
res.writeHead(proxyResp.status, outHeaders);
|
|
198
|
-
const responseBuffer = Buffer.from(await proxyResp.arrayBuffer());
|
|
199
|
-
res.end(responseBuffer);
|
|
246
|
+
await this.doHttpProxy(targetUrl, targetIp, hostname, reqMethod, req.headers, bodyBuffer, clientIp, res, breaker, targetUrl.toString());
|
|
200
247
|
}
|
|
201
248
|
catch (err) {
|
|
202
|
-
|
|
249
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError' || err.message === 'TimeoutError') {
|
|
250
|
+
audit.http(clientIp, reqMethod, hostname, targetUrl.pathname, 504, "Upstream Gateway Timeout");
|
|
251
|
+
res.writeHead(504, { "Content-Type": "text/html" });
|
|
252
|
+
res.end(`<h1>504 Gateway Timeout</h1>`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
audit.http(clientIp, reqMethod, hostname, targetUrl.pathname, 502, "Upstream Offline or Fault");
|
|
203
256
|
res.writeHead(502, { "Content-Type": "text/html" });
|
|
204
257
|
res.end(`<h1>502 Bad Gateway</h1>`);
|
|
205
258
|
}
|
|
@@ -245,8 +298,9 @@ export class HttpHandler {
|
|
|
245
298
|
}
|
|
246
299
|
if (hostConfig.http_proxy?.enabled && hostConfig.http_proxy.upstream) {
|
|
247
300
|
const upstreamBase = new URL(hostConfig.http_proxy.upstream);
|
|
301
|
+
let validatedIps = [];
|
|
248
302
|
try {
|
|
249
|
-
await this.proxyService.validateTargetFirewall(upstreamBase.toString(), this.config.firewall);
|
|
303
|
+
validatedIps = await this.proxyService.validateTargetFirewall(upstreamBase.toString(), this.config.firewall);
|
|
250
304
|
}
|
|
251
305
|
catch (fwErr) {
|
|
252
306
|
audit.http(clientIp, reqMethod, hostname, reqUrl, 403, "Blocked by L3 Firewall");
|
|
@@ -254,6 +308,7 @@ export class HttpHandler {
|
|
|
254
308
|
res.end(`<h1>403 Forbidden</h1><p>${fwErr.message}</p>`);
|
|
255
309
|
return;
|
|
256
310
|
}
|
|
311
|
+
const targetIp = validatedIps[0];
|
|
257
312
|
let safePath = "/";
|
|
258
313
|
try {
|
|
259
314
|
const parsedPath = new URL(reqUrl, "http://safe.local");
|
|
@@ -263,56 +318,38 @@ export class HttpHandler {
|
|
|
263
318
|
safePath = "/";
|
|
264
319
|
}
|
|
265
320
|
const targetUrl = new URL(safePath, upstreamBase);
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
321
|
+
const originalHostname = targetUrl.hostname;
|
|
322
|
+
const customReqHeaders = {};
|
|
323
|
+
const customResHeaders = { "X-Proxy": "zonzon" };
|
|
324
|
+
for (const [key, value] of Object.entries(hostConfig.http_proxy.headers)) {
|
|
325
|
+
const sanitized = this.proxyService.sanitizeHeader(value);
|
|
326
|
+
if (sanitized) {
|
|
327
|
+
customReqHeaders[key] = sanitized;
|
|
328
|
+
customResHeaders[key] = sanitized;
|
|
273
329
|
}
|
|
274
330
|
}
|
|
275
331
|
const shouldForwardBody = hostConfig.http_proxy.forwardRequestBody;
|
|
276
332
|
const maxBodyBytes = hostConfig.http_proxy.maxRequestBodyBytes ?? 5 * 1024 * 1024;
|
|
277
333
|
let bodyBuffer = undefined;
|
|
278
334
|
if (reqMethod !== "GET" && reqMethod !== "HEAD") {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
for await (const chunk of req) {
|
|
282
|
-
totalSize += chunk.length;
|
|
283
|
-
if (totalSize > maxBodyBytes) {
|
|
284
|
-
audit.http(clientIp, reqMethod, hostname, reqUrl, 413, upstreamBase.hostname);
|
|
285
|
-
res.writeHead(413, { "Content-Type": "text/html" });
|
|
286
|
-
res.end("<h1>413 Payload Too Large</h1>");
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
chunks.push(chunk);
|
|
335
|
+
try {
|
|
336
|
+
bodyBuffer = await this.readRequestBodySafe(req, maxBodyBytes, clientIp);
|
|
290
337
|
}
|
|
291
|
-
|
|
292
|
-
|
|
338
|
+
catch (readErr) {
|
|
339
|
+
audit.http(clientIp, reqMethod, hostname, reqUrl, 413, upstreamBase.hostname);
|
|
340
|
+
res.writeHead(413, { "Content-Type": "text/html", "Connection": "close" });
|
|
341
|
+
res.end("<h1>413 Payload Too Large / Read Fault</h1>");
|
|
342
|
+
if (!req.destroyed)
|
|
343
|
+
req.destroy();
|
|
344
|
+
return;
|
|
293
345
|
}
|
|
294
346
|
}
|
|
295
|
-
const proxiedReqInit = {
|
|
296
|
-
method: reqMethod,
|
|
297
|
-
headers,
|
|
298
|
-
};
|
|
299
347
|
if (shouldForwardBody && bodyBuffer) {
|
|
300
|
-
|
|
301
|
-
|
|
348
|
+
customReqHeaders["X-Body-Forwarded"] = "true";
|
|
349
|
+
customReqHeaders["X-Body-Size"] = String(bodyBuffer.length);
|
|
302
350
|
}
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
const proxyResp = await breaker.execute(() => fetch(proxiedReq));
|
|
306
|
-
const responseHeaders = new Headers(proxyResp.headers);
|
|
307
|
-
this.injectCustomHeaders(responseHeaders, hostConfig);
|
|
308
|
-
const outHeaders = {};
|
|
309
|
-
responseHeaders.forEach((value, key) => {
|
|
310
|
-
outHeaders[key] = value;
|
|
311
|
-
});
|
|
312
|
-
audit.http(clientIp, reqMethod, hostname, safePath, proxyResp.status, targetUrl.toString());
|
|
313
|
-
res.writeHead(proxyResp.status, outHeaders);
|
|
314
|
-
const responseBuffer = Buffer.from(await proxyResp.arrayBuffer());
|
|
315
|
-
res.end(responseBuffer);
|
|
351
|
+
const breaker = this.getCircuitBreaker(originalHostname);
|
|
352
|
+
await this.doHttpProxy(targetUrl, targetIp, originalHostname, reqMethod, req.headers, shouldForwardBody ? bodyBuffer : undefined, clientIp, res, breaker, targetUrl.toString(), customReqHeaders, customResHeaders);
|
|
316
353
|
return;
|
|
317
354
|
}
|
|
318
355
|
}
|
|
@@ -333,43 +370,46 @@ export class HttpHandler {
|
|
|
333
370
|
res.end("Forbidden");
|
|
334
371
|
return;
|
|
335
372
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
else if (typeof v === "string")
|
|
342
|
-
headers.set(k, v);
|
|
373
|
+
if (firewallEngine.evaluateOutbound(targetIp, this.config.firewall) === "DENY") {
|
|
374
|
+
audit.http(clientIp, reqMethod, hostname, reqUrl, 403, "Blocked by SSRF Policy");
|
|
375
|
+
res.writeHead(403);
|
|
376
|
+
res.end("Forbidden");
|
|
377
|
+
return;
|
|
343
378
|
}
|
|
344
|
-
const
|
|
379
|
+
const targetUrl = new URL(reqUrl, `http://${hostname}`);
|
|
380
|
+
let bodyBuffer = undefined;
|
|
345
381
|
if (reqMethod !== "GET" && reqMethod !== "HEAD") {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
382
|
+
const maxBodyBytes = this.config.hosts[hostname]?.http_proxy?.maxRequestBodyBytes ?? 5242880;
|
|
383
|
+
try {
|
|
384
|
+
bodyBuffer = await this.readRequestBodySafe(req, maxBodyBytes, clientIp);
|
|
385
|
+
}
|
|
386
|
+
catch (readErr) {
|
|
387
|
+
res.writeHead(413, { "Content-Type": "text/html", "Connection": "close" });
|
|
388
|
+
res.end("<h1>413 Payload Too Large / Read Fault</h1>");
|
|
389
|
+
if (!req.destroyed)
|
|
390
|
+
req.destroy();
|
|
391
|
+
return;
|
|
352
392
|
}
|
|
353
393
|
}
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
const proxyResp = await breaker.execute(() => fetch(proxiedReq));
|
|
357
|
-
const outHeaders = {};
|
|
358
|
-
proxyResp.headers.forEach((value, key) => { outHeaders[key] = value; });
|
|
359
|
-
audit.http(clientIp, reqMethod, hostname, reqUrl, proxyResp.status, targetIp);
|
|
360
|
-
res.writeHead(proxyResp.status, outHeaders);
|
|
361
|
-
res.end(Buffer.from(await proxyResp.arrayBuffer()));
|
|
394
|
+
const breaker = this.getCircuitBreaker(hostname);
|
|
395
|
+
await this.doHttpProxy(targetUrl, targetIp, hostname, reqMethod, req.headers, bodyBuffer, clientIp, res, breaker, targetIp);
|
|
362
396
|
}
|
|
363
397
|
catch (err) {
|
|
364
398
|
const message = err instanceof Error ? err.message : String(err);
|
|
365
399
|
audit.error(`HTTP request failed: ${message}`);
|
|
366
|
-
if (
|
|
400
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError' || message === 'TimeoutError') {
|
|
401
|
+
audit.http(clientIp, reqMethod, hostname, reqUrl, 504, "Upstream Gateway Timeout");
|
|
402
|
+
res.writeHead(504, { "Content-Type": "text/html" });
|
|
403
|
+
res.end(`<h1>504 Gateway Timeout</h1>`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (message.includes("Security Block") || message.includes("Firewall") || message.includes("Blocked") || message.includes("Restricted IP")) {
|
|
367
407
|
audit.http(clientIp, reqMethod, hostname, reqUrl, 403, "Blocked for Security (SSRF/Firewall)");
|
|
368
408
|
res.writeHead(403, { "Content-Type": "text/html" });
|
|
369
409
|
res.end(`<h1>403 Forbidden</h1>`);
|
|
370
410
|
return;
|
|
371
411
|
}
|
|
372
|
-
audit.http(clientIp, reqMethod, hostname, reqUrl, 502, "Upstream Offline/
|
|
412
|
+
audit.http(clientIp, reqMethod, hostname, reqUrl, 502, "Upstream Offline/Fault");
|
|
373
413
|
res.writeHead(502, { "Content-Type": "text/html" });
|
|
374
414
|
res.end(`<h1>502 Bad Gateway</h1>`);
|
|
375
415
|
return;
|
|
@@ -386,6 +426,9 @@ export class HttpHandler {
|
|
|
386
426
|
}
|
|
387
427
|
});
|
|
388
428
|
});
|
|
429
|
+
this.server.headersTimeout = 10000;
|
|
430
|
+
this.server.requestTimeout = this.idleTimeoutMs;
|
|
431
|
+
this.server.keepAliveTimeout = 5000;
|
|
389
432
|
this.server.on("connect", (req, clientSocket, head) => {
|
|
390
433
|
this.handleConnect(req, clientSocket, head).catch((err) => {
|
|
391
434
|
audit.error(`TCP CONNECT critical error: ${err}`);
|
|
@@ -410,16 +453,13 @@ export class HttpHandler {
|
|
|
410
453
|
}
|
|
411
454
|
async stop() {
|
|
412
455
|
if (this.server) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
this.activeConnections.clear();
|
|
417
|
-
if ('closeAllConnections' in this.server) {
|
|
418
|
-
this.server.closeAllConnections();
|
|
456
|
+
if ('closeIdleConnections' in this.server) {
|
|
457
|
+
this.server.closeIdleConnections();
|
|
419
458
|
}
|
|
420
459
|
await new Promise((resolve) => {
|
|
421
460
|
this.server.close(() => resolve());
|
|
422
461
|
});
|
|
462
|
+
this.activeConnections.clear();
|
|
423
463
|
this.server = null;
|
|
424
464
|
}
|
|
425
465
|
}
|
package/dist/http-proxy.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { HostConfig, ProxiedRequest, ModifiedHeaders, FirewallConfig } from "./types.js";
|
|
2
2
|
export declare class HttpProxyService {
|
|
3
|
-
|
|
4
|
-
validateTargetFirewall(targetUrl: string, fw?: FirewallConfig): Promise<void>;
|
|
3
|
+
validateTargetFirewall(targetUrl: string, fw?: FirewallConfig): Promise<string[]>;
|
|
5
4
|
getUpstreamHeaders(config: HostConfig, originalRequest: ProxiedRequest): ModifiedHeaders;
|
|
6
5
|
checkRedirect(config: HostConfig): {
|
|
7
6
|
code: number;
|
package/dist/http-proxy.js
CHANGED
|
@@ -2,12 +2,6 @@ import * as net from "net";
|
|
|
2
2
|
import * as dns from "dns/promises";
|
|
3
3
|
import { firewallEngine } from "./firewall.js";
|
|
4
4
|
export class HttpProxyService {
|
|
5
|
-
isRestrictedCloudMetadata(ip) {
|
|
6
|
-
if (!net.isIPv4(ip))
|
|
7
|
-
return false;
|
|
8
|
-
const parts = ip.split('.').map(Number);
|
|
9
|
-
return (parts[0] === 169 && parts[1] === 254) || parts[0] === 0;
|
|
10
|
-
}
|
|
11
5
|
async validateTargetFirewall(targetUrl, fw) {
|
|
12
6
|
const parsed = new URL(targetUrl);
|
|
13
7
|
const host = parsed.hostname;
|
|
@@ -30,8 +24,11 @@ export class HttpProxyService {
|
|
|
30
24
|
throw new Error(`Resolution Fault: '${host}'`);
|
|
31
25
|
}
|
|
32
26
|
}
|
|
27
|
+
if (targetIps.length === 0) {
|
|
28
|
+
throw new Error(`NXDOMAIN: '${host}'`);
|
|
29
|
+
}
|
|
33
30
|
for (const ip of targetIps) {
|
|
34
|
-
if (
|
|
31
|
+
if (firewallEngine.evaluateOutbound(ip, fw) === "DENY") {
|
|
35
32
|
throw new Error(`Restricted IP: (${ip})`);
|
|
36
33
|
}
|
|
37
34
|
if (fw) {
|
|
@@ -40,6 +37,7 @@ export class HttpProxyService {
|
|
|
40
37
|
}
|
|
41
38
|
}
|
|
42
39
|
}
|
|
40
|
+
return targetIps;
|
|
43
41
|
}
|
|
44
42
|
getUpstreamHeaders(config, originalRequest) {
|
|
45
43
|
const result = {
|
package/dist/rate-limiter.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
export interface RateLimiterOptions {
|
|
2
2
|
maxRequests: number;
|
|
3
3
|
windowMs: number;
|
|
4
|
+
maxTrackedIps?: number;
|
|
4
5
|
}
|
|
5
6
|
export declare class RateLimiter {
|
|
6
7
|
private options;
|
|
7
8
|
private buckets;
|
|
9
|
+
private gcInterval;
|
|
10
|
+
private maxTrackedIps;
|
|
8
11
|
constructor(options: RateLimiterOptions);
|
|
12
|
+
private garbageCollect;
|
|
13
|
+
destroy(): void;
|
|
9
14
|
allow(ip: string): boolean;
|
|
10
15
|
getRequestCount(ip: string): number;
|
|
11
16
|
}
|
package/dist/rate-limiter.js
CHANGED
|
@@ -1,33 +1,52 @@
|
|
|
1
1
|
export class RateLimiter {
|
|
2
2
|
options;
|
|
3
3
|
buckets = new Map();
|
|
4
|
+
gcInterval;
|
|
5
|
+
maxTrackedIps;
|
|
4
6
|
constructor(options) {
|
|
5
7
|
this.options = options;
|
|
8
|
+
this.maxTrackedIps = options.maxTrackedIps ?? 100000;
|
|
9
|
+
this.gcInterval = setInterval(() => this.garbageCollect(), Math.max(options.windowMs * 2, 60000));
|
|
10
|
+
this.gcInterval.unref();
|
|
11
|
+
}
|
|
12
|
+
garbageCollect() {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const [ip, bucket] of this.buckets.entries()) {
|
|
15
|
+
if (now >= bucket.resetTime) {
|
|
16
|
+
this.buckets.delete(ip);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
destroy() {
|
|
21
|
+
clearInterval(this.gcInterval);
|
|
22
|
+
this.buckets.clear();
|
|
6
23
|
}
|
|
7
24
|
allow(ip) {
|
|
8
25
|
const now = Date.now();
|
|
9
26
|
let bucket = this.buckets.get(ip);
|
|
10
|
-
if (!bucket || now
|
|
11
|
-
|
|
27
|
+
if (!bucket || now >= bucket.resetTime) {
|
|
28
|
+
if (this.buckets.size >= this.maxTrackedIps) {
|
|
29
|
+
const firstKey = this.buckets.keys().next().value;
|
|
30
|
+
if (firstKey !== undefined) {
|
|
31
|
+
this.buckets.delete(firstKey);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
bucket = { count: 1, resetTime: now + this.options.windowMs };
|
|
12
35
|
this.buckets.set(ip, bucket);
|
|
13
36
|
return true;
|
|
14
37
|
}
|
|
15
|
-
|
|
16
|
-
while (bucket.timestamps.length > 0 && bucket.timestamps[0] < windowStart) {
|
|
17
|
-
bucket.timestamps.shift();
|
|
18
|
-
}
|
|
19
|
-
if (bucket.timestamps.length >= this.options.maxRequests) {
|
|
38
|
+
if (bucket.count >= this.options.maxRequests) {
|
|
20
39
|
return false;
|
|
21
40
|
}
|
|
22
|
-
bucket.
|
|
41
|
+
bucket.count++;
|
|
23
42
|
return true;
|
|
24
43
|
}
|
|
25
44
|
getRequestCount(ip) {
|
|
26
45
|
const now = Date.now();
|
|
27
|
-
const windowStart = now - this.options.windowMs;
|
|
28
46
|
const bucket = this.buckets.get(ip);
|
|
29
|
-
if (!bucket)
|
|
47
|
+
if (!bucket || now >= bucket.resetTime) {
|
|
30
48
|
return 0;
|
|
31
|
-
|
|
49
|
+
}
|
|
50
|
+
return bucket.count;
|
|
32
51
|
}
|
|
33
52
|
}
|
package/dist/schema.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import * as net from "net";
|
|
3
|
-
const CrlfFreeString = z.string().refine((val) => !/[\r\n]/.test(val), "Contains CR/LF");
|
|
4
|
-
const Ipv4Schema = z.string().refine((ip) => net.isIPv4(ip), "Invalid IPv4");
|
|
5
|
-
const Ipv6Schema = z.string().refine((ip) => net.isIPv6(ip), "Invalid IPv6");
|
|
3
|
+
const CrlfFreeString = z.string().max(8192).refine((val) => !/[\r\n]/.test(val), "Contains CR/LF");
|
|
4
|
+
const Ipv4Schema = z.string().max(45).refine((ip) => net.isIPv4(ip), "Invalid IPv4");
|
|
5
|
+
const Ipv6Schema = z.string().max(45).refine((ip) => net.isIPv6(ip), "Invalid IPv6");
|
|
6
6
|
const HostnameSchema = z.string().max(253).refine((hostname) => {
|
|
7
7
|
const parts = hostname.split(".");
|
|
8
8
|
const hostPattern = /^[a-zA-Z0-9_]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?$/;
|
|
@@ -30,7 +30,7 @@ const DnsRecordSchema = z.discriminatedUnion("type", [
|
|
|
30
30
|
const HttpProxySchema = z.object({
|
|
31
31
|
enabled: z.boolean(),
|
|
32
32
|
upstream: CrlfFreeString.optional(),
|
|
33
|
-
headers: z.record(z.string().regex(/^[a-zA-Z0-9\-]+$/), CrlfFreeString).default({}),
|
|
33
|
+
headers: z.record(z.string().max(256).regex(/^[a-zA-Z0-9\-]+$/), CrlfFreeString).default({}),
|
|
34
34
|
forwardRequestBody: z.boolean().default(false),
|
|
35
35
|
maxRequestBodyBytes: z.number().int().min(0).max(10485760).default(5242880),
|
|
36
36
|
}).refine(data => !data.enabled || !!data.upstream, {
|
|
@@ -59,12 +59,19 @@ const FirewallSchema = z.object({
|
|
|
59
59
|
const ControlPlaneSchema = z.object({
|
|
60
60
|
enabled: z.boolean().default(true).optional(),
|
|
61
61
|
port: z.coerce.number().int().min(1).max(65535).default(8080).optional(),
|
|
62
|
-
apiKey: z.string().optional()
|
|
62
|
+
apiKey: z.string().max(256).optional()
|
|
63
|
+
});
|
|
64
|
+
const TlsSchema = z.object({
|
|
65
|
+
cert: z.string().min(1),
|
|
66
|
+
key: z.string().min(1)
|
|
63
67
|
});
|
|
64
68
|
const ServerConfigSchema = z.object({
|
|
65
69
|
port: z.coerce.number().int().min(1).max(65535).default(53),
|
|
66
70
|
httpPort: z.coerce.number().int().min(1).max(65535).optional(),
|
|
67
71
|
httpsPort: z.coerce.number().int().min(1).max(65535).optional(),
|
|
72
|
+
tls: TlsSchema.optional(),
|
|
73
|
+
dotPort: z.coerce.number().int().min(1).max(65535).default(853).optional(),
|
|
74
|
+
dohPort: z.coerce.number().int().min(1).max(65535).default(8443).optional(),
|
|
68
75
|
fallbackDns: Ipv4Schema.optional(),
|
|
69
76
|
firewall: FirewallSchema.optional(),
|
|
70
77
|
controlPlane: ControlPlaneSchema.optional(),
|
|
@@ -74,7 +81,7 @@ const ServerConfigSchema = z.object({
|
|
|
74
81
|
tcpIdleTimeoutMs: z.coerce.number().int().min(1000).max(600000).default(30000),
|
|
75
82
|
rateLimitMaxRequests: z.coerce.number().int().min(0).max(100000).default(0),
|
|
76
83
|
rateLimitWindowMs: z.coerce.number().int().min(100).max(60000).default(1000),
|
|
77
|
-
hosts: z.record(z.string(), HostConfigSchema).default({}),
|
|
84
|
+
hosts: z.record(z.string().max(253), HostConfigSchema).default({}),
|
|
78
85
|
}).refine(data => {
|
|
79
86
|
for (const key of Object.keys(data.hosts)) {
|
|
80
87
|
const normalized = key.toLowerCase();
|