@lhi/tdd-audit 1.16.0 → 1.20.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.
@@ -0,0 +1,394 @@
1
+ # Node.js Advanced Security Companion — Detection & Repair Guide
2
+
3
+ This guide covers Node.js/Express attack surfaces beyond the OWASP Top 10 basics.
4
+ Apply these patterns during the Explore and Audit phases.
5
+
6
+ ---
7
+
8
+ ## 1. Timing Oracle Attack (Non-Constant-Time String Comparison)
9
+
10
+ **What it is:** Using `===` or `==` to compare tokens, passwords, or HMACs allows an attacker
11
+ to infer the correct value one byte at a time by measuring response latency.
12
+
13
+ **Detection — look for:**
14
+ - `token === req.headers.authorization`
15
+ - `secret == providedKey`
16
+ - `apiKey === process.env.API_KEY`
17
+ - Any `===` comparison where one operand is from `req.*` and the other is a secret
18
+
19
+ **Repair:**
20
+ ```javascript
21
+ const crypto = require('crypto');
22
+ function timingSafeEqual(a, b) {
23
+ const ha = crypto.createHmac('sha256', 'cmp').update(String(a)).digest();
24
+ const hb = crypto.createHmac('sha256', 'cmp').update(String(b)).digest();
25
+ return crypto.timingSafeEqual(ha, hb);
26
+ }
27
+ if (!timingSafeEqual(req.headers.authorization, `Bearer ${process.env.API_KEY}`)) {
28
+ return res.status(401).json({ error: 'Unauthorized' });
29
+ }
30
+ ```
31
+
32
+ **Test snippet:**
33
+ ```javascript
34
+ test('responds in constant time for wrong vs missing token', async () => {
35
+ const t1 = Date.now(); await request(app).get('/api').set('Authorization', 'Bearer wrong'); const d1 = Date.now() - t1;
36
+ const t2 = Date.now(); await request(app).get('/api').set('Authorization', `Bearer ${'x'.repeat(100)}`); const d2 = Date.now() - t2;
37
+ expect(Math.abs(d1 - d2)).toBeLessThan(50); // within 50 ms
38
+ });
39
+ ```
40
+
41
+ ---
42
+
43
+ ## 2. Host Header Injection
44
+
45
+ **What it is:** `req.headers.host` (or `req.hostname`) is used to construct password-reset
46
+ links, email confirmation URLs, or redirects. An attacker supplies a forged `Host:` header,
47
+ redirecting victims to an attacker-controlled domain.
48
+
49
+ **Detection — look for:**
50
+ - `req.headers['host']`, `req.hostname`, `req.get('host')`
51
+ - Used in string concatenation building a URL for email, redirect, or link
52
+
53
+ **Repair:** Use a hard-coded trusted base URL from config — never trust `Host:` header:
54
+ ```javascript
55
+ const BASE_URL = process.env.BASE_URL; // e.g. 'https://app.example.com'
56
+ if (!BASE_URL) throw new Error('BASE_URL env var required');
57
+ const resetLink = `${BASE_URL}/reset?token=${token}`;
58
+ ```
59
+
60
+ ---
61
+
62
+ ## 3. Headless Browser SSRF (Puppeteer / Playwright / wkhtmltopdf)
63
+
64
+ **What it is:** A headless browser is instructed to navigate to a URL derived from user input.
65
+ The browser runs server-side, so it can access internal services, cloud metadata endpoints
66
+ (`169.254.169.254`), or local network resources.
67
+
68
+ **Detection — look for:**
69
+ - `page.goto(req.query.url)`, `page.navigate(req.body.url)`
70
+ - `wkhtmltopdf(userUrl, ...)`, `page.goto(url)` where `url` comes from request
71
+
72
+ **Repair:**
73
+ ```javascript
74
+ const { URL } = require('url');
75
+ const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
76
+ const BLOCKED_HOSTS = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/;
77
+
78
+ function assertSafeUrl(raw) {
79
+ let u;
80
+ try { u = new URL(raw); } catch { throw new Error('Invalid URL'); }
81
+ if (!ALLOWED_PROTOCOLS.has(u.protocol)) throw new Error(`Protocol not allowed: ${u.protocol}`);
82
+ if (BLOCKED_HOSTS.test(u.hostname)) throw new Error(`Blocked host: ${u.hostname}`);
83
+ }
84
+ ```
85
+
86
+ ---
87
+
88
+ ## 4. Body Parser DoS (No Size Limit)
89
+
90
+ **What it is:** `express.json()` or `bodyParser.json()` with no `limit` option will buffer
91
+ arbitrarily large payloads into memory, enabling a DoS attack with a single large request.
92
+
93
+ **Detection — look for:**
94
+ - `express.json()` — no argument
95
+ - `express.urlencoded()` — no argument
96
+ - `bodyParser.json()` — no `limit:` property in the options object
97
+
98
+ **Repair:**
99
+ ```javascript
100
+ app.use(express.json({ limit: '100kb' }));
101
+ app.use(express.urlencoded({ extended: false, limit: '100kb' }));
102
+ ```
103
+
104
+ ---
105
+
106
+ ## 5. vm2 Deprecated — Sandbox Escape
107
+
108
+ **What it is:** The `vm2` library has been publicly abandoned with unfixed sandbox-escape CVEs
109
+ (CVE-2023-29017, CVE-2023-32314). Any code using `require('vm2')` is vulnerable to full host
110
+ compromise from untrusted code execution.
111
+
112
+ **Detection — look for:**
113
+ - `require('vm2')` or `import ... from 'vm2'`
114
+
115
+ **Repair:** Replace with `isolated-vm` for true V8 isolate sandboxing, or Node's built-in
116
+ `vm.runInNewContext` with a frozen context for limited use cases:
117
+ ```javascript
118
+ // Replace vm2 with isolated-vm
119
+ const ivm = require('isolated-vm');
120
+ const isolate = new ivm.Isolate({ memoryLimit: 32 });
121
+ const context = isolate.createContextSync();
122
+ const result = isolate.compileScriptSync(untrustedCode).runSync(context);
123
+ ```
124
+
125
+ ---
126
+
127
+ ## 6. Template Engine Raw / Unescaped Output
128
+
129
+ **What it is:** Template engines provide escape bypasses for trusted HTML content. When these
130
+ are used with user-controlled values, they create reflected XSS vulnerabilities.
131
+
132
+ **Detection — look for:**
133
+ - **Pug:** `!{userValue}` (raw unescaped output)
134
+ - **EJS:** `<%-` tag (unescaped)
135
+ - **Handlebars:** `{{{userValue}}}` (triple-stache)
136
+ - **Dust.js:** `{userValue|s}` (safe/unescaped filter)
137
+ - **Vue SSR / v-html:** `v-html="userValue"` (server-rendered)
138
+
139
+ **Repair:** Use the escaped variants:
140
+ ```
141
+ Pug: #{userValue} not !{userValue}
142
+ EJS: <%= userValue %> not <%- userValue %>
143
+ Handlebars: {{userValue}} not {{{userValue}}}
144
+ ```
145
+
146
+ ---
147
+
148
+ ## 7. postMessage Missing Origin Validation
149
+
150
+ **What it is:** A `message` event listener on `window` does not check `event.origin`, allowing
151
+ any page (including attacker-controlled iframes) to send arbitrary messages.
152
+
153
+ **Detection — look for:**
154
+ - `addEventListener('message', handler)` where `handler` does not reference `event.origin`
155
+
156
+ **Repair:**
157
+ ```javascript
158
+ const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);
159
+ window.addEventListener('message', (event) => {
160
+ if (!ALLOWED_ORIGINS.has(event.origin)) return; // reject unknown origins
161
+ handleMessage(event.data);
162
+ });
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 8. Dynamic import() with User Input
168
+
169
+ **What it is:** `import()` or `require()` with a path derived from user input enables path
170
+ traversal to load arbitrary modules, including `child_process`, `fs`, or native addons.
171
+
172
+ **Detection — look for:**
173
+ - `import(req.query.module)`, `import(userPath)`, `require(req.body.plugin)`
174
+ - `` import(`./plugins/${req.params.name}`) ``
175
+
176
+ **Repair:** Use a static allowlist:
177
+ ```javascript
178
+ const ALLOWED_PLUGINS = new Map([
179
+ ['csv', () => import('./plugins/csv-parser.js')],
180
+ ['json', () => import('./plugins/json-parser.js')],
181
+ ]);
182
+ const loader = ALLOWED_PLUGINS.get(req.params.format);
183
+ if (!loader) return res.status(400).json({ error: 'Unknown format' });
184
+ const plugin = await loader();
185
+ ```
186
+
187
+ ---
188
+
189
+ ## 9. JWT — No Revocation Mechanism
190
+
191
+ **What it is:** JWTs with long-lived `expiresIn` values (days/hours) and no server-side
192
+ revocation mechanism cannot be invalidated after issuance. Stolen tokens remain valid until
193
+ natural expiry.
194
+
195
+ **Detection — look for:**
196
+ - `jwt.sign({ ... }, secret, { expiresIn: '7d' })` with no token blocklist or session store
197
+ - No middleware that checks a revocation list before trusting a valid JWT
198
+
199
+ **Repair options:**
200
+ 1. **Short TTL + refresh tokens:** Issue 15-minute access tokens, longer refresh tokens stored server-side.
201
+ 2. **JTI blocklist:** Store `jti` claims of revoked tokens in Redis with matching TTL:
202
+ ```javascript
203
+ async function revokeToken(token) {
204
+ const { jti, exp } = jwt.decode(token);
205
+ const ttl = exp - Math.floor(Date.now() / 1000);
206
+ await redis.set(`revoked:${jti}`, '1', 'EX', ttl);
207
+ }
208
+ async function isRevoked(jti) {
209
+ return !!(await redis.get(`revoked:${jti}`));
210
+ }
211
+ ```
212
+
213
+ ---
214
+
215
+ ## 10. X-Powered-By Header Exposes Framework Fingerprint
216
+
217
+ **What it is:** Express sets `X-Powered-By: Express` by default, advertising the framework
218
+ version to attackers for targeted exploit selection.
219
+
220
+ **Detection — look for:**
221
+ - `express()` app initialisation with no subsequent `app.disable('x-powered-by')`
222
+ - Absence of `helmet()` middleware (which removes this header)
223
+
224
+ **Repair:**
225
+ ```javascript
226
+ const helmet = require('helmet');
227
+ app.use(helmet()); // removes X-Powered-By and sets 11 security headers
228
+ // or manually:
229
+ app.disable('x-powered-by');
230
+ ```
231
+
232
+ ---
233
+
234
+ ## 11. Logger Data Leakage
235
+
236
+ **What it is:** User-supplied input is passed directly to `console.log`, `logger.info`,
237
+ `winston.debug`, etc., causing PII, tokens, or secrets to be written to log files or
238
+ shipped to centralised logging systems.
239
+
240
+ **Detection — look for:**
241
+ - `console.log(req.body)`, `logger.info(req.headers)`, `logger.debug(user)`
242
+ - Logging full request objects that include `authorization`, `password`, `token`
243
+
244
+ **Repair:** Sanitise log payloads — log an allowlist of safe fields only:
245
+ ```javascript
246
+ function safeLogRequest(req) {
247
+ return { method: req.method, path: req.path, ip: req.ip, userId: req.user?.id };
248
+ }
249
+ logger.info('Request received', safeLogRequest(req));
250
+ ```
251
+
252
+ ---
253
+
254
+ ## 12. GraphQL Introspection Enabled in Production
255
+
256
+ **What it is:** GraphQL introspection allows any client to enumerate the entire schema,
257
+ exposing internal types, field names, and resolver structure — a reconnaissance goldmine.
258
+
259
+ **Detection — look for:**
260
+ - `introspection: true` in ApolloServer config
261
+ - No `NODE_ENV` guard around introspection
262
+ - Missing depth/complexity limiting plugins
263
+
264
+ **Repair:**
265
+ ```javascript
266
+ const server = new ApolloServer({
267
+ typeDefs,
268
+ resolvers,
269
+ introspection: process.env.NODE_ENV !== 'production',
270
+ plugins: [
271
+ ApolloServerPluginLandingPageDisabled(), // prod: disable playground
272
+ createDepthLimitPlugin(7),
273
+ createComplexityPlugin({ maxComplexity: 1000 }),
274
+ ],
275
+ });
276
+ ```
277
+
278
+ ---
279
+
280
+ ## 13. Prototype Pollution via Bracket Notation
281
+
282
+ **What it is:** `obj[userKey] = userValue` where `userKey` comes from user input allows
283
+ setting `__proto__`, `constructor`, or `prototype` properties, poisoning the Object
284
+ prototype for all objects in the process and potentially enabling privilege escalation.
285
+
286
+ **Detection — look for:**
287
+ - `obj[req.body.key] = req.body.value`
288
+ - `config[userKey]` without key validation
289
+ - `_.merge(target, req.body)` without `_.cloneDeep` or `Object.create(null)` base
290
+
291
+ **Repair:**
292
+ ```javascript
293
+ function safeMerge(target, source) {
294
+ const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
295
+ for (const [k, v] of Object.entries(source)) {
296
+ if (BLOCKED.has(k)) continue;
297
+ target[k] = v;
298
+ }
299
+ }
300
+ // Or use Object.create(null) as base to avoid prototype chain entirely
301
+ const safeObj = Object.assign(Object.create(null), userInput);
302
+ ```
303
+
304
+ ---
305
+
306
+ ## 14. Silent Exception Swallow
307
+
308
+ **What it is:** `catch` blocks that contain only a comment or are completely empty silently
309
+ discard errors, hiding security-relevant failures (auth errors, validation failures,
310
+ crypto exceptions) and making incident investigation impossible.
311
+
312
+ **Detection — look for:**
313
+ - `catch (e) { }` — empty catch
314
+ - `catch (e) { // ignore }` — comment-only catch
315
+ - `catch (err) { return; }` — silent return
316
+
317
+ **Repair:** Always log and re-throw or return a safe error:
318
+ ```javascript
319
+ try {
320
+ await riskyOperation();
321
+ } catch (err) {
322
+ logger.error({ err, userId: req.user?.id }, 'Operation failed');
323
+ return res.status(500).json({ error: 'Internal error' });
324
+ }
325
+ ```
326
+
327
+ ---
328
+
329
+ ## 15. Sequelize / Knex TLS Disabled on Database Connection
330
+
331
+ **What it is:** `dialectOptions: { ssl: false }` or `ssl: { rejectUnauthorized: false }`
332
+ disables certificate validation on the database connection, exposing credentials to
333
+ man-in-the-middle attacks.
334
+
335
+ **Detection — look for:**
336
+ - `ssl: false` inside `dialectOptions`
337
+ - `ssl: { rejectUnauthorized: false }` in Sequelize/pg/mysql2 config
338
+ - `knex({ client: 'pg', connection: { ssl: false } })`
339
+
340
+ **Repair:**
341
+ ```javascript
342
+ new Sequelize(DATABASE_URL, {
343
+ dialectOptions: {
344
+ ssl: {
345
+ require: true,
346
+ rejectUnauthorized: true,
347
+ ca: fs.readFileSync('./certs/ca.pem'),
348
+ },
349
+ },
350
+ });
351
+ ```
352
+
353
+ ---
354
+
355
+ ## 16. Insecure WebSocket URL (ws:// in Production)
356
+
357
+ **What it is:** Connecting to `ws://` (unencrypted WebSocket) transmits data in plaintext,
358
+ enabling credential theft and message injection on any network path.
359
+
360
+ **Detection — look for:**
361
+ - `new WebSocket('ws://')` — hardcoded insecure URL (not localhost)
362
+ - `io.connect('ws://')` (Socket.IO)
363
+
364
+ **Repair:** Always use `wss://` in production:
365
+ ```javascript
366
+ const ws = new WebSocket(
367
+ process.env.NODE_ENV === 'production'
368
+ ? 'wss://api.example.com/ws'
369
+ : 'ws://localhost:3001'
370
+ );
371
+ ```
372
+
373
+ ---
374
+
375
+ ## Severity Reference
376
+
377
+ | Pattern | CWE | Severity |
378
+ |---|---|---|
379
+ | Headless browser SSRF | CWE-918 | CRITICAL |
380
+ | vm2 deprecated (sandbox escape) | CWE-693 | CRITICAL |
381
+ | Host header injection | CWE-601 | HIGH |
382
+ | Body parser DoS | CWE-400 | HIGH |
383
+ | Template engine raw output (XSS) | CWE-79 | HIGH |
384
+ | Dynamic import with user input | CWE-706 | HIGH |
385
+ | JWT no revocation | CWE-613 | HIGH |
386
+ | GraphQL introspection in prod | CWE-200 | HIGH |
387
+ | Prototype pollution | CWE-1321 | HIGH |
388
+ | Sequelize TLS disabled | CWE-295 | HIGH |
389
+ | Timing oracle (non-constant compare) | CWE-208 | MEDIUM |
390
+ | postMessage no origin check | CWE-346 | MEDIUM |
391
+ | X-Powered-By exposed | CWE-200 | MEDIUM |
392
+ | Logger data leakage | CWE-532 | MEDIUM |
393
+ | Silent exception swallow | CWE-390 | MEDIUM |
394
+ | Insecure WebSocket URL | CWE-311 | MEDIUM |