openpen 0.2.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/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/checks/auth-bypass.d.ts +12 -0
- package/dist/checks/auth-bypass.js +93 -0
- package/dist/checks/bac.d.ts +12 -0
- package/dist/checks/bac.js +107 -0
- package/dist/checks/base.d.ts +22 -0
- package/dist/checks/base.js +13 -0
- package/dist/checks/index.d.ts +7 -0
- package/dist/checks/index.js +40 -0
- package/dist/checks/llm-leak.d.ts +23 -0
- package/dist/checks/llm-leak.js +251 -0
- package/dist/checks/mass-assignment.d.ts +12 -0
- package/dist/checks/mass-assignment.js +169 -0
- package/dist/checks/prompt-injection.d.ts +23 -0
- package/dist/checks/prompt-injection.js +262 -0
- package/dist/checks/security-headers.d.ts +12 -0
- package/dist/checks/security-headers.js +133 -0
- package/dist/checks/sensitive-data.d.ts +12 -0
- package/dist/checks/sensitive-data.js +122 -0
- package/dist/checks/sqli.d.ts +12 -0
- package/dist/checks/sqli.js +178 -0
- package/dist/checks/ssrf.d.ts +12 -0
- package/dist/checks/ssrf.js +126 -0
- package/dist/checks/xss.d.ts +12 -0
- package/dist/checks/xss.js +79 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +300 -0
- package/dist/fuzzer/engine.d.ts +27 -0
- package/dist/fuzzer/engine.js +126 -0
- package/dist/fuzzer/mutator.d.ts +8 -0
- package/dist/fuzzer/mutator.js +54 -0
- package/dist/fuzzer/payloads.d.ts +13 -0
- package/dist/fuzzer/payloads.js +167 -0
- package/dist/reporter/index.d.ts +5 -0
- package/dist/reporter/index.js +5 -0
- package/dist/reporter/json.d.ts +5 -0
- package/dist/reporter/json.js +14 -0
- package/dist/reporter/terminal.d.ts +5 -0
- package/dist/reporter/terminal.js +59 -0
- package/dist/spec/openapi.d.ts +5 -0
- package/dist/spec/openapi.js +119 -0
- package/dist/spec/parser.d.ts +11 -0
- package/dist/spec/parser.js +45 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.js +4 -0
- package/dist/utils/http.d.ts +37 -0
- package/dist/utils/http.js +92 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/logger.js +20 -0
- package/dist/ws/checks.d.ts +18 -0
- package/dist/ws/checks.js +558 -0
- package/dist/ws/engine.d.ts +47 -0
- package/dist/ws/engine.js +139 -0
- package/package.json +41 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket security checks
|
|
3
|
+
* Built-in protocol tests for common WS vulnerabilities
|
|
4
|
+
*/
|
|
5
|
+
import { connect, sendAndWait, sendRaw, close, parseJson } from './engine.js';
|
|
6
|
+
import { verbose } from '../utils/logger.js';
|
|
7
|
+
/**
|
|
8
|
+
* Check: Unauthenticated access
|
|
9
|
+
* Tests if the server accepts connections and responds to commands without auth
|
|
10
|
+
*/
|
|
11
|
+
const unauthAccess = {
|
|
12
|
+
info: {
|
|
13
|
+
id: 'ws-unauth',
|
|
14
|
+
name: 'Unauthenticated Access',
|
|
15
|
+
description: 'Tests if server responds to commands without authentication',
|
|
16
|
+
},
|
|
17
|
+
async run(url, timeout) {
|
|
18
|
+
const start = Date.now();
|
|
19
|
+
try {
|
|
20
|
+
const conn = await connect(url, { timeout });
|
|
21
|
+
// Try common discovery commands without auth
|
|
22
|
+
const probes = [
|
|
23
|
+
'{"type":"LIST_CHANNELS"}',
|
|
24
|
+
'{"type":"LIST_AGENTS","channel":"#general"}',
|
|
25
|
+
'{"type":"SEARCH_SKILLS"}',
|
|
26
|
+
'{"action":"list"}',
|
|
27
|
+
'{"cmd":"help"}',
|
|
28
|
+
];
|
|
29
|
+
const responses = [];
|
|
30
|
+
for (const probe of probes) {
|
|
31
|
+
const resp = await sendAndWait(conn, probe, { timeout: 1500 });
|
|
32
|
+
if (resp) {
|
|
33
|
+
responses.push(resp);
|
|
34
|
+
verbose(` [ws-unauth] ${probe} -> ${resp.slice(0, 120)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
await close(conn);
|
|
38
|
+
const successResponses = responses.filter(r => {
|
|
39
|
+
const p = parseJson(r);
|
|
40
|
+
if (!p.parsed)
|
|
41
|
+
return false;
|
|
42
|
+
const v = p.value;
|
|
43
|
+
return v.type !== 'ERROR' && !v.error;
|
|
44
|
+
});
|
|
45
|
+
if (successResponses.length > 0) {
|
|
46
|
+
return {
|
|
47
|
+
checkId: 'ws-unauth',
|
|
48
|
+
checkName: 'Unauthenticated Access',
|
|
49
|
+
status: 'fail',
|
|
50
|
+
severity: 'high',
|
|
51
|
+
description: `Server responded to ${successResponses.length} commands without authentication`,
|
|
52
|
+
evidence: successResponses.map(r => r.slice(0, 200)).join('\n'),
|
|
53
|
+
remediation: 'Require authentication before processing any commands',
|
|
54
|
+
duration: Date.now() - start,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
checkId: 'ws-unauth',
|
|
59
|
+
checkName: 'Unauthenticated Access',
|
|
60
|
+
status: 'pass',
|
|
61
|
+
severity: 'info',
|
|
62
|
+
description: 'Server requires authentication for commands',
|
|
63
|
+
evidence: `${probes.length} probes sent, all rejected or ignored`,
|
|
64
|
+
duration: Date.now() - start,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return {
|
|
69
|
+
checkId: 'ws-unauth',
|
|
70
|
+
checkName: 'Unauthenticated Access',
|
|
71
|
+
status: 'error',
|
|
72
|
+
severity: 'info',
|
|
73
|
+
description: `Connection failed: ${e.message}`,
|
|
74
|
+
evidence: '',
|
|
75
|
+
duration: Date.now() - start,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Check: Message type enumeration
|
|
82
|
+
* Discovers valid message types by probing common names
|
|
83
|
+
*/
|
|
84
|
+
const typeEnumeration = {
|
|
85
|
+
info: {
|
|
86
|
+
id: 'ws-enum',
|
|
87
|
+
name: 'Message Type Enumeration',
|
|
88
|
+
description: 'Discovers valid protocol message types',
|
|
89
|
+
},
|
|
90
|
+
async run(url, timeout) {
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
const conn = await connect(url, { timeout });
|
|
94
|
+
const types = [
|
|
95
|
+
'IDENTIFY', 'LOGIN', 'AUTH', 'HELLO', 'REGISTER',
|
|
96
|
+
'JOIN', 'LEAVE', 'MSG', 'MESSAGE', 'SEND',
|
|
97
|
+
'PING', 'PONG', 'HEARTBEAT',
|
|
98
|
+
'LIST', 'LIST_CHANNELS', 'LIST_AGENTS', 'LIST_USERS', 'LIST_ROOMS',
|
|
99
|
+
'CREATE', 'CREATE_CHANNEL', 'CREATE_ROOM',
|
|
100
|
+
'DELETE', 'REMOVE', 'BAN', 'KICK',
|
|
101
|
+
'SUBSCRIBE', 'UNSUBSCRIBE', 'PUBLISH',
|
|
102
|
+
'PROPOSAL', 'ACCEPT', 'REJECT', 'COMPLETE', 'DISPUTE',
|
|
103
|
+
'REGISTER_SKILLS', 'SEARCH_SKILLS',
|
|
104
|
+
'SET_PRESENCE', 'STATUS',
|
|
105
|
+
'VERIFY_REQUEST', 'VERIFY_RESPONSE',
|
|
106
|
+
'INVITE', 'TRANSFER', 'ADMIN',
|
|
107
|
+
];
|
|
108
|
+
const recognized = [];
|
|
109
|
+
const errors = [];
|
|
110
|
+
for (const type of types) {
|
|
111
|
+
const resp = await sendAndWait(conn, JSON.stringify({ type }), { timeout: 500 });
|
|
112
|
+
if (resp) {
|
|
113
|
+
const p = parseJson(resp);
|
|
114
|
+
if (p.parsed) {
|
|
115
|
+
const v = p.value;
|
|
116
|
+
if (v.type === 'ERROR') {
|
|
117
|
+
const msg = (v.message || v.error || '');
|
|
118
|
+
// "Unknown message type" means it's NOT recognized
|
|
119
|
+
if (!/unknown.*type/i.test(msg)) {
|
|
120
|
+
recognized.push(type);
|
|
121
|
+
verbose(` [ws-enum] ${type} -> recognized (error: ${msg})`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
recognized.push(type);
|
|
126
|
+
verbose(` [ws-enum] ${type} -> recognized (response: ${v.type})`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
await close(conn);
|
|
132
|
+
return {
|
|
133
|
+
checkId: 'ws-enum',
|
|
134
|
+
checkName: 'Message Type Enumeration',
|
|
135
|
+
status: recognized.length > 0 ? 'warn' : 'pass',
|
|
136
|
+
severity: 'info',
|
|
137
|
+
description: `Discovered ${recognized.length} valid message types out of ${types.length} probed`,
|
|
138
|
+
evidence: recognized.length > 0
|
|
139
|
+
? `Valid types: ${recognized.join(', ')}`
|
|
140
|
+
: 'No types discovered (server may require auth first)',
|
|
141
|
+
remediation: 'Ensure sensitive operations are gated behind authentication',
|
|
142
|
+
duration: Date.now() - start,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
return {
|
|
147
|
+
checkId: 'ws-enum',
|
|
148
|
+
checkName: 'Message Type Enumeration',
|
|
149
|
+
status: 'error',
|
|
150
|
+
severity: 'info',
|
|
151
|
+
description: `Connection failed: ${e.message}`,
|
|
152
|
+
evidence: '',
|
|
153
|
+
duration: Date.now() - start,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Check: Message size limit
|
|
160
|
+
* Tests if the server enforces payload size limits
|
|
161
|
+
*/
|
|
162
|
+
const messageSizeLimit = {
|
|
163
|
+
info: {
|
|
164
|
+
id: 'ws-size',
|
|
165
|
+
name: 'Message Size Limit',
|
|
166
|
+
description: 'Tests if the server enforces WebSocket frame size limits',
|
|
167
|
+
},
|
|
168
|
+
async run(url, timeout) {
|
|
169
|
+
const start = Date.now();
|
|
170
|
+
const sizes = [
|
|
171
|
+
{ label: '1KB', size: 1024 },
|
|
172
|
+
{ label: '64KB', size: 64 * 1024 },
|
|
173
|
+
{ label: '256KB', size: 256 * 1024 },
|
|
174
|
+
{ label: '1MB', size: 1024 * 1024 },
|
|
175
|
+
{ label: '5MB', size: 5 * 1024 * 1024 },
|
|
176
|
+
];
|
|
177
|
+
let maxAccepted = 0;
|
|
178
|
+
let maxAcceptedLabel = 'none';
|
|
179
|
+
try {
|
|
180
|
+
for (const { label, size } of sizes) {
|
|
181
|
+
try {
|
|
182
|
+
const conn = await connect(url, { timeout });
|
|
183
|
+
const payload = JSON.stringify({ type: 'PING', data: 'x'.repeat(size) });
|
|
184
|
+
sendRaw(conn, payload);
|
|
185
|
+
// Wait for close event; scale with payload size for proxy latency
|
|
186
|
+
const waitMs = Math.max(2000, Math.min(Math.ceil(size / 256), 6000));
|
|
187
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
188
|
+
// Check for error responses (server may reject via error message instead of disconnect)
|
|
189
|
+
const errorResponse = conn.messages.find(m => {
|
|
190
|
+
if (m.direction !== 'received')
|
|
191
|
+
return false;
|
|
192
|
+
const p = parseJson(m.data);
|
|
193
|
+
if (!p.parsed)
|
|
194
|
+
return false;
|
|
195
|
+
const v = p.value;
|
|
196
|
+
const msg = (v.message || '').toLowerCase();
|
|
197
|
+
return (v.type === 'ERROR' && (msg.includes('too large') || msg.includes('size')));
|
|
198
|
+
});
|
|
199
|
+
if (errorResponse) {
|
|
200
|
+
verbose(` [ws-size] ${label} -> rejected (error response)`);
|
|
201
|
+
}
|
|
202
|
+
else if (conn.connected) {
|
|
203
|
+
maxAccepted = size;
|
|
204
|
+
maxAcceptedLabel = label;
|
|
205
|
+
verbose(` [ws-size] ${label} -> accepted`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
verbose(` [ws-size] ${label} -> connection closed (code: ${conn.closedCode})`);
|
|
209
|
+
}
|
|
210
|
+
await close(conn);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
verbose(` [ws-size] ${label} -> rejected/failed`);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (maxAccepted >= 1024 * 1024) {
|
|
218
|
+
return {
|
|
219
|
+
checkId: 'ws-size',
|
|
220
|
+
checkName: 'Message Size Limit',
|
|
221
|
+
status: 'fail',
|
|
222
|
+
severity: 'medium',
|
|
223
|
+
description: `Server accepts messages up to ${maxAcceptedLabel} without limit`,
|
|
224
|
+
evidence: `Max accepted payload: ${maxAcceptedLabel} (${maxAccepted} bytes)`,
|
|
225
|
+
remediation: 'Set maxPayload on WebSocket server (recommended: 256KB or less)',
|
|
226
|
+
duration: Date.now() - start,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (maxAccepted >= 256 * 1024) {
|
|
230
|
+
return {
|
|
231
|
+
checkId: 'ws-size',
|
|
232
|
+
checkName: 'Message Size Limit',
|
|
233
|
+
status: 'warn',
|
|
234
|
+
severity: 'low',
|
|
235
|
+
description: `Server accepts messages up to ${maxAcceptedLabel}`,
|
|
236
|
+
evidence: `Max accepted payload: ${maxAcceptedLabel} (${maxAccepted} bytes)`,
|
|
237
|
+
remediation: 'Consider lowering maxPayload if protocol messages are small',
|
|
238
|
+
duration: Date.now() - start,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
checkId: 'ws-size',
|
|
243
|
+
checkName: 'Message Size Limit',
|
|
244
|
+
status: 'pass',
|
|
245
|
+
severity: 'info',
|
|
246
|
+
description: `Server enforces message size limit (max accepted: ${maxAcceptedLabel})`,
|
|
247
|
+
evidence: `Largest accepted: ${maxAcceptedLabel} (${maxAccepted} bytes)`,
|
|
248
|
+
duration: Date.now() - start,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
return {
|
|
253
|
+
checkId: 'ws-size',
|
|
254
|
+
checkName: 'Message Size Limit',
|
|
255
|
+
status: 'error',
|
|
256
|
+
severity: 'info',
|
|
257
|
+
description: `Test failed: ${e.message}`,
|
|
258
|
+
evidence: '',
|
|
259
|
+
duration: Date.now() - start,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* Check: Rate limiting
|
|
266
|
+
* Tests if the server limits message frequency
|
|
267
|
+
*/
|
|
268
|
+
const rateLimiting = {
|
|
269
|
+
info: {
|
|
270
|
+
id: 'ws-rate',
|
|
271
|
+
name: 'Rate Limiting',
|
|
272
|
+
description: 'Tests if the server enforces message rate limits',
|
|
273
|
+
},
|
|
274
|
+
async run(url, timeout) {
|
|
275
|
+
const start = Date.now();
|
|
276
|
+
try {
|
|
277
|
+
const conn = await connect(url, { timeout });
|
|
278
|
+
// Send 20 messages as fast as possible
|
|
279
|
+
const burstCount = 20;
|
|
280
|
+
const startBurst = Date.now();
|
|
281
|
+
for (let i = 0; i < burstCount; i++) {
|
|
282
|
+
sendRaw(conn, JSON.stringify({ type: 'PING', seq: i }));
|
|
283
|
+
}
|
|
284
|
+
// Wait for responses
|
|
285
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
286
|
+
const burstDuration = Date.now() - startBurst;
|
|
287
|
+
// Check for rate limit errors in messages
|
|
288
|
+
const rateLimitErrors = conn.messages.filter(m => {
|
|
289
|
+
if (m.direction !== 'received')
|
|
290
|
+
return false;
|
|
291
|
+
const p = parseJson(m.data);
|
|
292
|
+
if (!p.parsed)
|
|
293
|
+
return false;
|
|
294
|
+
const v = p.value;
|
|
295
|
+
const msg = (v.message || v.error || '').toLowerCase();
|
|
296
|
+
return msg.includes('rate') || msg.includes('limit') || msg.includes('throttl') || msg.includes('too many');
|
|
297
|
+
});
|
|
298
|
+
// Also check if connection was closed (server may disconnect instead of sending error)
|
|
299
|
+
const disconnected = !conn.connected;
|
|
300
|
+
const rateLimitClose = disconnected && (conn.closedCode === 1008 ||
|
|
301
|
+
(conn.closedReason || '').toLowerCase().includes('rate limit'));
|
|
302
|
+
await close(conn);
|
|
303
|
+
if (rateLimitErrors.length > 0 || rateLimitClose) {
|
|
304
|
+
const evidence = rateLimitClose
|
|
305
|
+
? `Connection closed with code ${conn.closedCode}: ${conn.closedReason}`
|
|
306
|
+
: rateLimitErrors[0]?.data.slice(0, 200) || '';
|
|
307
|
+
return {
|
|
308
|
+
checkId: 'ws-rate',
|
|
309
|
+
checkName: 'Rate Limiting',
|
|
310
|
+
status: 'pass',
|
|
311
|
+
severity: 'info',
|
|
312
|
+
description: `Server enforces rate limiting (${rateLimitErrors.length > 0 ? rateLimitErrors.length + ' rate limit responses' : 'connection disconnected'} for ${burstCount} burst messages)`,
|
|
313
|
+
evidence,
|
|
314
|
+
duration: Date.now() - start,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
checkId: 'ws-rate',
|
|
319
|
+
checkName: 'Rate Limiting',
|
|
320
|
+
status: 'warn',
|
|
321
|
+
severity: 'medium',
|
|
322
|
+
description: `No rate limiting detected for ${burstCount} rapid messages in ${burstDuration}ms`,
|
|
323
|
+
evidence: `Sent ${burstCount} messages, received ${conn.messages.filter(m => m.direction === 'received').length} responses, no rate limit errors or disconnection`,
|
|
324
|
+
remediation: 'Implement per-connection rate limiting to prevent message flooding',
|
|
325
|
+
duration: Date.now() - start,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
return {
|
|
330
|
+
checkId: 'ws-rate',
|
|
331
|
+
checkName: 'Rate Limiting',
|
|
332
|
+
status: 'error',
|
|
333
|
+
severity: 'info',
|
|
334
|
+
description: `Test failed: ${e.message}`,
|
|
335
|
+
evidence: '',
|
|
336
|
+
duration: Date.now() - start,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
/**
|
|
342
|
+
* Check: Connection flood
|
|
343
|
+
* Tests if the server limits concurrent connections from one source
|
|
344
|
+
*/
|
|
345
|
+
const connectionFlood = {
|
|
346
|
+
info: {
|
|
347
|
+
id: 'ws-flood',
|
|
348
|
+
name: 'Connection Flood',
|
|
349
|
+
description: 'Tests if the server limits concurrent connections per IP',
|
|
350
|
+
},
|
|
351
|
+
async run(url, timeout) {
|
|
352
|
+
const start = Date.now();
|
|
353
|
+
const maxConns = 15;
|
|
354
|
+
const conns = [];
|
|
355
|
+
let rejected = false;
|
|
356
|
+
let rejectedAt = 0;
|
|
357
|
+
try {
|
|
358
|
+
for (let i = 0; i < maxConns; i++) {
|
|
359
|
+
try {
|
|
360
|
+
const conn = await connect(url, { timeout: 2000 });
|
|
361
|
+
conns.push(conn);
|
|
362
|
+
verbose(` [ws-flood] Connection ${i + 1} opened`);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
rejected = true;
|
|
366
|
+
rejectedAt = i + 1;
|
|
367
|
+
verbose(` [ws-flood] Connection ${i + 1} rejected`);
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Also check if any connections were forcibly closed
|
|
372
|
+
const closedConns = conns.filter(c => c && !c.connected);
|
|
373
|
+
// Clean up
|
|
374
|
+
for (const conn of conns) {
|
|
375
|
+
if (conn)
|
|
376
|
+
await close(conn);
|
|
377
|
+
}
|
|
378
|
+
if (rejected || closedConns.length > 0) {
|
|
379
|
+
const limit = rejected ? rejectedAt : conns.length;
|
|
380
|
+
return {
|
|
381
|
+
checkId: 'ws-flood',
|
|
382
|
+
checkName: 'Connection Flood',
|
|
383
|
+
status: 'pass',
|
|
384
|
+
severity: 'info',
|
|
385
|
+
description: `Server limits connections per IP (limit ~${limit})`,
|
|
386
|
+
evidence: rejected
|
|
387
|
+
? `Connection ${rejectedAt} was rejected`
|
|
388
|
+
: `${closedConns.length} connections were forcibly closed`,
|
|
389
|
+
duration: Date.now() - start,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
checkId: 'ws-flood',
|
|
394
|
+
checkName: 'Connection Flood',
|
|
395
|
+
status: 'warn',
|
|
396
|
+
severity: 'medium',
|
|
397
|
+
description: `Server accepted all ${maxConns} concurrent connections without limiting`,
|
|
398
|
+
evidence: `${conns.length} simultaneous connections from same IP were accepted`,
|
|
399
|
+
remediation: 'Implement per-IP connection limiting (recommended: 10-20 max)',
|
|
400
|
+
duration: Date.now() - start,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
// Clean up on error
|
|
405
|
+
for (const conn of conns) {
|
|
406
|
+
if (conn)
|
|
407
|
+
await close(conn).catch(() => { });
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
checkId: 'ws-flood',
|
|
411
|
+
checkName: 'Connection Flood',
|
|
412
|
+
status: 'error',
|
|
413
|
+
severity: 'info',
|
|
414
|
+
description: `Test failed: ${e.message}`,
|
|
415
|
+
evidence: '',
|
|
416
|
+
duration: Date.now() - start,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
/**
|
|
422
|
+
* Check: Invalid message handling
|
|
423
|
+
* Tests how server handles malformed/malicious input
|
|
424
|
+
*/
|
|
425
|
+
const invalidMessages = {
|
|
426
|
+
info: {
|
|
427
|
+
id: 'ws-invalid',
|
|
428
|
+
name: 'Invalid Message Handling',
|
|
429
|
+
description: 'Tests server response to malformed and malicious payloads',
|
|
430
|
+
},
|
|
431
|
+
async run(url, timeout) {
|
|
432
|
+
const start = Date.now();
|
|
433
|
+
try {
|
|
434
|
+
const conn = await connect(url, { timeout });
|
|
435
|
+
// Keep to 8 payloads to stay under pre-auth rate limits
|
|
436
|
+
const payloads = [
|
|
437
|
+
// Malformed JSON
|
|
438
|
+
'{not valid json',
|
|
439
|
+
'{"type":',
|
|
440
|
+
'',
|
|
441
|
+
// Injection attempts
|
|
442
|
+
'{"type":"<script>alert(1)</script>"}',
|
|
443
|
+
'{"type":"IDENTIFY","name":"test\'; DROP TABLE users; --"}',
|
|
444
|
+
// Type confusion
|
|
445
|
+
'{"type":123}',
|
|
446
|
+
'{"type":null}',
|
|
447
|
+
// Prototype pollution
|
|
448
|
+
'{"__proto__":{"admin":true}}',
|
|
449
|
+
];
|
|
450
|
+
let crashed = false;
|
|
451
|
+
let rateLimited = false;
|
|
452
|
+
let errorResponses = 0;
|
|
453
|
+
let silentDrops = 0;
|
|
454
|
+
for (const payload of payloads) {
|
|
455
|
+
try {
|
|
456
|
+
sendRaw(conn, payload);
|
|
457
|
+
await new Promise(r => setTimeout(r, 300));
|
|
458
|
+
if (!conn.connected) {
|
|
459
|
+
// Distinguish rate limiting from actual crash
|
|
460
|
+
if (conn.closedCode === 1008 || (conn.closedReason || '').toLowerCase().includes('rate limit')) {
|
|
461
|
+
rateLimited = true;
|
|
462
|
+
verbose(` [ws-invalid] Rate limited after: ${payload.slice(0, 60)}`);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
crashed = true;
|
|
466
|
+
verbose(` [ws-invalid] Server closed connection after: ${payload.slice(0, 60)}`);
|
|
467
|
+
}
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
crashed = true;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// Count error responses
|
|
477
|
+
errorResponses = conn.messages.filter(m => {
|
|
478
|
+
if (m.direction !== 'received')
|
|
479
|
+
return false;
|
|
480
|
+
const p = parseJson(m.data);
|
|
481
|
+
if (!p.parsed)
|
|
482
|
+
return false;
|
|
483
|
+
const v = p.value;
|
|
484
|
+
return v.type === 'ERROR' || v.error;
|
|
485
|
+
}).length;
|
|
486
|
+
silentDrops = payloads.length - errorResponses - (crashed || rateLimited ? 1 : 0);
|
|
487
|
+
await close(conn);
|
|
488
|
+
if (rateLimited) {
|
|
489
|
+
return {
|
|
490
|
+
checkId: 'ws-invalid',
|
|
491
|
+
checkName: 'Invalid Message Handling',
|
|
492
|
+
status: 'pass',
|
|
493
|
+
severity: 'info',
|
|
494
|
+
description: `Server rate-limited after ${conn.messages.length} malformed messages (expected behavior)`,
|
|
495
|
+
evidence: `Connection closed: code ${conn.closedCode}, ${errorResponses} error responses before disconnect`,
|
|
496
|
+
duration: Date.now() - start,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (crashed) {
|
|
500
|
+
return {
|
|
501
|
+
checkId: 'ws-invalid',
|
|
502
|
+
checkName: 'Invalid Message Handling',
|
|
503
|
+
status: 'fail',
|
|
504
|
+
severity: 'medium',
|
|
505
|
+
description: 'Server crashed or disconnected on malformed input',
|
|
506
|
+
evidence: `Connection dropped (code: ${conn.closedCode}, reason: ${conn.closedReason}), ${conn.messages.length} messages exchanged`,
|
|
507
|
+
remediation: 'Gracefully handle all malformed input without closing the connection',
|
|
508
|
+
duration: Date.now() - start,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
checkId: 'ws-invalid',
|
|
513
|
+
checkName: 'Invalid Message Handling',
|
|
514
|
+
status: 'pass',
|
|
515
|
+
severity: 'info',
|
|
516
|
+
description: `Server handled ${payloads.length} malformed payloads gracefully`,
|
|
517
|
+
evidence: `${errorResponses} error responses, ${silentDrops} silently dropped, connection stable`,
|
|
518
|
+
duration: Date.now() - start,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
return {
|
|
523
|
+
checkId: 'ws-invalid',
|
|
524
|
+
checkName: 'Invalid Message Handling',
|
|
525
|
+
status: 'error',
|
|
526
|
+
severity: 'info',
|
|
527
|
+
description: `Test failed: ${e.message}`,
|
|
528
|
+
evidence: '',
|
|
529
|
+
duration: Date.now() - start,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
// Registry
|
|
535
|
+
const ALL_WS_CHECKS = [
|
|
536
|
+
unauthAccess,
|
|
537
|
+
typeEnumeration,
|
|
538
|
+
messageSizeLimit,
|
|
539
|
+
rateLimiting,
|
|
540
|
+
connectionFlood,
|
|
541
|
+
invalidMessages,
|
|
542
|
+
];
|
|
543
|
+
export function getAllWsChecks() {
|
|
544
|
+
return ALL_WS_CHECKS;
|
|
545
|
+
}
|
|
546
|
+
export function getWsChecksByIds(ids) {
|
|
547
|
+
const checks = [];
|
|
548
|
+
for (const id of ids) {
|
|
549
|
+
const check = ALL_WS_CHECKS.find(c => c.info.id === id);
|
|
550
|
+
if (!check)
|
|
551
|
+
throw new Error(`Unknown WebSocket check: ${id}`);
|
|
552
|
+
checks.push(check);
|
|
553
|
+
}
|
|
554
|
+
return checks;
|
|
555
|
+
}
|
|
556
|
+
export function listWsChecks() {
|
|
557
|
+
return ALL_WS_CHECKS.map(c => c.info);
|
|
558
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket test engine
|
|
3
|
+
* Handles connections, message exchange, and response collection
|
|
4
|
+
*/
|
|
5
|
+
import WebSocket from 'ws';
|
|
6
|
+
import type { WsMessage } from '../types.js';
|
|
7
|
+
export interface WsConnection {
|
|
8
|
+
ws: WebSocket;
|
|
9
|
+
messages: WsMessage[];
|
|
10
|
+
connected: boolean;
|
|
11
|
+
closedCode?: number;
|
|
12
|
+
closedReason?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Open a WebSocket connection to the target
|
|
16
|
+
*/
|
|
17
|
+
export declare function connect(url: string, options?: {
|
|
18
|
+
timeout?: number;
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
}): Promise<WsConnection>;
|
|
21
|
+
/**
|
|
22
|
+
* Send a message and wait for a response
|
|
23
|
+
*/
|
|
24
|
+
export declare function sendAndWait(conn: WsConnection, data: string, options?: {
|
|
25
|
+
timeout?: number;
|
|
26
|
+
matchFn?: (msg: string) => boolean;
|
|
27
|
+
}): Promise<string | null>;
|
|
28
|
+
/**
|
|
29
|
+
* Send raw data (string or buffer) without recording
|
|
30
|
+
*/
|
|
31
|
+
export declare function sendRaw(conn: WsConnection, data: string | Buffer): void;
|
|
32
|
+
/**
|
|
33
|
+
* Close a connection gracefully
|
|
34
|
+
*/
|
|
35
|
+
export declare function close(conn: WsConnection): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Wait for a specific number of messages or timeout
|
|
38
|
+
*/
|
|
39
|
+
export declare function waitForMessages(conn: WsConnection, count: number, timeout?: number): Promise<WsMessage[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Try to parse a message as JSON
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseJson(data: string): {
|
|
44
|
+
parsed: boolean;
|
|
45
|
+
value?: unknown;
|
|
46
|
+
error?: string;
|
|
47
|
+
};
|