@opensecurity/zonzon-core 0.1.2 → 0.1.4
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 +10 -0
- package/dist/audit.js +39 -0
- package/dist/cache-layer.test.d.ts +1 -0
- package/dist/cache-layer.test.js +205 -0
- package/dist/cache-multi-question.test.d.ts +1 -0
- package/dist/cache-multi-question.test.js +187 -0
- package/dist/dns-handler.d.ts +27 -0
- package/dist/dns-handler.js +323 -0
- package/dist/dns-service.d.ts +45 -0
- package/dist/dns-service.js +546 -0
- package/dist/dns-service.test.d.ts +1 -0
- package/dist/dns-service.test.js +306 -0
- package/dist/dns-wireformat.test.d.ts +1 -0
- package/dist/dns-wireformat.test.js +669 -0
- package/dist/firewall.d.ts +9 -0
- package/dist/firewall.js +62 -0
- package/dist/http-body-forwarding-integration.test.d.ts +1 -0
- package/dist/http-body-forwarding-integration.test.js +318 -0
- package/dist/http-body-forwarding.test.d.ts +1 -0
- package/dist/http-body-forwarding.test.js +84 -0
- package/dist/http-handler.d.ts +21 -0
- package/dist/http-handler.js +429 -0
- package/dist/http-proxy.d.ts +14 -0
- package/dist/http-proxy.js +135 -0
- package/dist/http-proxy.test.d.ts +1 -0
- package/dist/http-proxy.test.js +375 -0
- package/{src/index.ts → dist/index.d.ts} +1 -1
- package/dist/index.js +10 -0
- package/dist/rate-limiter.d.ts +11 -0
- package/dist/rate-limiter.js +33 -0
- package/dist/rate-limiter.test.d.ts +1 -0
- package/dist/rate-limiter.test.js +149 -0
- package/dist/schema.d.ts +12 -0
- package/dist/schema.js +126 -0
- package/dist/schema.test.d.ts +1 -0
- package/dist/schema.test.js +586 -0
- package/dist/sni-proxy.d.ts +12 -0
- package/dist/sni-proxy.js +141 -0
- package/dist/srv-record.test.d.ts +1 -0
- package/dist/srv-record.test.js +186 -0
- package/dist/tcp-connection-limit.test.d.ts +1 -0
- package/dist/tcp-connection-limit.test.js +89 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +34 -0
- package/dist/wildcard-matching.test.d.ts +1 -0
- package/dist/wildcard-matching.test.js +162 -0
- package/package.json +4 -1
- package/src/audit.ts +0 -43
- package/src/cache-layer.test.ts +0 -236
- package/src/cache-multi-question.test.ts +0 -263
- package/src/dns-handler.ts +0 -355
- package/src/dns-service.test.ts +0 -371
- package/src/dns-service.ts +0 -655
- package/src/dns-wireformat.test.ts +0 -771
- package/src/env.d.ts +0 -1
- package/src/firewall.ts +0 -66
- package/src/http-body-forwarding-integration.test.ts +0 -357
- package/src/http-body-forwarding.test.ts +0 -101
- package/src/http-handler.ts +0 -489
- package/src/http-proxy.test.ts +0 -440
- package/src/http-proxy.ts +0 -148
- package/src/rate-limiter.test.ts +0 -144
- package/src/rate-limiter.ts +0 -50
- package/src/schema.test.ts +0 -685
- package/src/schema.ts +0 -137
- package/src/sni-proxy.ts +0 -164
- package/src/srv-record.test.ts +0 -211
- package/src/tcp-connection-limit.test.ts +0 -110
- package/src/types.ts +0 -168
- package/src/wildcard-matching.test.ts +0 -196
- package/tsconfig.json +0 -9
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { HttpProxyService } from "./http-proxy.js";
|
|
4
|
+
function makeConfig(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
records: [{ type: "A", address: "127.0.0.1" }],
|
|
7
|
+
http_proxy: undefined,
|
|
8
|
+
redirect: undefined,
|
|
9
|
+
...overrides,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function makeProxyEnabledConfig() {
|
|
13
|
+
return makeConfig({
|
|
14
|
+
http_proxy: {
|
|
15
|
+
enabled: true,
|
|
16
|
+
upstream: "http://upstream.example.com",
|
|
17
|
+
headers: {
|
|
18
|
+
"X-Custom": "custom-value",
|
|
19
|
+
"X-Env": "production",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function makeRedirectConfig() {
|
|
25
|
+
return makeConfig({
|
|
26
|
+
redirect: {
|
|
27
|
+
enabled: true,
|
|
28
|
+
code: 301,
|
|
29
|
+
target: "https://target.example.com/path",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
describe("HttpProxyService - Header Sanitization", () => {
|
|
34
|
+
const proxy = new HttpProxyService();
|
|
35
|
+
it("accepts normal alphanumeric header values", () => {
|
|
36
|
+
assert.strictEqual(proxy.sanitizeHeader("normal-value"), "normal-value");
|
|
37
|
+
});
|
|
38
|
+
it("rejects header values with CR characters", () => {
|
|
39
|
+
assert.strictEqual(proxy.sanitizeHeader("value\x0dwithCR"), null);
|
|
40
|
+
});
|
|
41
|
+
it("rejects header values with LF characters", () => {
|
|
42
|
+
assert.strictEqual(proxy.sanitizeHeader("value\nwithLF"), null);
|
|
43
|
+
});
|
|
44
|
+
it("rejects header values with CRLF combinations", () => {
|
|
45
|
+
assert.strictEqual(proxy.sanitizeHeader("value\r\nInjected: true"), null);
|
|
46
|
+
});
|
|
47
|
+
it("rejects header values exceeding 8192 characters", () => {
|
|
48
|
+
const longValue = "x".repeat(8193);
|
|
49
|
+
assert.strictEqual(proxy.sanitizeHeader(longValue), null);
|
|
50
|
+
});
|
|
51
|
+
it("accepts header values at exactly 8192 characters", () => {
|
|
52
|
+
const maxLen = "x".repeat(8192);
|
|
53
|
+
assert.strictEqual(proxy.sanitizeHeader(maxLen), maxLen);
|
|
54
|
+
});
|
|
55
|
+
it("rejects non-string values", () => {
|
|
56
|
+
assert.strictEqual(proxy.sanitizeHeader(123), null);
|
|
57
|
+
assert.strictEqual(proxy.sanitizeHeader(null), null);
|
|
58
|
+
assert.strictEqual(proxy.sanitizeHeader(undefined), null);
|
|
59
|
+
assert.strictEqual(proxy.sanitizeHeader({}), null);
|
|
60
|
+
});
|
|
61
|
+
it("accepts header values with safe special characters", () => {
|
|
62
|
+
const value = "Bearer abc123.def456 ghi789!@#$%^&*()";
|
|
63
|
+
assert.strictEqual(proxy.sanitizeHeader(value), value);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("HttpProxyService - Header Name Validation", () => {
|
|
67
|
+
const proxy = new HttpProxyService();
|
|
68
|
+
it("accepts valid RFC 7230 token header names", () => {
|
|
69
|
+
assert.strictEqual(proxy.isValidHeaderName("Content-Type"), true);
|
|
70
|
+
assert.strictEqual(proxy.isValidHeaderName("X-Custom-Header"), true);
|
|
71
|
+
assert.strictEqual(proxy.isValidHeaderName("Authorization"), true);
|
|
72
|
+
assert.strictEqual(proxy.isValidHeaderName("X-B3-TraceId"), true);
|
|
73
|
+
});
|
|
74
|
+
it("accepts header names with RFC-valid special characters", () => {
|
|
75
|
+
assert.strictEqual(proxy.isValidHeaderName("X-Test_Header"), true);
|
|
76
|
+
assert.strictEqual(proxy.isValidHeaderName("X-Test+Header"), true);
|
|
77
|
+
assert.strictEqual(proxy.isValidHeaderName("X-Test~Header"), true);
|
|
78
|
+
});
|
|
79
|
+
it("rejects empty header names", () => {
|
|
80
|
+
assert.strictEqual(proxy.isValidHeaderName(""), false);
|
|
81
|
+
});
|
|
82
|
+
it("rejects header names exceeding 256 characters", () => {
|
|
83
|
+
const longName = "x".repeat(257);
|
|
84
|
+
assert.strictEqual(proxy.isValidHeaderName(longName), false);
|
|
85
|
+
});
|
|
86
|
+
it("rejects header names with spaces", () => {
|
|
87
|
+
assert.strictEqual(proxy.isValidHeaderName("X- Evil"), false);
|
|
88
|
+
});
|
|
89
|
+
it("rejects header names with newlines (CRLF injection)", () => {
|
|
90
|
+
assert.strictEqual(proxy.isValidHeaderName("X-Bad\r\nInjected"), false);
|
|
91
|
+
assert.strictEqual(proxy.isValidHeaderName("X-Bad\nInjected"), false);
|
|
92
|
+
});
|
|
93
|
+
it("rejects header names with forward slashes", () => {
|
|
94
|
+
assert.strictEqual(proxy.isValidHeaderName("X/Path/Header"), false);
|
|
95
|
+
});
|
|
96
|
+
it("rejects header names with backslashes", () => {
|
|
97
|
+
assert.strictEqual(proxy.isValidHeaderName("X\\Backslash"), false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("HttpProxyService - Hop-by-Hop Headers", () => {
|
|
101
|
+
const proxy = new HttpProxyService();
|
|
102
|
+
it("returns all hop-by-hop headers to exclude", () => {
|
|
103
|
+
const excluded = proxy.getHopByHopHeaders();
|
|
104
|
+
assert.ok(excluded.includes("connection"));
|
|
105
|
+
assert.ok(excluded.includes("keep-alive"));
|
|
106
|
+
assert.ok(excluded.includes("te"));
|
|
107
|
+
assert.ok(excluded.includes("transfer-encoding"));
|
|
108
|
+
assert.ok(excluded.includes("upgrade"));
|
|
109
|
+
assert.ok(excluded.includes("proxy-authenticate"));
|
|
110
|
+
assert.ok(excluded.includes("proxy-authorization"));
|
|
111
|
+
assert.ok(excluded.includes("trailer"));
|
|
112
|
+
});
|
|
113
|
+
it("excludes hop-by-hop headers from upstream forwarding", () => {
|
|
114
|
+
const config = makeProxyEnabledConfig();
|
|
115
|
+
const request = {
|
|
116
|
+
hostname: "app.loop",
|
|
117
|
+
originalUrl: "/",
|
|
118
|
+
method: "GET",
|
|
119
|
+
headers: {
|
|
120
|
+
connection: "keep-alive",
|
|
121
|
+
"keep-alive": "timeout=5",
|
|
122
|
+
"X-Forwarded-For": "192.168.1.1",
|
|
123
|
+
"X-Custom": "custom-value",
|
|
124
|
+
"Transfer-Encoding": "chunked",
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
const result = proxy.getUpstreamHeaders(config, request);
|
|
128
|
+
assert.ok("X-Forwarded-For" in result.clientResponseHeaders);
|
|
129
|
+
assert.strictEqual(result.upstreamHeaders["X-Custom"], "custom-value");
|
|
130
|
+
assert.strictEqual(result.clientResponseHeaders["X-Custom"], "custom-value");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe("HttpProxyService - Header Injection", () => {
|
|
134
|
+
const proxy = new HttpProxyService();
|
|
135
|
+
it("injects custom headers when proxy is enabled", () => {
|
|
136
|
+
const config = makeProxyEnabledConfig();
|
|
137
|
+
const request = {
|
|
138
|
+
hostname: "app.loop",
|
|
139
|
+
originalUrl: "/",
|
|
140
|
+
method: "GET",
|
|
141
|
+
headers: {},
|
|
142
|
+
};
|
|
143
|
+
const result = proxy.getUpstreamHeaders(config, request);
|
|
144
|
+
assert.strictEqual(result.upstreamHeaders["X-Custom"], "custom-value");
|
|
145
|
+
assert.strictEqual(result.upstreamHeaders["X-Env"], "production");
|
|
146
|
+
assert.strictEqual(result.clientResponseHeaders["X-Proxy"], "zonzon");
|
|
147
|
+
});
|
|
148
|
+
it("does not inject any headers when proxy is disabled", () => {
|
|
149
|
+
const config = makeConfig({ http_proxy: { enabled: false, upstream: "", headers: {} } });
|
|
150
|
+
const request = {
|
|
151
|
+
hostname: "app.loop",
|
|
152
|
+
originalUrl: "/",
|
|
153
|
+
method: "GET",
|
|
154
|
+
headers: {},
|
|
155
|
+
};
|
|
156
|
+
const result = proxy.getUpstreamHeaders(config, request);
|
|
157
|
+
assert.strictEqual(Object.keys(result.upstreamHeaders).length, 0);
|
|
158
|
+
assert.ok(!result.clientResponseHeaders["X-Proxy"]);
|
|
159
|
+
});
|
|
160
|
+
it("does not inject any headers when proxy config is absent", () => {
|
|
161
|
+
const config = makeConfig();
|
|
162
|
+
const request = {
|
|
163
|
+
hostname: "app.loop",
|
|
164
|
+
originalUrl: "/",
|
|
165
|
+
method: "GET",
|
|
166
|
+
headers: {},
|
|
167
|
+
};
|
|
168
|
+
const result = proxy.getUpstreamHeaders(config, request);
|
|
169
|
+
assert.strictEqual(Object.keys(result.upstreamHeaders).length, 0);
|
|
170
|
+
assert.ok(!result.clientResponseHeaders["X-Proxy"]);
|
|
171
|
+
});
|
|
172
|
+
it("adds X-Proxy identification header to client responses", () => {
|
|
173
|
+
const config = makeProxyEnabledConfig();
|
|
174
|
+
const request = {
|
|
175
|
+
hostname: "app.loop",
|
|
176
|
+
originalUrl: "/index.html",
|
|
177
|
+
method: "GET",
|
|
178
|
+
headers: {},
|
|
179
|
+
};
|
|
180
|
+
const result = proxy.getUpstreamHeaders(config, request);
|
|
181
|
+
assert.strictEqual(result.clientResponseHeaders["X-Proxy"], "zonzon");
|
|
182
|
+
});
|
|
183
|
+
it("passes through non-hop-by-hop original headers to client response", () => {
|
|
184
|
+
const config = makeProxyEnabledConfig();
|
|
185
|
+
const request = {
|
|
186
|
+
hostname: "app.loop",
|
|
187
|
+
originalUrl: "/",
|
|
188
|
+
method: "GET",
|
|
189
|
+
headers: {
|
|
190
|
+
Accept: "text/html",
|
|
191
|
+
"User-Agent": "test-agent/1.0",
|
|
192
|
+
"X-Request-Id": "abc-123",
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const result = proxy.getUpstreamHeaders(config, request);
|
|
196
|
+
assert.strictEqual(result.clientResponseHeaders["Accept"], "text/html");
|
|
197
|
+
assert.strictEqual(result.clientResponseHeaders["User-Agent"], "test-agent/1.0");
|
|
198
|
+
assert.strictEqual(result.clientResponseHeaders["X-Request-Id"], "abc-123");
|
|
199
|
+
});
|
|
200
|
+
it("strips hop-by-hop headers from client response", () => {
|
|
201
|
+
const config = makeProxyEnabledConfig();
|
|
202
|
+
const request = {
|
|
203
|
+
hostname: "app.loop",
|
|
204
|
+
originalUrl: "/",
|
|
205
|
+
method: "GET",
|
|
206
|
+
headers: {
|
|
207
|
+
Connection: "close",
|
|
208
|
+
"Keep-Alive": "timeout=10",
|
|
209
|
+
TE: "trailers",
|
|
210
|
+
"Transfer-Encoding": "chunked",
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
const result = proxy.getUpstreamHeaders(config, request);
|
|
214
|
+
assert.ok(!result.clientResponseHeaders["Connection"]);
|
|
215
|
+
assert.ok(!result.clientResponseHeaders["Keep-Alive"]);
|
|
216
|
+
assert.ok(!result.clientResponseHeaders["TE"]);
|
|
217
|
+
assert.ok(!result.clientResponseHeaders["Transfer-Encoding"]);
|
|
218
|
+
});
|
|
219
|
+
it("handles case-insensitive hop-by-hop header matching", () => {
|
|
220
|
+
const config = makeProxyEnabledConfig();
|
|
221
|
+
const request = {
|
|
222
|
+
hostname: "app.loop",
|
|
223
|
+
originalUrl: "/",
|
|
224
|
+
method: "GET",
|
|
225
|
+
headers: {
|
|
226
|
+
"CONNECTION": "keep-alive",
|
|
227
|
+
"KEEP-ALIVE": "timeout=5",
|
|
228
|
+
"TRANSFER-ENCODING": "chunked",
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const result = proxy.getUpstreamHeaders(config, request);
|
|
232
|
+
assert.ok(!result.clientResponseHeaders["CONNECTION"]);
|
|
233
|
+
assert.ok(!result.clientResponseHeaders["KEEP-ALIVE"]);
|
|
234
|
+
assert.ok(!result.clientResponseHeaders["TRANSFER-ENCODING"]);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
describe("HttpProxyService - Redirect Checks", () => {
|
|
238
|
+
const proxy = new HttpProxyService();
|
|
239
|
+
it("returns redirect info when enabled with valid URL", () => {
|
|
240
|
+
const config = makeRedirectConfig();
|
|
241
|
+
const result = proxy.checkRedirect(config);
|
|
242
|
+
assert.strictEqual(result?.code, 301);
|
|
243
|
+
assert.strictEqual(result?.target, "https://target.example.com/path");
|
|
244
|
+
});
|
|
245
|
+
it("returns null when redirect is not enabled", () => {
|
|
246
|
+
const config = makeConfig();
|
|
247
|
+
assert.strictEqual(proxy.checkRedirect(config), null);
|
|
248
|
+
});
|
|
249
|
+
it("returns null when proxy-only config (no redirect)", () => {
|
|
250
|
+
const config = makeProxyEnabledConfig();
|
|
251
|
+
assert.strictEqual(proxy.checkRedirect(config), null);
|
|
252
|
+
});
|
|
253
|
+
it("rejects redirects with relative URLs", () => {
|
|
254
|
+
const config = makeConfig({
|
|
255
|
+
redirect: {
|
|
256
|
+
enabled: true,
|
|
257
|
+
code: 301,
|
|
258
|
+
target: "/path/relative",
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
assert.strictEqual(proxy.checkRedirect(config), null);
|
|
262
|
+
});
|
|
263
|
+
it("rejects redirects with malformed URLs", () => {
|
|
264
|
+
const config = makeConfig({
|
|
265
|
+
redirect: {
|
|
266
|
+
enabled: true,
|
|
267
|
+
code: 301,
|
|
268
|
+
target: "not a valid url at all",
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
assert.strictEqual(proxy.checkRedirect(config), null);
|
|
272
|
+
});
|
|
273
|
+
it("rejects redirects with protocol-relative URLs", () => {
|
|
274
|
+
const config = makeConfig({
|
|
275
|
+
redirect: {
|
|
276
|
+
enabled: true,
|
|
277
|
+
code: 301,
|
|
278
|
+
target: "//evil.example.com",
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
assert.strictEqual(proxy.checkRedirect(config), null);
|
|
282
|
+
});
|
|
283
|
+
it("accepts redirect with query parameters in target", () => {
|
|
284
|
+
const config = makeConfig({
|
|
285
|
+
redirect: {
|
|
286
|
+
enabled: true,
|
|
287
|
+
code: 302,
|
|
288
|
+
target: "https://target.example.com/path?key=value&foo=bar",
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
const result = proxy.checkRedirect(config);
|
|
292
|
+
assert.strictEqual(result?.code, 302);
|
|
293
|
+
assert.ok(result?.target.includes("?key=value"));
|
|
294
|
+
});
|
|
295
|
+
it("accepts all valid redirect codes (301, 302, 303, 307, 308)", () => {
|
|
296
|
+
for (const code of [301, 302, 303, 307, 308]) {
|
|
297
|
+
const config = makeConfig({
|
|
298
|
+
redirect: { enabled: true, code, target: "https://example.com" },
|
|
299
|
+
});
|
|
300
|
+
assert.ok(proxy.checkRedirect(config));
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
it("rejects invalid redirect codes (300, 404, 999)", () => {
|
|
304
|
+
for (const code of [200, 300, 404, 500, 999]) {
|
|
305
|
+
const config = makeConfig({
|
|
306
|
+
redirect: { enabled: true, code, target: "https://example.com" },
|
|
307
|
+
});
|
|
308
|
+
assert.strictEqual(proxy.checkRedirect(config), null);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
describe("HttpProxyService - Timeout", () => {
|
|
313
|
+
const proxy = new HttpProxyService();
|
|
314
|
+
it("returns 0 timeout when proxy is disabled", () => {
|
|
315
|
+
const config = makeConfig();
|
|
316
|
+
assert.strictEqual(proxy.calculateTimeout(config), 0);
|
|
317
|
+
});
|
|
318
|
+
it("returns bounded timeout when proxy is enabled", () => {
|
|
319
|
+
const config = makeProxyEnabledConfig();
|
|
320
|
+
const timeout = proxy.calculateTimeout(config);
|
|
321
|
+
assert.ok(timeout >= 1000 && timeout <= 30000);
|
|
322
|
+
});
|
|
323
|
+
it("defaults to 5 seconds for enabled proxy", () => {
|
|
324
|
+
const config = makeProxyEnabledConfig();
|
|
325
|
+
assert.strictEqual(proxy.calculateTimeout(config), 5000);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
describe("HttpProxyService - Security Edge Cases", () => {
|
|
329
|
+
const proxy = new HttpProxyService();
|
|
330
|
+
it("sanitizes headers containing URL-encoded CR/LF percent sequences in values", () => {
|
|
331
|
+
assert.strictEqual(proxy.sanitizeHeader("encoded%0d%0aInjected"), null);
|
|
332
|
+
});
|
|
333
|
+
it("rejects header values with tab characters (HTTP smuggling vector)", () => {
|
|
334
|
+
assert.strictEqual(proxy.sanitizeHeader("value\twith\ttabs"), null);
|
|
335
|
+
});
|
|
336
|
+
it("rejects header values with unicode control characters", () => {
|
|
337
|
+
assert.strictEqual(proxy.sanitizeHeader("value\u000b\u000ccontrol"), null);
|
|
338
|
+
});
|
|
339
|
+
it("handles proxy config without custom headers gracefully", () => {
|
|
340
|
+
const config = makeConfig({
|
|
341
|
+
http_proxy: {
|
|
342
|
+
enabled: true,
|
|
343
|
+
upstream: "http://upstream.example.com",
|
|
344
|
+
headers: {},
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
const request = {
|
|
348
|
+
hostname: "app.loop",
|
|
349
|
+
originalUrl: "/",
|
|
350
|
+
method: "GET",
|
|
351
|
+
headers: {},
|
|
352
|
+
};
|
|
353
|
+
const result = proxy.getUpstreamHeaders(config, request);
|
|
354
|
+
assert.strictEqual(Object.keys(result.upstreamHeaders).length, 0);
|
|
355
|
+
assert.strictEqual(result.clientResponseHeaders["X-Proxy"], "zonzon");
|
|
356
|
+
});
|
|
357
|
+
it("handles empty host config records array gracefully", () => {
|
|
358
|
+
const config = makeConfig({ http_proxy: undefined });
|
|
359
|
+
assert.ok(proxy.checkRedirect(config) === null);
|
|
360
|
+
assert.strictEqual(proxy.calculateTimeout(config), 0);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
describe("HttpProxyService - Header Injection with Sanitization", () => {
|
|
364
|
+
const proxy = new HttpProxyService();
|
|
365
|
+
it("sanitizes injected custom headers before passing to response", () => {
|
|
366
|
+
const value = "injected\r\nMalicious: header";
|
|
367
|
+
const sanitized = proxy.sanitizeHeader(value);
|
|
368
|
+
assert.strictEqual(sanitized, null);
|
|
369
|
+
});
|
|
370
|
+
it("accepts safe config header values for injection", () => {
|
|
371
|
+
const value = "safe-value-with-dashes_and_underscores";
|
|
372
|
+
const sanitized = proxy.sanitizeHeader(value);
|
|
373
|
+
assert.strictEqual(sanitized, value);
|
|
374
|
+
});
|
|
375
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./schema.js";
|
|
3
|
+
export * from "./audit.js";
|
|
4
|
+
export * from "./firewall.js";
|
|
5
|
+
export * from "./rate-limiter.js";
|
|
6
|
+
export * from "./dns-service.js";
|
|
7
|
+
export * from "./dns-handler.js";
|
|
8
|
+
export * from "./http-proxy.js";
|
|
9
|
+
export * from "./http-handler.js";
|
|
10
|
+
export * from "./sni-proxy.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RateLimiterOptions {
|
|
2
|
+
maxRequests: number;
|
|
3
|
+
windowMs: number;
|
|
4
|
+
}
|
|
5
|
+
export declare class RateLimiter {
|
|
6
|
+
private options;
|
|
7
|
+
private buckets;
|
|
8
|
+
constructor(options: RateLimiterOptions);
|
|
9
|
+
allow(ip: string): boolean;
|
|
10
|
+
getRequestCount(ip: string): number;
|
|
11
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class RateLimiter {
|
|
2
|
+
options;
|
|
3
|
+
buckets = new Map();
|
|
4
|
+
constructor(options) {
|
|
5
|
+
this.options = options;
|
|
6
|
+
}
|
|
7
|
+
allow(ip) {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
let bucket = this.buckets.get(ip);
|
|
10
|
+
if (!bucket || now - bucket.timestamps[0] > this.options.windowMs) {
|
|
11
|
+
bucket = { timestamps: [now] };
|
|
12
|
+
this.buckets.set(ip, bucket);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
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) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
bucket.timestamps.push(now);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
getRequestCount(ip) {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const windowStart = now - this.options.windowMs;
|
|
28
|
+
const bucket = this.buckets.get(ip);
|
|
29
|
+
if (!bucket)
|
|
30
|
+
return 0;
|
|
31
|
+
return bucket.timestamps.filter((t) => t >= windowStart).length;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "assert";
|
|
3
|
+
import * as net from "net";
|
|
4
|
+
let PORT_BASE = 65000;
|
|
5
|
+
function nextPort() { return PORT_BASE++; }
|
|
6
|
+
describe("TCP Rate Limiting", () => {
|
|
7
|
+
function buildTcpDnsQuery(name) {
|
|
8
|
+
const encoder = new (class {
|
|
9
|
+
buf = Buffer.alloc(256);
|
|
10
|
+
offset = 0;
|
|
11
|
+
writeUint16(v) { this.buf.writeUInt16BE(v, this.offset); this.offset += 2; }
|
|
12
|
+
writeUint8(v) { this.buf.writeUInt8(v, this.offset); this.offset += 1; }
|
|
13
|
+
writeDomainName(nm) {
|
|
14
|
+
for (const label of nm.split(".")) {
|
|
15
|
+
if (!label.length)
|
|
16
|
+
continue;
|
|
17
|
+
this.writeUint8(label.length);
|
|
18
|
+
Buffer.from(label).copy(this.buf, this.offset);
|
|
19
|
+
this.offset += label.length;
|
|
20
|
+
}
|
|
21
|
+
this.writeUint8(0);
|
|
22
|
+
}
|
|
23
|
+
finish() { return this.buf.subarray(0, this.offset); }
|
|
24
|
+
})();
|
|
25
|
+
encoder.writeUint16(0xDEAD);
|
|
26
|
+
encoder.writeUint16(0x0100);
|
|
27
|
+
encoder.writeUint16(1);
|
|
28
|
+
encoder.writeUint16(0);
|
|
29
|
+
encoder.writeUint16(0);
|
|
30
|
+
encoder.writeUint16(0);
|
|
31
|
+
encoder.writeDomainName(name);
|
|
32
|
+
encoder.writeUint16(1);
|
|
33
|
+
encoder.writeUint16(1);
|
|
34
|
+
const query = encoder.finish();
|
|
35
|
+
const prefixed = Buffer.alloc(2 + query.length);
|
|
36
|
+
prefixed.writeUInt16BE(query.length, 0);
|
|
37
|
+
query.copy(prefixed, 2);
|
|
38
|
+
return prefixed;
|
|
39
|
+
}
|
|
40
|
+
it("TCP queries are rate limited when configured", async () => {
|
|
41
|
+
const port = nextPort();
|
|
42
|
+
const { DnsHandler } = await import("./dns-handler.js");
|
|
43
|
+
const { DevDnsServer } = await import("./dns-service.js");
|
|
44
|
+
const config = { port, hosts: { "test.loop": { records: [{ type: "A", address: "5.6.7.8" }] } }, rateLimitMaxRequests: 3, rateLimitWindowMs: 1000 };
|
|
45
|
+
const dnsServer = new DevDnsServer(config);
|
|
46
|
+
const handler = new DnsHandler(dnsServer, config);
|
|
47
|
+
await handler.start();
|
|
48
|
+
try {
|
|
49
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
50
|
+
const results = [];
|
|
51
|
+
for (let i = 0; i < 3; i++) {
|
|
52
|
+
let responses = 0;
|
|
53
|
+
await new Promise((resolve) => {
|
|
54
|
+
const socket = net.createConnection(port, "127.0.0.1", () => {
|
|
55
|
+
socket.write(buildTcpDnsQuery("test.loop"));
|
|
56
|
+
socket.on("data", (data) => {
|
|
57
|
+
let off = 0;
|
|
58
|
+
while (off + 2 <= data.length) {
|
|
59
|
+
const len = data.readUInt16BE(off);
|
|
60
|
+
if (len === 0 || off + 2 + len > data.length)
|
|
61
|
+
break;
|
|
62
|
+
responses++;
|
|
63
|
+
off += 2 + len;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
setTimeout(() => { socket.destroy(); resolve(); }, 1000);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
results.push(responses);
|
|
70
|
+
}
|
|
71
|
+
assert.ok(results.every((r) => r > 0));
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await handler.stop();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it("TCP connection is terminated when source IP exceeds rate limit", async () => {
|
|
78
|
+
const port = nextPort();
|
|
79
|
+
const { DnsHandler } = await import("./dns-handler.js");
|
|
80
|
+
const { DevDnsServer } = await import("./dns-service.js");
|
|
81
|
+
const config = { port, hosts: { "test.loop": { records: [{ type: "A", address: "9.8.7.6" }] } }, rateLimitMaxRequests: 1, rateLimitWindowMs: 5000 };
|
|
82
|
+
const dnsServer = new DevDnsServer(config);
|
|
83
|
+
const handler = new DnsHandler(dnsServer, config);
|
|
84
|
+
await handler.start();
|
|
85
|
+
try {
|
|
86
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
87
|
+
let r1Responses = 0;
|
|
88
|
+
await new Promise((resolve) => {
|
|
89
|
+
const socket = net.createConnection(port, "127.0.0.1", () => {
|
|
90
|
+
socket.write(buildTcpDnsQuery("test.loop"));
|
|
91
|
+
socket.on("data", (data) => {
|
|
92
|
+
let off = 0;
|
|
93
|
+
while (off + 2 <= data.length) {
|
|
94
|
+
const len = data.readUInt16BE(off);
|
|
95
|
+
if (len === 0 || off + 2 + len > data.length)
|
|
96
|
+
break;
|
|
97
|
+
r1Responses++;
|
|
98
|
+
off += 2 + len;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
setTimeout(() => { socket.destroy(); resolve(); }, 1000);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
assert.ok(r1Responses > 0);
|
|
105
|
+
let r2Destroyed = false;
|
|
106
|
+
await new Promise((resolve) => {
|
|
107
|
+
const socket = net.createConnection(port, "127.0.0.1", () => {
|
|
108
|
+
setTimeout(() => { socket.destroy(); resolve(); }, 1500);
|
|
109
|
+
});
|
|
110
|
+
socket.on("error", () => { r2Destroyed = true; });
|
|
111
|
+
socket.on("close", () => { if (!r2Destroyed)
|
|
112
|
+
r2Destroyed = true; });
|
|
113
|
+
});
|
|
114
|
+
assert.strictEqual(r2Destroyed, true);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await handler.stop();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
it("TCP queries work when rate limiting is disabled", async () => {
|
|
121
|
+
const port = nextPort();
|
|
122
|
+
const { DnsHandler } = await import("./dns-handler.js");
|
|
123
|
+
const { DevDnsServer } = await import("./dns-service.js");
|
|
124
|
+
const config = { port, hosts: { "test.loop": { records: [{ type: "A", address: "1.2.3.4" }] } }, rateLimitMaxRequests: 0 };
|
|
125
|
+
const dnsServer = new DevDnsServer(config);
|
|
126
|
+
const handler = new DnsHandler(dnsServer, config);
|
|
127
|
+
await handler.start();
|
|
128
|
+
try {
|
|
129
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
130
|
+
let successes = 0;
|
|
131
|
+
for (let i = 0; i < 5; i++) {
|
|
132
|
+
let gotResponse = false;
|
|
133
|
+
await new Promise((resolve) => {
|
|
134
|
+
const socket = net.createConnection(port, "127.0.0.1", () => {
|
|
135
|
+
socket.write(buildTcpDnsQuery("test.loop"));
|
|
136
|
+
socket.on("data", () => { gotResponse = true; });
|
|
137
|
+
setTimeout(() => { socket.destroy(); resolve(); }, 1000);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
if (gotResponse)
|
|
141
|
+
successes++;
|
|
142
|
+
}
|
|
143
|
+
assert.strictEqual(successes, 5);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await handler.stop();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { HostConfig, DnsRecord, ServerConfig } from "./types.js";
|
|
2
|
+
export declare function validateARecord(record: unknown): DnsRecord;
|
|
3
|
+
export declare function validateAAAARecord(record: unknown): DnsRecord;
|
|
4
|
+
export declare function validateCNAME(record: unknown): DnsRecord;
|
|
5
|
+
export declare function validateTXT(record: unknown): DnsRecord;
|
|
6
|
+
export declare function validateMX(record: unknown): DnsRecord;
|
|
7
|
+
export declare function validateNS(record: unknown): DnsRecord;
|
|
8
|
+
export declare function validateSRV(record: unknown): DnsRecord;
|
|
9
|
+
export declare function validatePTR(record: unknown): DnsRecord;
|
|
10
|
+
export declare function validateRecord(record: unknown): DnsRecord;
|
|
11
|
+
export declare function validateHostConfig(config: unknown): HostConfig;
|
|
12
|
+
export declare function validateServerConfig(config: unknown): ServerConfig;
|