claw-dashboard 1.9.0 → 2.0.0
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/.c8rc.json +38 -0
- package/.dockerignore +68 -0
- package/.github/dependabot.yml +45 -0
- package/.github/pull_request_template.md +39 -0
- package/.github/workflows/ci.yml +147 -0
- package/.github/workflows/release.yml +40 -0
- package/.github/workflows/security.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.planning/codebase/ARCHITECTURE.md +206 -0
- package/.planning/codebase/INTEGRATIONS.md +150 -0
- package/.planning/codebase/STACK.md +122 -0
- package/.planning/codebase/STRUCTURE.md +201 -0
- package/CHANGELOG.md +293 -0
- package/CONTRIBUTING.md +378 -0
- package/Dockerfile +54 -0
- package/FEATURES.md +195 -21
- package/README.md +83 -6
- package/TODO.md +28 -0
- package/build-cjs.js +127 -0
- package/cjs-shim.js +13 -0
- package/dist/clawdash +1729 -0
- package/dist/clawdash.meta.json +2236 -0
- package/dist/widgets.cjs +6511 -0
- package/docker-compose.yml +67 -0
- package/docs/API.md +1050 -0
- package/docs/PLUGINS.md +1504 -0
- package/esbuild.config.js +158 -0
- package/eslint.config.js +56 -0
- package/examples/plugins/README.md +122 -0
- package/examples/plugins/api-status/index.js +294 -0
- package/examples/plugins/api-status/plugin.json +19 -0
- package/examples/plugins/hello-world/index.js +104 -0
- package/examples/plugins/hello-world/plugin.json +15 -0
- package/examples/plugins/system-metrics-chart/index.js +339 -0
- package/examples/plugins/system-metrics-chart/plugin.json +18 -0
- package/examples/plugins/weather-widget/index.js +136 -0
- package/examples/plugins/weather-widget/plugin.json +19 -0
- package/index.cjs +23005 -0
- package/index.js +5236 -566
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -5
- package/schemas/plugin-manifest.json +128 -0
- package/scripts/release.js +595 -0
- package/src/alerts.js +693 -0
- package/src/auto-save.js +584 -0
- package/src/cache.js +390 -0
- package/src/checksum.js +146 -0
- package/src/cli/args.js +110 -0
- package/src/cli/export-schedule.js +423 -0
- package/src/cli/export-snapshot.js +138 -0
- package/src/cli/help.js +69 -0
- package/src/cli/import-snapshot.js +230 -0
- package/src/cli/index.js +14 -0
- package/src/cli/list-templates.js +69 -0
- package/src/cli/validate-config.js +76 -0
- package/src/cli/validate-plugin.js +187 -0
- package/src/cli/version.js +15 -0
- package/src/config-validator.js +586 -0
- package/src/config-watcher.js +401 -0
- package/src/config.js +684 -0
- package/src/container-detector.js +499 -0
- package/src/database.js +734 -0
- package/src/differential-render.js +327 -0
- package/src/errors.js +169 -0
- package/src/export-scheduler.js +581 -0
- package/src/gateway-manager.js +685 -0
- package/src/hints.js +371 -0
- package/src/loading-states.js +509 -0
- package/src/logger.js +185 -0
- package/src/memory-pressure.js +467 -0
- package/src/performance-monitor.js +374 -0
- package/src/plugin-errors.js +586 -0
- package/src/plugin-manifest-validator.js +219 -0
- package/src/plugin-reload.js +481 -0
- package/src/plugin-scaffold.js +1941 -0
- package/src/plugin-validator.js +284 -0
- package/src/retry.js +218 -0
- package/src/security.js +860 -0
- package/src/snapshot.js +372 -0
- package/src/splash.js +115 -0
- package/src/theme-selector.js +411 -0
- package/src/themes.js +874 -0
- package/src/transitions.js +534 -0
- package/src/types.d.ts +174 -0
- package/src/validation.js +926 -0
- package/src/web-server.js +885 -0
- package/src/widgets/builtin-widgets.js +1057 -0
- package/src/widgets/config-processor.js +401 -0
- package/src/widgets/dependency-resolver.js +596 -0
- package/src/widgets/index.js +79 -0
- package/src/widgets/plugin-api.js +845 -0
- package/src/widgets/widget-error-boundary.js +773 -0
- package/src/widgets/widget-error-isolation.js +551 -0
- package/src/widgets/widget-loader.js +1437 -0
- package/src/workers/system-worker.js +130 -0
- package/src/workers/worker-pool.js +633 -0
- package/tests/alerts.test.js +437 -0
- package/tests/auto-save.test.js +529 -0
- package/tests/cache.test.js +317 -0
- package/tests/cli.test.js +351 -0
- package/tests/command-palette.test.js +320 -0
- package/tests/config-processor.test.js +452 -0
- package/tests/config-validator.test.js +710 -0
- package/tests/config-watcher.test.js +594 -0
- package/tests/config.test.js +755 -0
- package/tests/database.test.js +438 -0
- package/tests/errors.test.js +189 -0
- package/tests/example-plugins.test.js +624 -0
- package/tests/integration.test.js +1321 -0
- package/tests/loading-states.test.js +300 -0
- package/tests/manifest-validation-on-load.test.js +671 -0
- package/tests/performance-monitor.test.js +190 -0
- package/tests/plugin-api-rate-limit.test.js +302 -0
- package/tests/plugin-errors.test.js +311 -0
- package/tests/plugin-lifecycle-e2e.test.js +1036 -0
- package/tests/plugin-reload.test.js +764 -0
- package/tests/rate-limiter.test.js +539 -0
- package/tests/retry.test.js +308 -0
- package/tests/security.test.js +411 -0
- package/tests/settings-modal.test.js +226 -0
- package/tests/theme-selector.test.js +57 -0
- package/tests/utils.js +242 -0
- package/tests/utils.test.js +317 -0
- package/tests/validate-plugin-cli.test.js +359 -0
- package/tests/validation.test.js +837 -0
- package/tests/web-server.test.js +646 -0
- package/tests/widget-arrange-mode.test.js +183 -0
- package/tests/widget-config-hot-reload.test.js +465 -0
- package/tests/widget-dependency.test.js +591 -0
- package/tests/widget-error-boundary.test.js +749 -0
- package/tests/widget-error-isolation.test.js +469 -0
- package/tests/widget-integration.test.js +1147 -0
- package/tests/widget-refresh-intervals.test.js +284 -0
- package/tests/worker-pool.test.js +522 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for web server security features (rate limiting and CORS)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import http from 'http';
|
|
6
|
+
import { WebServer, WebRateLimiter, CorsManager } from '../src/web-server.js';
|
|
7
|
+
import config from '../src/config.js';
|
|
8
|
+
|
|
9
|
+
describe('Web Server Security', () => {
|
|
10
|
+
describe('WebRateLimiter', () => {
|
|
11
|
+
let limiter;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
limiter = new WebRateLimiter({
|
|
15
|
+
enabled: true,
|
|
16
|
+
windowMs: 60000,
|
|
17
|
+
maxRequests: 5,
|
|
18
|
+
trustProxy: false,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
limiter.stop();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('getClientIp', () => {
|
|
27
|
+
test('should extract IP from socket remoteAddress', () => {
|
|
28
|
+
const req = {
|
|
29
|
+
socket: { remoteAddress: '192.168.1.1' },
|
|
30
|
+
headers: {},
|
|
31
|
+
};
|
|
32
|
+
expect(limiter.getClientIp(req)).toBe('192.168.1.1');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('should extract IP from connection remoteAddress', () => {
|
|
36
|
+
const req = {
|
|
37
|
+
connection: { remoteAddress: '192.168.1.2' },
|
|
38
|
+
socket: null,
|
|
39
|
+
headers: {},
|
|
40
|
+
};
|
|
41
|
+
expect(limiter.getClientIp(req)).toBe('192.168.1.2');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('should use X-Forwarded-For when trustProxy is enabled', () => {
|
|
45
|
+
limiter.trustProxy = true;
|
|
46
|
+
const req = {
|
|
47
|
+
socket: { remoteAddress: '192.168.1.1' },
|
|
48
|
+
headers: { 'x-forwarded-for': '10.0.0.1, 10.0.0.2' },
|
|
49
|
+
};
|
|
50
|
+
expect(limiter.getClientIp(req)).toBe('10.0.0.1');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('should use X-Real-IP when trustProxy is enabled', () => {
|
|
54
|
+
limiter.trustProxy = true;
|
|
55
|
+
const req = {
|
|
56
|
+
socket: { remoteAddress: '192.168.1.1' },
|
|
57
|
+
headers: { 'x-real-ip': '10.0.0.3' },
|
|
58
|
+
};
|
|
59
|
+
expect(limiter.getClientIp(req)).toBe('10.0.0.3');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('should fall back to socket address when proxy headers missing', () => {
|
|
63
|
+
limiter.trustProxy = true;
|
|
64
|
+
const req = {
|
|
65
|
+
socket: { remoteAddress: '192.168.1.1' },
|
|
66
|
+
headers: {},
|
|
67
|
+
};
|
|
68
|
+
expect(limiter.getClientIp(req)).toBe('192.168.1.1');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('should return unknown when no IP available', () => {
|
|
72
|
+
const req = {
|
|
73
|
+
socket: null,
|
|
74
|
+
connection: null,
|
|
75
|
+
headers: {},
|
|
76
|
+
};
|
|
77
|
+
expect(limiter.getClientIp(req)).toBe('unknown');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('check', () => {
|
|
82
|
+
test('should allow all requests when disabled', () => {
|
|
83
|
+
limiter.enabled = false;
|
|
84
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
const result = limiter.check(req);
|
|
88
|
+
expect(result.allowed).toBe(true);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('should allow requests under limit', () => {
|
|
93
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
94
|
+
|
|
95
|
+
const result = limiter.check(req);
|
|
96
|
+
expect(result.allowed).toBe(true);
|
|
97
|
+
expect(result.remaining).toBe(5);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('should track requests per IP separately', () => {
|
|
101
|
+
const req1 = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
102
|
+
const req2 = { socket: { remoteAddress: '192.168.1.2' }, headers: {} };
|
|
103
|
+
|
|
104
|
+
// Fill up limiter for IP 1
|
|
105
|
+
for (let i = 0; i < 5; i++) {
|
|
106
|
+
limiter.record(req1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// IP 1 should be blocked
|
|
110
|
+
expect(limiter.check(req1).allowed).toBe(false);
|
|
111
|
+
|
|
112
|
+
// IP 2 should still be allowed
|
|
113
|
+
expect(limiter.check(req2).allowed).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('should block requests over limit', () => {
|
|
117
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
118
|
+
|
|
119
|
+
// Fill up limit
|
|
120
|
+
for (let i = 0; i < 5; i++) {
|
|
121
|
+
limiter.record(req);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const result = limiter.check(req);
|
|
125
|
+
expect(result.allowed).toBe(false);
|
|
126
|
+
expect(result.retryAfter).toBeGreaterThan(0);
|
|
127
|
+
expect(result.ip).toBe('192.168.1.1');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('should include retryAfter when blocked', () => {
|
|
131
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
132
|
+
|
|
133
|
+
// Fill up limit
|
|
134
|
+
for (let i = 0; i < 5; i++) {
|
|
135
|
+
limiter.record(req);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result = limiter.check(req);
|
|
139
|
+
expect(result.retryAfter).toBeGreaterThanOrEqual(1);
|
|
140
|
+
expect(result.retryAfter).toBeLessThanOrEqual(60);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('record', () => {
|
|
145
|
+
test('should not record when disabled', () => {
|
|
146
|
+
limiter.enabled = false;
|
|
147
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
148
|
+
|
|
149
|
+
limiter.record(req);
|
|
150
|
+
limiter.record(req);
|
|
151
|
+
limiter.record(req);
|
|
152
|
+
|
|
153
|
+
const status = limiter.getStatus(req);
|
|
154
|
+
expect(status.current).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('should record request timestamp', () => {
|
|
158
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
159
|
+
|
|
160
|
+
limiter.record(req);
|
|
161
|
+
limiter.record(req);
|
|
162
|
+
|
|
163
|
+
const status = limiter.getStatus(req);
|
|
164
|
+
expect(status.current).toBe(2);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('getStatus', () => {
|
|
169
|
+
test('should return status when disabled', () => {
|
|
170
|
+
limiter.enabled = false;
|
|
171
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
172
|
+
|
|
173
|
+
const status = limiter.getStatus(req);
|
|
174
|
+
expect(status.enabled).toBe(false);
|
|
175
|
+
expect(status.limit).toBe(5);
|
|
176
|
+
expect(status.remaining).toBe(5);
|
|
177
|
+
expect(status.resetTime).toBe(null);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('should return current count and remaining', () => {
|
|
181
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
182
|
+
|
|
183
|
+
limiter.record(req);
|
|
184
|
+
limiter.record(req);
|
|
185
|
+
|
|
186
|
+
const status = limiter.getStatus(req);
|
|
187
|
+
expect(status.enabled).toBe(true);
|
|
188
|
+
expect(status.limit).toBe(5);
|
|
189
|
+
expect(status.current).toBe(2);
|
|
190
|
+
expect(status.remaining).toBe(3);
|
|
191
|
+
expect(status.ip).toBe('192.168.1.1');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('should return resetTime based on oldest request', () => {
|
|
195
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
196
|
+
|
|
197
|
+
limiter.record(req);
|
|
198
|
+
|
|
199
|
+
const status = limiter.getStatus(req);
|
|
200
|
+
expect(status.resetTime).toBeTruthy();
|
|
201
|
+
expect(new Date(status.resetTime).getTime()).toBeGreaterThan(Date.now());
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('cleanup', () => {
|
|
206
|
+
test('should remove expired timestamps', async () => {
|
|
207
|
+
limiter.windowMs = 100; // Short window for testing
|
|
208
|
+
const req = { socket: { remoteAddress: '192.168.1.1' }, headers: {} };
|
|
209
|
+
|
|
210
|
+
limiter.record(req);
|
|
211
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
212
|
+
|
|
213
|
+
limiter.cleanup();
|
|
214
|
+
|
|
215
|
+
const status = limiter.getStatus(req);
|
|
216
|
+
expect(status.current).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('CorsManager', () => {
|
|
222
|
+
describe('isOriginAllowed', () => {
|
|
223
|
+
test('should allow all origins with *', () => {
|
|
224
|
+
const cors = new CorsManager({ allowedOrigins: '*' });
|
|
225
|
+
expect(cors.isOriginAllowed('https://example.com')).toBe(true);
|
|
226
|
+
expect(cors.isOriginAllowed('https://other.com')).toBe(true);
|
|
227
|
+
expect(cors.isOriginAllowed(null)).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('should allow exact match', () => {
|
|
231
|
+
const cors = new CorsManager({ allowedOrigins: 'https://example.com' });
|
|
232
|
+
expect(cors.isOriginAllowed('https://example.com')).toBe(true);
|
|
233
|
+
expect(cors.isOriginAllowed('https://other.com')).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('should allow from array of origins', () => {
|
|
237
|
+
const cors = new CorsManager({
|
|
238
|
+
allowedOrigins: ['https://example.com', 'https://other.com'],
|
|
239
|
+
});
|
|
240
|
+
expect(cors.isOriginAllowed('https://example.com')).toBe(true);
|
|
241
|
+
expect(cors.isOriginAllowed('https://other.com')).toBe(true);
|
|
242
|
+
expect(cors.isOriginAllowed('https://evil.com')).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('should support wildcard patterns', () => {
|
|
246
|
+
const cors = new CorsManager({
|
|
247
|
+
allowedOrigins: ['https://*.example.com'],
|
|
248
|
+
});
|
|
249
|
+
expect(cors.isOriginAllowed('https://app.example.com')).toBe(true);
|
|
250
|
+
expect(cors.isOriginAllowed('https://sub.app.example.com')).toBe(true);
|
|
251
|
+
expect(cors.isOriginAllowed('https://other.com')).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('should allow null origin (direct requests)', () => {
|
|
255
|
+
const cors = new CorsManager({ allowedOrigins: ['https://example.com'] });
|
|
256
|
+
expect(cors.isOriginAllowed(null)).toBe(true);
|
|
257
|
+
expect(cors.isOriginAllowed(undefined)).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('getHeaders', () => {
|
|
262
|
+
test('should return correct CORS headers for allow-all', () => {
|
|
263
|
+
const cors = new CorsManager({ allowedOrigins: '*' });
|
|
264
|
+
const req = { headers: { origin: 'https://example.com' } };
|
|
265
|
+
const headers = cors.getHeaders(req);
|
|
266
|
+
|
|
267
|
+
expect(headers['Access-Control-Allow-Origin']).toBe('*');
|
|
268
|
+
expect(headers['Access-Control-Allow-Methods']).toContain('GET');
|
|
269
|
+
expect(headers['Access-Control-Allow-Methods']).toContain('POST');
|
|
270
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('should mirror origin when credentials enabled with *', () => {
|
|
274
|
+
const cors = new CorsManager({
|
|
275
|
+
allowedOrigins: '*',
|
|
276
|
+
credentials: true,
|
|
277
|
+
});
|
|
278
|
+
const req = { headers: { origin: 'https://example.com' } };
|
|
279
|
+
const headers = cors.getHeaders(req);
|
|
280
|
+
|
|
281
|
+
expect(headers['Access-Control-Allow-Origin']).toBe('https://example.com');
|
|
282
|
+
expect(headers['Access-Control-Allow-Credentials']).toBe('true');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('should include credentials header when enabled', () => {
|
|
286
|
+
const cors = new CorsManager({
|
|
287
|
+
allowedOrigins: 'https://example.com',
|
|
288
|
+
credentials: true,
|
|
289
|
+
});
|
|
290
|
+
const req = { headers: { origin: 'https://example.com' } };
|
|
291
|
+
const headers = cors.getHeaders(req);
|
|
292
|
+
|
|
293
|
+
expect(headers['Access-Control-Allow-Credentials']).toBe('true');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('should use configured methods and headers', () => {
|
|
297
|
+
const cors = new CorsManager({
|
|
298
|
+
allowedOrigins: '*',
|
|
299
|
+
allowedMethods: ['GET', 'DELETE'],
|
|
300
|
+
allowedHeaders: ['Content-Type', 'X-Custom-Header'],
|
|
301
|
+
maxAge: 3600,
|
|
302
|
+
});
|
|
303
|
+
const req = { headers: {} };
|
|
304
|
+
const headers = cors.getHeaders(req);
|
|
305
|
+
|
|
306
|
+
expect(headers['Access-Control-Allow-Methods']).toBe('GET, DELETE');
|
|
307
|
+
expect(headers['Access-Control-Allow-Headers']).toBe('Content-Type, X-Custom-Header');
|
|
308
|
+
expect(headers['Access-Control-Max-Age']).toBe('3600');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('should not include CORS headers for disallowed origin', () => {
|
|
312
|
+
const cors = new CorsManager({ allowedOrigins: 'https://example.com' });
|
|
313
|
+
const req = { headers: { origin: 'https://evil.com' } };
|
|
314
|
+
const headers = cors.getHeaders(req);
|
|
315
|
+
|
|
316
|
+
// Should not have Access-Control-Allow-Origin for disallowed origin
|
|
317
|
+
expect(headers['Access-Control-Allow-Origin']).toBeUndefined();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe('WebServer Integration', () => {
|
|
323
|
+
let servers = [];
|
|
324
|
+
|
|
325
|
+
afterEach(async () => {
|
|
326
|
+
// Stop all servers
|
|
327
|
+
for (const server of servers) {
|
|
328
|
+
try {
|
|
329
|
+
await server.stop();
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// Ignore errors during cleanup
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
servers = [];
|
|
335
|
+
// Wait a bit for ports to be released
|
|
336
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('should start server with rate limiting enabled', async () => {
|
|
340
|
+
const server = new WebServer({
|
|
341
|
+
port: 0, // Let OS assign port
|
|
342
|
+
host: '127.0.0.1', // Bind to localhost only for tests
|
|
343
|
+
rateLimit: {
|
|
344
|
+
enabled: true,
|
|
345
|
+
windowMs: 60000,
|
|
346
|
+
maxRequests: 10,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
servers.push(server);
|
|
350
|
+
|
|
351
|
+
await server.start();
|
|
352
|
+
|
|
353
|
+
const info = server.getInfo();
|
|
354
|
+
expect(info.security.rateLimit.enabled).toBe(true);
|
|
355
|
+
expect(info.security.rateLimit.maxRequests).toBe(10);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('should start server with restricted CORS', async () => {
|
|
359
|
+
const server = new WebServer({
|
|
360
|
+
port: 0,
|
|
361
|
+
host: '127.0.0.1',
|
|
362
|
+
corsOrigins: ['https://example.com'],
|
|
363
|
+
corsCredentials: true,
|
|
364
|
+
});
|
|
365
|
+
servers.push(server);
|
|
366
|
+
|
|
367
|
+
await server.start();
|
|
368
|
+
|
|
369
|
+
const info = server.getInfo();
|
|
370
|
+
expect(info.security.cors.mode).toBe('restricted');
|
|
371
|
+
expect(info.security.cors.credentials).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('should start server with allow-all CORS', async () => {
|
|
375
|
+
const server = new WebServer({
|
|
376
|
+
port: 0,
|
|
377
|
+
host: '127.0.0.1',
|
|
378
|
+
corsOrigins: '*',
|
|
379
|
+
});
|
|
380
|
+
servers.push(server);
|
|
381
|
+
|
|
382
|
+
await server.start();
|
|
383
|
+
|
|
384
|
+
const info = server.getInfo();
|
|
385
|
+
expect(info.security.cors.mode).toBe('allow-all');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('should track request and error counts', async () => {
|
|
389
|
+
const server = new WebServer({
|
|
390
|
+
port: 0,
|
|
391
|
+
host: '127.0.0.1',
|
|
392
|
+
rateLimit: {
|
|
393
|
+
enabled: false, // Disable to avoid rate limiting in this test
|
|
394
|
+
maxRequests: 5,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
servers.push(server);
|
|
398
|
+
|
|
399
|
+
await server.start();
|
|
400
|
+
const port = server.server.address().port;
|
|
401
|
+
|
|
402
|
+
// Make a request to health endpoint
|
|
403
|
+
await new Promise((resolve) => {
|
|
404
|
+
http.get(`http://127.0.0.1:${port}/health`, (res) => {
|
|
405
|
+
res.resume();
|
|
406
|
+
res.on('end', resolve);
|
|
407
|
+
}).on('error', resolve);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const info = server.getInfo();
|
|
411
|
+
expect(info.requests).toBeGreaterThanOrEqual(1);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe('HTTP Endpoint Security', () => {
|
|
416
|
+
let server;
|
|
417
|
+
let port;
|
|
418
|
+
|
|
419
|
+
beforeEach(async () => {
|
|
420
|
+
server = new WebServer({
|
|
421
|
+
port: 0,
|
|
422
|
+
host: '127.0.0.1',
|
|
423
|
+
rateLimit: {
|
|
424
|
+
enabled: true,
|
|
425
|
+
windowMs: 60000,
|
|
426
|
+
maxRequests: 3,
|
|
427
|
+
},
|
|
428
|
+
corsOrigins: ['https://allowed.com'],
|
|
429
|
+
});
|
|
430
|
+
server.setDataProvider(async (type) => {
|
|
431
|
+
if (type === 'metrics') return { cpu: 50 };
|
|
432
|
+
if (type === 'sessions') return [];
|
|
433
|
+
if (type === 'agents') return [];
|
|
434
|
+
if (type === 'logs') return [];
|
|
435
|
+
return null;
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await server.start();
|
|
439
|
+
port = server.server.address().port;
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
afterEach(async () => {
|
|
443
|
+
if (server) {
|
|
444
|
+
await server.stop();
|
|
445
|
+
server = null;
|
|
446
|
+
}
|
|
447
|
+
// Wait for port release
|
|
448
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('should return 429 when rate limit exceeded', async () => {
|
|
452
|
+
const makeRequest = () => new Promise((resolve) => {
|
|
453
|
+
http.get(`http://127.0.0.1:${port}/metrics`, (res) => {
|
|
454
|
+
res.resume();
|
|
455
|
+
resolve({ status: res.statusCode, headers: res.headers });
|
|
456
|
+
}).on('error', () => resolve({ status: 0, headers: {} }));
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Make requests up to limit
|
|
460
|
+
await makeRequest();
|
|
461
|
+
await makeRequest();
|
|
462
|
+
await makeRequest();
|
|
463
|
+
|
|
464
|
+
// Next request should be rate limited
|
|
465
|
+
const response = await makeRequest();
|
|
466
|
+
|
|
467
|
+
expect(response.status).toBe(429);
|
|
468
|
+
expect(response.headers['retry-after']).toBeDefined();
|
|
469
|
+
expect(response.headers['x-ratelimit-remaining']).toBe('0');
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('should include rate limit headers on successful requests', async () => new Promise((resolve) => {
|
|
473
|
+
http.get(`http://127.0.0.1:${port}/metrics`, (res) => {
|
|
474
|
+
expect(res.statusCode).toBe(200);
|
|
475
|
+
expect(res.headers['x-ratelimit-limit']).toBeDefined();
|
|
476
|
+
expect(res.headers['x-ratelimit-remaining']).toBeDefined();
|
|
477
|
+
res.resume();
|
|
478
|
+
res.on('end', resolve);
|
|
479
|
+
}).on('error', resolve);
|
|
480
|
+
}));
|
|
481
|
+
|
|
482
|
+
test('should return 403 for disallowed CORS origin', async () => {
|
|
483
|
+
const options = {
|
|
484
|
+
hostname: '127.0.0.1',
|
|
485
|
+
port,
|
|
486
|
+
path: '/metrics',
|
|
487
|
+
method: 'GET',
|
|
488
|
+
headers: {
|
|
489
|
+
'Origin': 'https://evil.com',
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
return new Promise((resolve) => {
|
|
494
|
+
const req = http.request(options, (res) => {
|
|
495
|
+
expect(res.statusCode).toBe(403);
|
|
496
|
+
res.resume();
|
|
497
|
+
res.on('end', resolve);
|
|
498
|
+
});
|
|
499
|
+
req.on('error', resolve);
|
|
500
|
+
req.end();
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('should allow request from allowed CORS origin', async () => {
|
|
505
|
+
const options = {
|
|
506
|
+
hostname: '127.0.0.1',
|
|
507
|
+
port,
|
|
508
|
+
path: '/metrics',
|
|
509
|
+
method: 'GET',
|
|
510
|
+
headers: {
|
|
511
|
+
'Origin': 'https://allowed.com',
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
return new Promise((resolve) => {
|
|
516
|
+
const req = http.request(options, (res) => {
|
|
517
|
+
expect(res.statusCode).toBe(200);
|
|
518
|
+
expect(res.headers['access-control-allow-origin']).toBe('https://allowed.com');
|
|
519
|
+
res.resume();
|
|
520
|
+
res.on('end', resolve);
|
|
521
|
+
});
|
|
522
|
+
req.on('error', resolve);
|
|
523
|
+
req.end();
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('should handle CORS preflight requests', async () => {
|
|
528
|
+
const options = {
|
|
529
|
+
hostname: '127.0.0.1',
|
|
530
|
+
port,
|
|
531
|
+
path: '/metrics',
|
|
532
|
+
method: 'OPTIONS',
|
|
533
|
+
headers: {
|
|
534
|
+
'Origin': 'https://allowed.com',
|
|
535
|
+
'Access-Control-Request-Method': 'GET',
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
return new Promise((resolve) => {
|
|
540
|
+
const req = http.request(options, (res) => {
|
|
541
|
+
expect(res.statusCode).toBe(200);
|
|
542
|
+
expect(res.headers['access-control-allow-origin']).toBe('https://allowed.com');
|
|
543
|
+
res.resume();
|
|
544
|
+
res.on('end', resolve);
|
|
545
|
+
});
|
|
546
|
+
req.on('error', resolve);
|
|
547
|
+
req.end();
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('should skip rate limiting for health endpoint', async () => {
|
|
552
|
+
// Make many requests to health endpoint
|
|
553
|
+
for (let i = 0; i < 10; i++) {
|
|
554
|
+
await new Promise((resolve) => {
|
|
555
|
+
http.get(`http://127.0.0.1:${port}/health`, (res) => {
|
|
556
|
+
expect(res.statusCode).toBe(200);
|
|
557
|
+
res.resume();
|
|
558
|
+
res.on('end', resolve);
|
|
559
|
+
}).on('error', resolve);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('health endpoint should include rate limit info', async () => {
|
|
565
|
+
return new Promise((resolve) => {
|
|
566
|
+
http.get(`http://127.0.0.1:${port}/health`, (res) => {
|
|
567
|
+
let data = '';
|
|
568
|
+
res.on('data', chunk => data += chunk);
|
|
569
|
+
res.on('end', () => {
|
|
570
|
+
const body = JSON.parse(data);
|
|
571
|
+
expect(body.rateLimit).toBeDefined();
|
|
572
|
+
expect(body.rateLimit.enabled).toBe(true);
|
|
573
|
+
expect(body.rateLimit.limit).toBe(3);
|
|
574
|
+
resolve();
|
|
575
|
+
});
|
|
576
|
+
}).on('error', resolve);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test('should track requests in server info', async () => {
|
|
581
|
+
const initialInfo = server.getInfo();
|
|
582
|
+
const initialRequests = initialInfo.requests;
|
|
583
|
+
|
|
584
|
+
// Make a request to health endpoint
|
|
585
|
+
await new Promise((resolve) => {
|
|
586
|
+
http.get(`http://127.0.0.1:${port}/health`, (res) => {
|
|
587
|
+
res.resume();
|
|
588
|
+
res.on('end', resolve);
|
|
589
|
+
}).on('error', resolve);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const info = server.getInfo();
|
|
593
|
+
expect(info.requests).toBeGreaterThan(initialRequests);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('should track errors in server info', async () => {
|
|
597
|
+
const initialInfo = server.getInfo();
|
|
598
|
+
const initialErrors = initialInfo.errors;
|
|
599
|
+
|
|
600
|
+
// Trigger a rate limit error by exceeding the limit
|
|
601
|
+
const makeRequest = () => new Promise((resolve) => {
|
|
602
|
+
http.get(`http://127.0.0.1:${port}/metrics`, (res) => {
|
|
603
|
+
res.resume();
|
|
604
|
+
res.on('end', resolve);
|
|
605
|
+
}).on('error', resolve);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// Make requests to exceed limit (maxRequests is 3)
|
|
609
|
+
await makeRequest();
|
|
610
|
+
await makeRequest();
|
|
611
|
+
await makeRequest();
|
|
612
|
+
await makeRequest(); // This one should trigger 429 and increment errorCount
|
|
613
|
+
|
|
614
|
+
const info = server.getInfo();
|
|
615
|
+
expect(info.errors).toBeGreaterThan(initialErrors);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
describe('Configuration', () => {
|
|
620
|
+
test('should use default config values', () => {
|
|
621
|
+
const server = new WebServer({});
|
|
622
|
+
|
|
623
|
+
expect(server.rateLimiter.enabled).toBe(config.WEB.RATE_LIMIT.ENABLED);
|
|
624
|
+
expect(server.rateLimiter.maxRequests).toBe(config.WEB.RATE_LIMIT.MAX_REQUESTS);
|
|
625
|
+
expect(server.corsManager.allowedOrigins).toBe(config.WEB.CORS.ALLOWED_ORIGINS);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test('should override with custom options', () => {
|
|
629
|
+
const server = new WebServer({
|
|
630
|
+
rateLimit: {
|
|
631
|
+
enabled: false,
|
|
632
|
+
windowMs: 30000,
|
|
633
|
+
maxRequests: 50,
|
|
634
|
+
},
|
|
635
|
+
corsOrigins: ['https://custom.com'],
|
|
636
|
+
corsCredentials: true,
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
expect(server.rateLimiter.enabled).toBe(false);
|
|
640
|
+
expect(server.rateLimiter.windowMs).toBe(30000);
|
|
641
|
+
expect(server.rateLimiter.maxRequests).toBe(50);
|
|
642
|
+
expect(server.corsManager.allowedOrigins).toEqual(['https://custom.com']);
|
|
643
|
+
expect(server.corsManager.credentials).toBe(true);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
});
|