@schalkneethling/calavera-skill-frontend-security 0.2.0-next.2
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/calavera-artifact.json +11 -0
- package/package.json +24 -0
- package/payload/frontend-security/SKILL.md +57 -0
- package/payload/frontend-security/agents/openai.yaml +4 -0
- package/payload/frontend-security/references/audit-workflow.md +57 -0
- package/payload/frontend-security/references/csp-configuration.md +262 -0
- package/payload/frontend-security/references/csrf-protection.md +383 -0
- package/payload/frontend-security/references/dom-security.md +263 -0
- package/payload/frontend-security/references/file-upload-security.md +662 -0
- package/payload/frontend-security/references/framework-patterns.md +328 -0
- package/payload/frontend-security/references/input-validation.md +360 -0
- package/payload/frontend-security/references/jwt-security.md +352 -0
- package/payload/frontend-security/references/nodejs-npm-security.md +325 -0
- package/payload/frontend-security/references/xss-prevention.md +212 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# JWT Security Reference
|
|
2
|
+
|
|
3
|
+
## Common Vulnerabilities
|
|
4
|
+
|
|
5
|
+
### None Algorithm Attack
|
|
6
|
+
|
|
7
|
+
Attack: Attacker changes algorithm to "none" to bypass signature verification.
|
|
8
|
+
|
|
9
|
+
```javascript
|
|
10
|
+
// VULNERABLE - explicitly accepts unsigned tokens
|
|
11
|
+
const decoded = jwt.verify(token, null, { algorithms: ["none"] });
|
|
12
|
+
|
|
13
|
+
// SECURE - explicitly specify allowed algorithms
|
|
14
|
+
const decoded = jwt.verify(token, secret, {
|
|
15
|
+
algorithms: ["HS256"], // Only accept HS256
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Algorithm Confusion Attack
|
|
20
|
+
|
|
21
|
+
Attack: Switching from RS256 to HS256 using public key as secret.
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
// VULNERABLE - disables the asymmetric key-type safety check for compatibility
|
|
25
|
+
const decoded = jwt.verify(token, publicKey, {
|
|
26
|
+
allowInvalidAsymmetricKeyTypes: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// SECURE - specify expected algorithm
|
|
30
|
+
const decoded = jwt.verify(token, publicKey, {
|
|
31
|
+
algorithms: ["RS256"], // Only accept RS256
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Weak Secret
|
|
36
|
+
|
|
37
|
+
Attack: Brute-force weak HMAC secrets.
|
|
38
|
+
|
|
39
|
+
```javascript
|
|
40
|
+
// VULNERABLE - weak secret
|
|
41
|
+
const token = jwt.sign(payload, "password123");
|
|
42
|
+
|
|
43
|
+
// SECURE - stable secret provisioned by a secret manager (256+ bits)
|
|
44
|
+
const crypto = require("node:crypto");
|
|
45
|
+
const fs = require("node:fs");
|
|
46
|
+
if (typeof process.env.JWT_SECRET_BASE64 !== "string") throw new Error("Missing JWT secret");
|
|
47
|
+
const secret = Buffer.from(process.env.JWT_SECRET_BASE64, "base64");
|
|
48
|
+
if (secret.length < 32) throw new Error("JWT secret must contain at least 256 bits");
|
|
49
|
+
const token = jwt.sign(payload, secret);
|
|
50
|
+
|
|
51
|
+
// Or use RSA keys
|
|
52
|
+
const privateKey = fs.readFileSync("private.pem");
|
|
53
|
+
const token = jwt.sign(payload, privateKey, { algorithm: "RS256" });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Token Sidejacking Prevention
|
|
57
|
+
|
|
58
|
+
For browser-held bearer tokens, a hardened fingerprint cookie can reduce reuse
|
|
59
|
+
of a stolen token. Treat it as defense in depth, not a substitute for short
|
|
60
|
+
lifetimes, XSS prevention, revocation, or keeping tokens out of browser storage
|
|
61
|
+
when the architecture allows it.
|
|
62
|
+
|
|
63
|
+
```javascript
|
|
64
|
+
const crypto = require("crypto");
|
|
65
|
+
|
|
66
|
+
// Generate fingerprint on login
|
|
67
|
+
function generateFingerprint() {
|
|
68
|
+
return crypto.randomBytes(32).toString("hex");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Create token with fingerprint hash
|
|
72
|
+
function createToken(userId, fingerprint) {
|
|
73
|
+
const fingerprintHash = crypto.createHash("sha256").update(fingerprint).digest("hex");
|
|
74
|
+
|
|
75
|
+
return jwt.sign({ sub: userId, fph: fingerprintHash }, secret, { expiresIn: "15m" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Set fingerprint as httpOnly cookie
|
|
79
|
+
res.cookie("__Secure-Fgp", fingerprint, {
|
|
80
|
+
httpOnly: true,
|
|
81
|
+
secure: true,
|
|
82
|
+
sameSite: "Strict",
|
|
83
|
+
maxAge: 15 * 60 * 1000, // Match token expiry
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Validate both token and fingerprint
|
|
87
|
+
function validateToken(token, fingerprintCookie, { requireFingerprint = false } = {}) {
|
|
88
|
+
const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });
|
|
89
|
+
|
|
90
|
+
if (!requireFingerprint) return decoded;
|
|
91
|
+
if (typeof fingerprintCookie !== "string") throw new Error("Missing fingerprint cookie");
|
|
92
|
+
|
|
93
|
+
const fingerprintHash = crypto.createHash("sha256").update(fingerprintCookie).digest("hex");
|
|
94
|
+
|
|
95
|
+
if (decoded.fph !== fingerprintHash) {
|
|
96
|
+
throw new Error("Invalid fingerprint");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return decoded;
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Token Storage and Browser Architecture
|
|
104
|
+
|
|
105
|
+
Choose token handling from the application architecture. Do not present
|
|
106
|
+
Web Storage as a universal default.
|
|
107
|
+
|
|
108
|
+
### Traditional Web App or BFF: httpOnly Cookie
|
|
109
|
+
|
|
110
|
+
Prefer server-owned sessions or a backend-for-frontend (BFF) that stores tokens
|
|
111
|
+
server-side and sends only `httpOnly`, `Secure`, SameSite cookies to the
|
|
112
|
+
browser. Cookie-based auth needs CSRF protection on state-changing requests;
|
|
113
|
+
SameSite is useful defense in depth but does not replace tokens plus
|
|
114
|
+
Origin/Referer checks for sensitive operations.
|
|
115
|
+
|
|
116
|
+
```javascript
|
|
117
|
+
// Server sets cookie
|
|
118
|
+
res.cookie("token", jwt, {
|
|
119
|
+
httpOnly: true, // Not accessible via JavaScript
|
|
120
|
+
secure: true, // HTTPS only
|
|
121
|
+
sameSite: "Strict", // CSRF protection
|
|
122
|
+
maxAge: 15 * 60 * 1000,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Need CSRF protection with cookie-based auth
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### SPA: In-Memory Access Token
|
|
129
|
+
|
|
130
|
+
For browser-only SPAs, prefer short-lived access tokens kept in memory, refresh
|
|
131
|
+
token rotation where appropriate, and a clear re-authentication path. Avoid
|
|
132
|
+
long-lived bearer tokens in Web Storage. If refresh tokens are issued to browser
|
|
133
|
+
clients, rotate them, expire them, detect reuse, and avoid storing them in
|
|
134
|
+
`localStorage` or other long-lived JavaScript-readable storage.
|
|
135
|
+
|
|
136
|
+
```javascript
|
|
137
|
+
let accessToken = null;
|
|
138
|
+
const trustedApiOrigin = "https://api.example.com";
|
|
139
|
+
|
|
140
|
+
export function setAccessToken(token) {
|
|
141
|
+
accessToken = token;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function apiFetch(url, options = {}) {
|
|
145
|
+
const target = new URL(url, trustedApiOrigin);
|
|
146
|
+
if (target.origin !== trustedApiOrigin) {
|
|
147
|
+
throw new Error("Refusing to send bearer token to an untrusted origin");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return fetch(target.href, {
|
|
151
|
+
...options,
|
|
152
|
+
headers: {
|
|
153
|
+
...options.headers,
|
|
154
|
+
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Constrained Fallback: sessionStorage + Fingerprint Cookie
|
|
161
|
+
|
|
162
|
+
`sessionStorage` is a last-resort compromise for browser-held bearer tokens, not
|
|
163
|
+
a security improvement over in-memory storage. It is readable by XSS. Use it
|
|
164
|
+
only when the architecture requires browser-held tokens and the risk is
|
|
165
|
+
explicitly accepted. Keep token lifetimes short, bind tokens to an `httpOnly`
|
|
166
|
+
fingerprint cookie when possible, and clear tokens on logout.
|
|
167
|
+
|
|
168
|
+
```javascript
|
|
169
|
+
sessionStorage.setItem("token", jwt);
|
|
170
|
+
sessionStorage.removeItem("token");
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Avoid: localStorage
|
|
174
|
+
|
|
175
|
+
```javascript
|
|
176
|
+
// VULNERABLE - persists after browser close, accessible to XSS
|
|
177
|
+
localStorage.setItem("token", jwt); // Not recommended
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Token Expiration
|
|
181
|
+
|
|
182
|
+
The refresh-token example is a server-side sketch. For browser clients, do not
|
|
183
|
+
pair it with long-lived refresh tokens stored in Web Storage; prefer a BFF,
|
|
184
|
+
server-side session, or refresh-token rotation with storage and replay controls
|
|
185
|
+
chosen for the client architecture.
|
|
186
|
+
|
|
187
|
+
```javascript
|
|
188
|
+
// Short-lived access tokens (15-60 minutes)
|
|
189
|
+
const accessToken = jwt.sign(payload, secret, { expiresIn: "15m" });
|
|
190
|
+
|
|
191
|
+
// Use Redis or another shared durable store in production. Atomic consume is
|
|
192
|
+
// required so two requests cannot rotate the same refresh token.
|
|
193
|
+
const activeRefreshTokens = new Map();
|
|
194
|
+
|
|
195
|
+
function issueRefreshToken(userId) {
|
|
196
|
+
const jti = crypto.randomUUID();
|
|
197
|
+
const token = jwt.sign({ sub: userId, type: "refresh", jti }, refreshSecret, {
|
|
198
|
+
expiresIn: "7d",
|
|
199
|
+
});
|
|
200
|
+
activeRefreshTokens.set(jti, userId);
|
|
201
|
+
return token;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const refreshToken = issueRefreshToken(userId);
|
|
205
|
+
|
|
206
|
+
// Refresh endpoint
|
|
207
|
+
app.post("/refresh", async (req, res) => {
|
|
208
|
+
const { refreshToken } = req.body;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const decoded = jwt.verify(refreshToken, refreshSecret, {
|
|
212
|
+
algorithms: ["HS256"],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (decoded.type !== "refresh") {
|
|
216
|
+
throw new Error("Invalid token type");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!decoded.jti || activeRefreshTokens.get(decoded.jti) !== decoded.sub) {
|
|
220
|
+
throw new Error("Token revoked");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// In production, replace this Map operation with an atomic get-and-delete.
|
|
224
|
+
activeRefreshTokens.delete(decoded.jti);
|
|
225
|
+
|
|
226
|
+
// Issue new access token
|
|
227
|
+
const newAccessToken = jwt.sign({ sub: decoded.sub, jti: crypto.randomUUID() }, secret, {
|
|
228
|
+
expiresIn: "15m",
|
|
229
|
+
});
|
|
230
|
+
const newRefreshToken = issueRefreshToken(decoded.sub);
|
|
231
|
+
|
|
232
|
+
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
|
|
233
|
+
} catch (error) {
|
|
234
|
+
res.status(401).json({ error: "Invalid refresh token" });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Token Revocation
|
|
240
|
+
|
|
241
|
+
JWTs are stateless, so revocation requires additional mechanisms:
|
|
242
|
+
|
|
243
|
+
```javascript
|
|
244
|
+
// Denylist approach
|
|
245
|
+
const revokedTokens = new Map(); // Use Redis in production
|
|
246
|
+
|
|
247
|
+
function revokeToken(token) {
|
|
248
|
+
const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });
|
|
249
|
+
const tokenId = decoded.jti;
|
|
250
|
+
const expiry = decoded.exp * 1000;
|
|
251
|
+
|
|
252
|
+
if (!tokenId || !expiry) {
|
|
253
|
+
throw new Error("Token missing required revocation claims");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Store until token expires
|
|
257
|
+
revokedTokens.set(tokenId, expiry);
|
|
258
|
+
|
|
259
|
+
// Clean up expired entries periodically
|
|
260
|
+
setTimeout(() => revokedTokens.delete(tokenId), Math.max(0, expiry - Date.now()));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isTokenRevoked(decoded) {
|
|
264
|
+
return Boolean(decoded.jti) && revokedTokens.has(decoded.jti);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Include jti (JWT ID) in tokens
|
|
268
|
+
const token = jwt.sign({ sub: userId, jti: crypto.randomUUID() }, secret, { expiresIn: "15m" });
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Token Information Disclosure
|
|
272
|
+
|
|
273
|
+
JWTs are base64-encoded, not encrypted. Sensitive data is visible.
|
|
274
|
+
|
|
275
|
+
```javascript
|
|
276
|
+
// VULNERABLE - sensitive data in payload
|
|
277
|
+
const token = jwt.sign(
|
|
278
|
+
{
|
|
279
|
+
sub: userId,
|
|
280
|
+
ssn: "123-45-6789", // Visible to anyone!
|
|
281
|
+
salary: 100000,
|
|
282
|
+
},
|
|
283
|
+
secret,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// SECURE - minimal claims
|
|
287
|
+
const token = jwt.sign(
|
|
288
|
+
{
|
|
289
|
+
sub: userId,
|
|
290
|
+
role: "user",
|
|
291
|
+
},
|
|
292
|
+
secret,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// If sensitive claims are unavoidable, use JWE or keep the data server-side.
|
|
296
|
+
const encryptedToken = encrypt(token, encryptionKey);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Validation Middleware
|
|
300
|
+
|
|
301
|
+
This middleware validates bearer tokens after they reach an API. It does not
|
|
302
|
+
recommend where the browser should store those tokens; use the storage guidance
|
|
303
|
+
above before deciding whether a browser should send an `Authorization` header at
|
|
304
|
+
all.
|
|
305
|
+
|
|
306
|
+
```javascript
|
|
307
|
+
function authenticateToken(req, res, next) {
|
|
308
|
+
// Get token from header
|
|
309
|
+
const authHeader = req.headers.authorization;
|
|
310
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
|
|
311
|
+
|
|
312
|
+
if (!token) {
|
|
313
|
+
return res.status(401).json({ error: "Token required" });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
// Validate token and fingerprint when using sidejacking protection
|
|
318
|
+
const fingerprint = req.cookies["__Secure-Fgp"];
|
|
319
|
+
const decoded = validateToken(token, fingerprint);
|
|
320
|
+
|
|
321
|
+
// Check revocation
|
|
322
|
+
if (isTokenRevoked(decoded)) {
|
|
323
|
+
throw new Error("Token revoked");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
req.user = decoded;
|
|
327
|
+
next();
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error.name === "TokenExpiredError") {
|
|
330
|
+
return res.status(401).json({ error: "Token expired" });
|
|
331
|
+
}
|
|
332
|
+
return res.status(403).json({ error: "Invalid token" });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Security Checklist
|
|
338
|
+
|
|
339
|
+
- [ ] Explicit algorithm specification (never auto-detect)
|
|
340
|
+
- [ ] Strong secret (256+ bits) or RSA keys
|
|
341
|
+
- [ ] Short expiration times (15-60 minutes for access tokens)
|
|
342
|
+
- [ ] Fingerprint cookie considered for browser-held bearer tokens where appropriate
|
|
343
|
+
- [ ] Validate issuer (iss) and audience (aud) claims
|
|
344
|
+
- [ ] Implement token revocation mechanism
|
|
345
|
+
- [ ] No sensitive data in payload
|
|
346
|
+
- [ ] Choose storage based on architecture: server-side session/BFF, in-memory SPA token, or constrained sessionStorage fallback
|
|
347
|
+
- [ ] Refresh tokens are rotated, replay-detected, and not kept in long-lived Web Storage
|
|
348
|
+
- [ ] Avoid long-lived bearer tokens in Web Storage
|
|
349
|
+
- [ ] Protect cookie-based auth with CSRF defenses
|
|
350
|
+
- [ ] Use HTTPS only
|
|
351
|
+
|
|
352
|
+
OWASP Reference: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# Node.js and NPM Security Reference
|
|
2
|
+
|
|
3
|
+
## NPM Dependency Security
|
|
4
|
+
|
|
5
|
+
### Audit Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Check for vulnerabilities
|
|
9
|
+
npm audit
|
|
10
|
+
|
|
11
|
+
# Preview the proposed changes and lockfile diff first, then run `npm audit fix`
|
|
12
|
+
npm audit fix
|
|
13
|
+
|
|
14
|
+
# Force fix can apply breaking direct or transitive upgrades. Use only after
|
|
15
|
+
# reviewing release notes, dependency graphs, lockfile diffs, and test results.
|
|
16
|
+
npm audit fix --force
|
|
17
|
+
|
|
18
|
+
# Generate detailed report
|
|
19
|
+
npm audit --json > audit-report.json
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Lockfile Enforcement
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Always use lockfile in CI/CD
|
|
26
|
+
npm ci # Instead of npm install
|
|
27
|
+
|
|
28
|
+
# Verify lockfile integrity
|
|
29
|
+
npm ci --ignore-scripts # Safer for first run
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Dependency Update Review
|
|
33
|
+
|
|
34
|
+
Treat automated dependency updates as reviewable changes, not background
|
|
35
|
+
maintenance. Dependabot or Renovate PRs are useful because they isolate the
|
|
36
|
+
package, lockfile, release notes, provenance or publisher signals where
|
|
37
|
+
available, and CI result for each update. Review dependency diffs carefully for
|
|
38
|
+
new install scripts, ownership changes, unexpected transitive churn, native
|
|
39
|
+
build steps, and broad permission or runtime changes.
|
|
40
|
+
|
|
41
|
+
Run audits in CI and scheduled automation so ordinary installs are predictable.
|
|
42
|
+
Use `npm audit fix --force` only when the breaking-change and transitive-update
|
|
43
|
+
effects are understood.
|
|
44
|
+
|
|
45
|
+
### Package.json Security
|
|
46
|
+
|
|
47
|
+
Avoid package lifecycle scripts that fetch or audit during ordinary installs.
|
|
48
|
+
Install-time `npx` expands supply-chain trust, and `postinstall` audits make
|
|
49
|
+
local installs depend on network and registry behavior. Prefer pinned
|
|
50
|
+
devDependencies, package-manager-native overrides, CI checks, and scheduled
|
|
51
|
+
dependency automation.
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"overrides": {
|
|
56
|
+
"vulnerable-package": "^2.0.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"audit:ci": "npm audit --audit-level=high"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Dangerous Functions
|
|
65
|
+
|
|
66
|
+
### Code Execution
|
|
67
|
+
|
|
68
|
+
```javascript
|
|
69
|
+
// DANGEROUS - never use with user input
|
|
70
|
+
eval(userInput);
|
|
71
|
+
new Function(userInput);
|
|
72
|
+
vm.runInThisContext(userInput);
|
|
73
|
+
require(userInput);
|
|
74
|
+
|
|
75
|
+
// DANGEROUS - dynamic module loading with user-controlled specifiers
|
|
76
|
+
await import(userInput);
|
|
77
|
+
|
|
78
|
+
// SAFER - map user choices to fixed module specifiers
|
|
79
|
+
const allowedModules = new Map([["csv", "./parsers/csv.js"]]);
|
|
80
|
+
const modulePath = allowedModules.get(userInput);
|
|
81
|
+
if (!modulePath) throw new Error("Invalid module");
|
|
82
|
+
await import(modulePath);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Child Process Injection
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
// DANGEROUS - command injection
|
|
89
|
+
const { exec } = require("child_process");
|
|
90
|
+
exec(`ls ${userInput}`); // Shell injection
|
|
91
|
+
|
|
92
|
+
// SAFER - use execFile with arguments array and no shell
|
|
93
|
+
const { execFile } = require("child_process");
|
|
94
|
+
execFile("ls", ["--", userInput], { shell: false }, callback);
|
|
95
|
+
|
|
96
|
+
// SAFEST - avoid shelling out when a library API can do the work.
|
|
97
|
+
// If a command is necessary, allowlist the command and argument shapes.
|
|
98
|
+
const { spawn } = require("child_process");
|
|
99
|
+
const allowedFormats = new Set(["json", "text"]);
|
|
100
|
+
if (!allowedFormats.has(userSelectedFormat)) {
|
|
101
|
+
throw new Error("Invalid format");
|
|
102
|
+
}
|
|
103
|
+
spawn("tool", ["--format", userSelectedFormat, "--", userPath], {
|
|
104
|
+
shell: false,
|
|
105
|
+
cwd: "/srv/app",
|
|
106
|
+
env: { PATH: "/usr/bin:/bin" },
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Argument arrays prevent shell metacharacter expansion, but they do not make
|
|
111
|
+
user-controlled arguments safe automatically. Some programs treat values as
|
|
112
|
+
options, paths, patterns, or expressions. Use `--` before user-controlled path
|
|
113
|
+
arguments when supported, validate option values, set `cwd` and `env`
|
|
114
|
+
explicitly, run with least privilege, and treat command output as untrusted.
|
|
115
|
+
|
|
116
|
+
### File System
|
|
117
|
+
|
|
118
|
+
```javascript
|
|
119
|
+
const path = require("node:path");
|
|
120
|
+
const fs = require("node:fs");
|
|
121
|
+
|
|
122
|
+
// DANGEROUS - path traversal
|
|
123
|
+
const filePath = `/uploads/${userInput}`;
|
|
124
|
+
|
|
125
|
+
// Keep baseDir and its parent directories trusted and non-writable by users.
|
|
126
|
+
function safeReadFile(userInput, baseDir) {
|
|
127
|
+
const basePath = fs.realpathSync(baseDir);
|
|
128
|
+
const requestedPath = path.resolve(basePath, userInput);
|
|
129
|
+
const safePath = fs.realpathSync(requestedPath);
|
|
130
|
+
const relativePath = path.relative(basePath, safePath);
|
|
131
|
+
|
|
132
|
+
// Verify path is within allowed directory
|
|
133
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
134
|
+
throw new Error("Invalid file path");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// O_NOFOLLOW prevents a final-component symlink swap between validation and open.
|
|
138
|
+
// The trusted-directory requirement prevents intermediate path replacement.
|
|
139
|
+
const fd = fs.openSync(safePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
140
|
+
try {
|
|
141
|
+
return fs.readFileSync(fd);
|
|
142
|
+
} finally {
|
|
143
|
+
fs.closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Request Handling
|
|
149
|
+
|
|
150
|
+
### Rate Limiting
|
|
151
|
+
|
|
152
|
+
The default memory store below is suitable only for a single-process development example. In
|
|
153
|
+
multi-process or multi-instance deployments, configure a shared store such as Redis and set
|
|
154
|
+
Express `trust proxy` only to the exact proxy topology so client IP keys cannot be spoofed.
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
const rateLimit = require("express-rate-limit");
|
|
158
|
+
|
|
159
|
+
const limiter = rateLimit({
|
|
160
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
161
|
+
max: 100, // Limit each IP to 100 requests per window
|
|
162
|
+
message: "Too many requests",
|
|
163
|
+
standardHeaders: true,
|
|
164
|
+
legacyHeaders: false,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
app.use(limiter);
|
|
168
|
+
|
|
169
|
+
// Stricter limits for auth endpoints
|
|
170
|
+
const authLimiter = rateLimit({
|
|
171
|
+
windowMs: 60 * 60 * 1000, // 1 hour
|
|
172
|
+
max: 5, // 5 attempts per hour
|
|
173
|
+
message: "Too many login attempts",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
app.use("/api/login", authLimiter);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Request Size Limits
|
|
180
|
+
|
|
181
|
+
```javascript
|
|
182
|
+
const express = require("express");
|
|
183
|
+
const app = express();
|
|
184
|
+
|
|
185
|
+
// Limit JSON body size
|
|
186
|
+
app.use(express.json({ limit: "100kb" }));
|
|
187
|
+
|
|
188
|
+
// Limit URL-encoded body
|
|
189
|
+
app.use(express.urlencoded({ extended: true, limit: "100kb" }));
|
|
190
|
+
|
|
191
|
+
// Limit file uploads
|
|
192
|
+
const multer = require("multer");
|
|
193
|
+
const upload = multer({
|
|
194
|
+
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Timeout Configuration
|
|
199
|
+
|
|
200
|
+
```javascript
|
|
201
|
+
const server = app.listen(3000);
|
|
202
|
+
|
|
203
|
+
// Set timeouts
|
|
204
|
+
server.setTimeout(30000); // 30 seconds
|
|
205
|
+
server.keepAliveTimeout = 65000;
|
|
206
|
+
server.headersTimeout = 66000;
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Secure Headers
|
|
210
|
+
|
|
211
|
+
```javascript
|
|
212
|
+
const helmet = require("helmet");
|
|
213
|
+
|
|
214
|
+
app.use(
|
|
215
|
+
helmet({
|
|
216
|
+
contentSecurityPolicy: {
|
|
217
|
+
directives: {
|
|
218
|
+
defaultSrc: ["'self'"],
|
|
219
|
+
scriptSrc: ["'self'"],
|
|
220
|
+
styleSrc: ["'self'"],
|
|
221
|
+
imgSrc: ["'self'", "data:", "https:"],
|
|
222
|
+
objectSrc: ["'none'"],
|
|
223
|
+
upgradeInsecureRequests: [],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
hsts: {
|
|
227
|
+
maxAge: 31536000,
|
|
228
|
+
includeSubDomains: true,
|
|
229
|
+
preload: true,
|
|
230
|
+
},
|
|
231
|
+
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
|
|
232
|
+
frameguard: { action: "deny" },
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Error Handling
|
|
238
|
+
|
|
239
|
+
```javascript
|
|
240
|
+
// Global error handler - don't expose details
|
|
241
|
+
app.use((err, req, res, next) => {
|
|
242
|
+
// Use a structured logger configured to redact credentials, request data,
|
|
243
|
+
// SQL, tokens, and PII. Emit only reviewed fields.
|
|
244
|
+
logger.error({
|
|
245
|
+
message: typeof err?.message === "string" ? err.message.slice(0, 256) : "Unhandled error",
|
|
246
|
+
stack: process.env.NODE_ENV === "development" ? err?.stack : undefined,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Send generic message to client
|
|
250
|
+
res.status(500).json({
|
|
251
|
+
error: "An unexpected error occurred",
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Async error wrapper
|
|
256
|
+
const asyncHandler = (fn) => (req, res, next) => {
|
|
257
|
+
Promise.resolve(fn(req, res, next)).catch(next);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
app.get(
|
|
261
|
+
"/data",
|
|
262
|
+
asyncHandler(async (req, res) => {
|
|
263
|
+
const data = await fetchData();
|
|
264
|
+
res.json(data);
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Environment Variables
|
|
270
|
+
|
|
271
|
+
```javascript
|
|
272
|
+
// NEVER commit secrets to code
|
|
273
|
+
// Use environment variables
|
|
274
|
+
const apiKey = process.env.API_KEY;
|
|
275
|
+
|
|
276
|
+
// Validate required env vars at startup
|
|
277
|
+
const required = ["API_KEY", "DB_URL", "SESSION_SECRET"];
|
|
278
|
+
required.forEach((varName) => {
|
|
279
|
+
if (!process.env[varName]) {
|
|
280
|
+
console.error(`Missing required env var: ${varName}`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Regex DoS Prevention
|
|
287
|
+
|
|
288
|
+
```javascript
|
|
289
|
+
// DANGEROUS - evil regex (catastrophic backtracking)
|
|
290
|
+
const evilRegex = /^(a+)+$/;
|
|
291
|
+
evilRegex.test("aaaaaaaaaaaaaaaaaaaaaaaaaaa!"); // Hangs
|
|
292
|
+
|
|
293
|
+
// Heuristic only: safe-regex can have false positives/negatives for ReDoS
|
|
294
|
+
const safe = require("safe-regex");
|
|
295
|
+
if (!safe(userProvidedRegex)) {
|
|
296
|
+
throw new Error("Unsafe regex pattern");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Preferred for untrusted patterns: use RE2 for guaranteed linear time
|
|
300
|
+
const RE2 = require("re2");
|
|
301
|
+
const pattern = new RE2(userProvidedRegex);
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## NPM Security Checklist
|
|
305
|
+
|
|
306
|
+
- [ ] Run `npm audit` regularly and in CI/CD
|
|
307
|
+
- [ ] Use `npm ci` instead of `npm install` in CI
|
|
308
|
+
- [ ] Enable 2FA on npm account
|
|
309
|
+
- [ ] Use lockfiles and commit them
|
|
310
|
+
- [ ] Review new dependencies before installation
|
|
311
|
+
- [ ] Use `--ignore-scripts` for untrusted packages
|
|
312
|
+
- [ ] Set up automated vulnerability scanning such as Dependabot or Renovate
|
|
313
|
+
- [ ] Review dependency and lockfile diffs for risky scripts, publisher changes, and unexpected transitive churn
|
|
314
|
+
- [ ] Keep dependencies updated
|
|
315
|
+
- [ ] Avoid typosquatting by double-checking package names
|
|
316
|
+
- [ ] Use `npm-shrinkwrap.json` only when publishing a deployable app or CLI that must lock transitive dependencies; avoid it for libraries unless you intentionally want to constrain consumers
|
|
317
|
+
|
|
318
|
+
OWASP References:
|
|
319
|
+
|
|
320
|
+
- https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html
|
|
321
|
+
- https://cheatsheetseries.owasp.org/cheatsheets/NPM_Security_Cheat_Sheet.html
|
|
322
|
+
- https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
|
|
323
|
+
- https://docs.npmjs.com/cli/commands/npm-audit
|
|
324
|
+
- https://docs.github.com/en/code-security/dependabot
|
|
325
|
+
- https://nodejs.org/api/child_process.html
|