@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.
@@ -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();
61
- constructor(dnsServer, config, port = 80) {
62
+ idleTimeoutMs;
63
+ constructor(dnsServer, config, port) {
62
64
  this.dnsServer = dnsServer;
63
65
  this.proxyService = new HttpProxyService();
64
- this.port = port;
65
66
  this.config = config;
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
- audit.http(clientIp, "CONNECT", hostname, `:${port}`, 200, "TCP Tunnel Established");
103
- const srvSocket = net.connect(port, hostname, () => {
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 headers = new Headers();
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
- const chunks = [];
163
- let totalSize = 0;
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
- if (chunks.length > 0) {
175
- bodyBuffer = Buffer.concat(chunks);
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 proxiedReqInit = {
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
- const proxyResp = await breaker.execute(() => fetch(proxiedReq));
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
- audit.http(clientIp, reqMethod, hostname, targetUrl.pathname, 502, "Upstream Timeout or Fault");
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 headers = new Headers();
267
- for (const [k, v] of Object.entries(req.headers)) {
268
- if (Array.isArray(v)) {
269
- headers.set(k, v.join(", "));
270
- }
271
- else if (typeof v === "string") {
272
- headers.set(k, v);
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
- const chunks = [];
280
- let totalSize = 0;
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
- if (chunks.length > 0) {
292
- bodyBuffer = Buffer.concat(chunks);
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
- proxiedReqInit.body = bodyBuffer;
301
- proxiedReqInit.duplex = "half";
348
+ customReqHeaders["X-Body-Forwarded"] = "true";
349
+ customReqHeaders["X-Body-Size"] = String(bodyBuffer.length);
302
350
  }
303
- const proxiedReq = new Request(targetUrl.toString(), proxiedReqInit);
304
- const breaker = this.getCircuitBreaker(upstreamBase.hostname);
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
- const targetUrl = new URL(reqUrl, `http://${hostname}`);
337
- const headers = new Headers();
338
- for (const [k, v] of Object.entries(req.headers)) {
339
- if (Array.isArray(v))
340
- headers.set(k, v.join(", "));
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 proxiedReqInit = { method: reqMethod, headers };
379
+ const targetUrl = new URL(reqUrl, `http://${hostname}`);
380
+ let bodyBuffer = undefined;
345
381
  if (reqMethod !== "GET" && reqMethod !== "HEAD") {
346
- const chunks = [];
347
- for await (const chunk of req)
348
- chunks.push(chunk);
349
- if (chunks.length > 0) {
350
- proxiedReqInit.body = Buffer.concat(chunks);
351
- proxiedReqInit.duplex = "half";
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 proxiedReq = new Request(targetUrl.toString(), proxiedReqInit);
355
- const breaker = this.getCircuitBreaker(targetUrl.hostname);
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 (message.includes("Security Block") || message.includes("Firewall") || message.includes("Blocked")) {
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/Timeout");
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
- for (const socket of this.activeConnections) {
414
- socket.destroy();
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
  }
@@ -1,7 +1,6 @@
1
1
  import { HostConfig, ProxiedRequest, ModifiedHeaders, FirewallConfig } from "./types.js";
2
2
  export declare class HttpProxyService {
3
- private isRestrictedCloudMetadata;
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;
@@ -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 (this.isRestrictedCloudMetadata(ip)) {
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 = {
@@ -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
  }
@@ -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 - bucket.timestamps[0] > this.options.windowMs) {
11
- bucket = { timestamps: [now] };
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
- const windowStart = now - this.options.windowMs;
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.timestamps.push(now);
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
- return bucket.timestamps.filter((t) => t >= windowStart).length;
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,10 +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),
70
+ httpPort: z.coerce.number().int().min(1).max(65535).optional(),
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(),
66
75
  fallbackDns: Ipv4Schema.optional(),
67
76
  firewall: FirewallSchema.optional(),
68
77
  controlPlane: ControlPlaneSchema.optional(),
@@ -72,7 +81,7 @@ const ServerConfigSchema = z.object({
72
81
  tcpIdleTimeoutMs: z.coerce.number().int().min(1000).max(600000).default(30000),
73
82
  rateLimitMaxRequests: z.coerce.number().int().min(0).max(100000).default(0),
74
83
  rateLimitWindowMs: z.coerce.number().int().min(100).max(60000).default(1000),
75
- hosts: z.record(z.string(), HostConfigSchema).default({}),
84
+ hosts: z.record(z.string().max(253), HostConfigSchema).default({}),
76
85
  }).refine(data => {
77
86
  for (const key of Object.keys(data.hosts)) {
78
87
  const normalized = key.toLowerCase();
@@ -4,6 +4,7 @@ export declare class SniProxyService {
4
4
  private config;
5
5
  private server;
6
6
  private activeConnections;
7
+ private idleTimeoutMs;
7
8
  constructor(config: ServerConfig, port?: number);
8
9
  private extractSNI;
9
10
  private handleConnection;