@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,383 @@
|
|
|
1
|
+
# CSRF Protection Reference
|
|
2
|
+
|
|
3
|
+
## Token-Based Protection
|
|
4
|
+
|
|
5
|
+
### Synchronizer Token Pattern
|
|
6
|
+
|
|
7
|
+
Generate unique per-session tokens and validate on state-changing requests:
|
|
8
|
+
|
|
9
|
+
```javascript
|
|
10
|
+
// Server-side token generation (Node.js)
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
|
|
13
|
+
function generateCSRFToken(session) {
|
|
14
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
15
|
+
session.csrfToken = token;
|
|
16
|
+
return token;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Middleware validation
|
|
20
|
+
function validateCSRF(req, res, next) {
|
|
21
|
+
const token = req.headers["x-csrf-token"] || req.body?._csrf;
|
|
22
|
+
const sessionToken = req.session?.csrfToken;
|
|
23
|
+
|
|
24
|
+
if (typeof token !== "string" || typeof sessionToken !== "string") {
|
|
25
|
+
return res.status(403).json({ error: "Invalid CSRF token" });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const tokenBuffer = Buffer.from(token);
|
|
29
|
+
const sessionTokenBuffer = Buffer.from(sessionToken);
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
tokenBuffer.length !== sessionTokenBuffer.length ||
|
|
33
|
+
!crypto.timingSafeEqual(tokenBuffer, sessionTokenBuffer)
|
|
34
|
+
) {
|
|
35
|
+
return res.status(403).json({ error: "Invalid CSRF token" });
|
|
36
|
+
}
|
|
37
|
+
next();
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Naive Double Submit Cookie Pattern (Insufficient Alone)
|
|
42
|
+
|
|
43
|
+
Do not rely on a plain "cookie equals header" comparison as the main CSRF
|
|
44
|
+
defense. Use signed, session-bound double-submit tokens when the synchronizer
|
|
45
|
+
token pattern is not practical.
|
|
46
|
+
|
|
47
|
+
```javascript
|
|
48
|
+
// Recognition-only pseudocode for audits. Do not paste into production.
|
|
49
|
+
// If all the server checks is:
|
|
50
|
+
//
|
|
51
|
+
// cookie.csrf_token === header["x-csrf-token"]
|
|
52
|
+
//
|
|
53
|
+
// then an attacker who can set or inject the cookie value may be able to satisfy
|
|
54
|
+
// both sides. Replace this pattern with a signed, session-bound token flow such
|
|
55
|
+
// as the Express.js example below.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## SameSite Cookie Attribute
|
|
59
|
+
|
|
60
|
+
```javascript
|
|
61
|
+
// Strict - never sent cross-site
|
|
62
|
+
res.cookie("session", value, { sameSite: "Strict", secure: true, httpOnly: true });
|
|
63
|
+
|
|
64
|
+
// Lax - sent for top-level GET navigations (default in modern browsers)
|
|
65
|
+
res.cookie("session", value, { sameSite: "Lax", secure: true, httpOnly: true });
|
|
66
|
+
|
|
67
|
+
// None - requires Secure flag, sent cross-site
|
|
68
|
+
res.cookie("session", value, { sameSite: "None", secure: true, httpOnly: true });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Recommendation**: Use `SameSite=Strict` for session cookies when possible, `Lax` as minimum.
|
|
72
|
+
|
|
73
|
+
## Fetch Metadata Headers
|
|
74
|
+
|
|
75
|
+
Validate request origin using Sec-Fetch-\* headers as a layered control. Fetch
|
|
76
|
+
Metadata does not replace CSRF tokens for cookie-authenticated state-changing
|
|
77
|
+
requests. `Sec-Fetch-Site: none` usually represents user-initiated browser
|
|
78
|
+
navigations and should be allowed only for safe top-level navigations.
|
|
79
|
+
`same-site` can include sibling subdomains, so allow it only when every same-site
|
|
80
|
+
origin shares the same trust boundary.
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
function validateFetchMetadata(req, res, next) {
|
|
84
|
+
const site = req.headers["sec-fetch-site"];
|
|
85
|
+
const mode = req.headers["sec-fetch-mode"];
|
|
86
|
+
const dest = req.headers["sec-fetch-dest"];
|
|
87
|
+
const method = req.method;
|
|
88
|
+
const safeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
|
|
89
|
+
const trustSameSiteSubdomains = false;
|
|
90
|
+
|
|
91
|
+
// Absence is not proof of safety. Continue only to rely on CSRF tokens and
|
|
92
|
+
// Origin/Referer validation in later middleware.
|
|
93
|
+
if (!site) return next();
|
|
94
|
+
|
|
95
|
+
// Allow same-origin requests
|
|
96
|
+
if (site === "same-origin") return next();
|
|
97
|
+
|
|
98
|
+
// Same-site can include sibling subdomains. Only allow it where those
|
|
99
|
+
// subdomains share the same trust boundary, and do not use it to permit
|
|
100
|
+
// state-changing requests without CSRF token validation.
|
|
101
|
+
if (site === "same-site" && trustSameSiteSubdomains && safeMethod) {
|
|
102
|
+
return next();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Allow browser-initiated top-level navigations only for safe methods.
|
|
106
|
+
if (site === "none" && mode === "navigate" && dest === "document" && safeMethod) {
|
|
107
|
+
return next();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return res.status(403).json({ error: "Fetch metadata validation failed" });
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Origin and Referer Checks
|
|
115
|
+
|
|
116
|
+
Check `Origin` for state-changing requests and fall back to `Referer` only when
|
|
117
|
+
needed. Treat missing headers according to the endpoint risk and client support.
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
function validateOrigin(req, res, next) {
|
|
121
|
+
const expectedOrigin = "https://app.example.com";
|
|
122
|
+
const origin = req.headers.origin;
|
|
123
|
+
const referer = req.headers.referer;
|
|
124
|
+
|
|
125
|
+
if (origin) {
|
|
126
|
+
if (origin !== expectedOrigin) {
|
|
127
|
+
return res.status(403).json({ error: "Invalid origin" });
|
|
128
|
+
}
|
|
129
|
+
return next();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (referer) {
|
|
133
|
+
try {
|
|
134
|
+
if (new URL(referer).origin !== expectedOrigin) {
|
|
135
|
+
return res.status(403).json({ error: "Invalid referer" });
|
|
136
|
+
}
|
|
137
|
+
return next();
|
|
138
|
+
} catch {
|
|
139
|
+
return res.status(403).json({ error: "Invalid referer" });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return res.status(403).json({ error: "Missing origin" });
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Framework Integration
|
|
148
|
+
|
|
149
|
+
### Express.js with Signed Double-Submit Tokens
|
|
150
|
+
|
|
151
|
+
This server-rendered variant stores the signed token in an `httpOnly` cookie and
|
|
152
|
+
also injects the same token into the form response. Do not combine this exact
|
|
153
|
+
cookie setting with client libraries that expect JavaScript to read the CSRF
|
|
154
|
+
cookie automatically. For AJAX-only clients, inject the token into the page or
|
|
155
|
+
use a readable signed CSRF cookie only when that exposure is an accepted tradeoff;
|
|
156
|
+
the server must still verify the signature and session binding.
|
|
157
|
+
|
|
158
|
+
Install body parsing, session, and cookie parsing middleware before these handlers. Missing
|
|
159
|
+
session or cookie state must fail closed rather than throw or bypass validation.
|
|
160
|
+
|
|
161
|
+
```javascript
|
|
162
|
+
const crypto = require("crypto");
|
|
163
|
+
|
|
164
|
+
const CSRF_COOKIE_NAME = "csrf_token";
|
|
165
|
+
if (!/^[a-f0-9]{64,}$/i.test(process.env.CSRF_SECRET || "")) {
|
|
166
|
+
throw new Error("CSRF_SECRET must be a hex-encoded secret with at least 32 bytes of entropy");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const CSRF_SECRET = Buffer.from(process.env.CSRF_SECRET, "hex");
|
|
170
|
+
|
|
171
|
+
function signToken(sessionId, nonce) {
|
|
172
|
+
return crypto
|
|
173
|
+
.createHmac("sha256", CSRF_SECRET)
|
|
174
|
+
.update(`${sessionId}:${nonce}`)
|
|
175
|
+
.digest("base64url");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function constantTimeEqual(value, expected) {
|
|
179
|
+
if (typeof value !== "string" || typeof expected !== "string") return false;
|
|
180
|
+
|
|
181
|
+
const valueBuffer = Buffer.from(value);
|
|
182
|
+
const expectedBuffer = Buffer.from(expected);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
valueBuffer.length === expectedBuffer.length &&
|
|
186
|
+
crypto.timingSafeEqual(valueBuffer, expectedBuffer)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function generateToken(req) {
|
|
191
|
+
if (typeof req.session?.id !== "string") return null;
|
|
192
|
+
const nonce = crypto.randomBytes(32).toString("base64url");
|
|
193
|
+
const signature = signToken(req.session.id, nonce);
|
|
194
|
+
return `${nonce}.${signature}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sendToken(req, res, next) {
|
|
198
|
+
const token = generateToken(req);
|
|
199
|
+
if (!token) return res.status(403).json({ error: "Invalid CSRF state" });
|
|
200
|
+
|
|
201
|
+
res.cookie(CSRF_COOKIE_NAME, token, {
|
|
202
|
+
httpOnly: true,
|
|
203
|
+
secure: true,
|
|
204
|
+
sameSite: "Strict",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
res.locals.csrfToken = token;
|
|
208
|
+
next();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function verifyToken(req, res, next) {
|
|
212
|
+
const token = req.headers["x-csrf-token"] || req.body?._csrf;
|
|
213
|
+
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
|
|
214
|
+
const sessionId = req.session?.id;
|
|
215
|
+
|
|
216
|
+
if (typeof sessionId !== "string" || !constantTimeEqual(token, cookieToken)) {
|
|
217
|
+
return res.status(403).json({ error: "Invalid CSRF token" });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const [nonce, signature, extra] = cookieToken.split(".");
|
|
221
|
+
if (extra || !nonce || !signature) {
|
|
222
|
+
return res.status(403).json({ error: "Invalid CSRF token" });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!constantTimeEqual(signature, signToken(sessionId, nonce))) {
|
|
226
|
+
return res.status(403).json({ error: "Invalid CSRF token" });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
next();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
app.get("/form", sendToken, (req, res) => {
|
|
233
|
+
res.render("form", { csrfToken: res.locals.csrfToken });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
app.post("/submit", verifyToken, (req, res) => {
|
|
237
|
+
res.json({ ok: true });
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Example tests for token issuance and verification:
|
|
242
|
+
|
|
243
|
+
```javascript
|
|
244
|
+
const assert = require("node:assert/strict");
|
|
245
|
+
const test = require("node:test");
|
|
246
|
+
|
|
247
|
+
test("sendToken issues a cookie and exposes a form token", () => {
|
|
248
|
+
const req = { session: { id: "session-123" } };
|
|
249
|
+
const res = {
|
|
250
|
+
locals: {},
|
|
251
|
+
cookie(name, value, options) {
|
|
252
|
+
this.cookieArgs = { name, value, options };
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
sendToken(req, res, () => {});
|
|
257
|
+
|
|
258
|
+
assert.equal(res.cookieArgs.name, CSRF_COOKIE_NAME);
|
|
259
|
+
assert.equal(res.locals.csrfToken, res.cookieArgs.value);
|
|
260
|
+
assert.equal(res.cookieArgs.options.httpOnly, true);
|
|
261
|
+
assert.equal(res.cookieArgs.options.sameSite, "Strict");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("verifyToken accepts a matching signed token", () => {
|
|
265
|
+
const req = { session: { id: "session-123" } };
|
|
266
|
+
const token = generateToken(req);
|
|
267
|
+
let called = false;
|
|
268
|
+
|
|
269
|
+
verifyToken(
|
|
270
|
+
{
|
|
271
|
+
...req,
|
|
272
|
+
headers: { "x-csrf-token": token },
|
|
273
|
+
body: {},
|
|
274
|
+
cookies: { [CSRF_COOKIE_NAME]: token },
|
|
275
|
+
},
|
|
276
|
+
{},
|
|
277
|
+
() => {
|
|
278
|
+
called = true;
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
assert.equal(called, true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("verifyToken rejects mismatched or tampered tokens", () => {
|
|
286
|
+
const req = {
|
|
287
|
+
session: { id: "session-123" },
|
|
288
|
+
headers: { "x-csrf-token": "tampered.token" },
|
|
289
|
+
body: {},
|
|
290
|
+
cookies: { [CSRF_COOKIE_NAME]: generateToken({ session: { id: "session-123" } }) },
|
|
291
|
+
};
|
|
292
|
+
const res = {
|
|
293
|
+
status(code) {
|
|
294
|
+
this.statusCode = code;
|
|
295
|
+
return this;
|
|
296
|
+
},
|
|
297
|
+
json(body) {
|
|
298
|
+
this.body = body;
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
verifyToken(req, res, () => {});
|
|
303
|
+
|
|
304
|
+
assert.equal(res.statusCode, 403);
|
|
305
|
+
assert.deepEqual(res.body, { error: "Invalid CSRF token" });
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### React Forms
|
|
310
|
+
|
|
311
|
+
```jsx
|
|
312
|
+
function Form({ csrfToken }) {
|
|
313
|
+
const handleSubmit = async (e) => {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
await fetch("/api/submit", {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: {
|
|
318
|
+
"Content-Type": "application/json",
|
|
319
|
+
"X-CSRF-Token": csrfToken,
|
|
320
|
+
},
|
|
321
|
+
body: JSON.stringify(formData),
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<form onSubmit={handleSubmit}>
|
|
327
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
328
|
+
{/* form fields */}
|
|
329
|
+
</form>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Twig Forms
|
|
335
|
+
|
|
336
|
+
```twig
|
|
337
|
+
<form method="post" action="/submit">
|
|
338
|
+
<input type="hidden" name="_csrf_token" value="{{ csrf_token('form_name') }}">
|
|
339
|
+
<!-- form fields -->
|
|
340
|
+
</form>
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Client-Side CSRF (AJAX)
|
|
344
|
+
|
|
345
|
+
Protect AJAX requests by sending a server-provided CSRF token in a header. Avoid
|
|
346
|
+
using a plain cookie-to-header mirror as the only defense; the token must still
|
|
347
|
+
be signed and session-bound or backed by a server-side synchronizer token.
|
|
348
|
+
|
|
349
|
+
```javascript
|
|
350
|
+
// If the server intentionally exposes a signed CSRF cookie to JavaScript:
|
|
351
|
+
import axios from "axios";
|
|
352
|
+
|
|
353
|
+
axios.defaults.xsrfCookieName = "csrf_token";
|
|
354
|
+
axios.defaults.xsrfHeaderName = "X-CSRF-Token";
|
|
355
|
+
axios.defaults.withCredentials = true;
|
|
356
|
+
|
|
357
|
+
// Prefer server-rendered meta tags or a bootstrap response when using fetch.
|
|
358
|
+
async function secureFetch(url, options = {}) {
|
|
359
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
360
|
+
|
|
361
|
+
return fetch(url, {
|
|
362
|
+
...options,
|
|
363
|
+
credentials: "same-origin",
|
|
364
|
+
headers: {
|
|
365
|
+
...options.headers,
|
|
366
|
+
"X-CSRF-Token": csrfToken,
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Verification Checklist
|
|
373
|
+
|
|
374
|
+
- [ ] All state-changing endpoints require CSRF tokens
|
|
375
|
+
- [ ] Tokens are cryptographically random (≥128 bits)
|
|
376
|
+
- [ ] Tokens are tied to user session
|
|
377
|
+
- [ ] Tokens validated server-side before processing
|
|
378
|
+
- [ ] SameSite cookie attribute set appropriately
|
|
379
|
+
- [ ] Fetch Metadata headers validated for sensitive endpoints
|
|
380
|
+
- [ ] Origin or Referer validated for state-changing requests
|
|
381
|
+
- [ ] GET requests are idempotent (no state changes)
|
|
382
|
+
|
|
383
|
+
OWASP Reference: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# DOM Security Reference
|
|
2
|
+
|
|
3
|
+
## DOM-Based XSS Prevention
|
|
4
|
+
|
|
5
|
+
### Rule #1: HTML Subcontext
|
|
6
|
+
|
|
7
|
+
Untrusted data in HTML element content:
|
|
8
|
+
|
|
9
|
+
```javascript
|
|
10
|
+
// UNSAFE
|
|
11
|
+
element.innerHTML = "<div>" + userInput + "</div>";
|
|
12
|
+
|
|
13
|
+
// SAFE - use textContent
|
|
14
|
+
element.textContent = userInput;
|
|
15
|
+
|
|
16
|
+
// SAFE - create elements programmatically
|
|
17
|
+
const div = document.createElement("div");
|
|
18
|
+
div.textContent = userInput;
|
|
19
|
+
parent.appendChild(div);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Rule #2: JavaScript Context
|
|
23
|
+
|
|
24
|
+
Untrusted data in JavaScript:
|
|
25
|
+
|
|
26
|
+
```javascript
|
|
27
|
+
// UNSAFE - string concatenation in script
|
|
28
|
+
const script = 'var x = "' + userInput + '"';
|
|
29
|
+
|
|
30
|
+
// UNSAFE - dynamic property access
|
|
31
|
+
window[userInput]();
|
|
32
|
+
|
|
33
|
+
// SAFE - use data attributes
|
|
34
|
+
element.dataset.value = userInput;
|
|
35
|
+
const value = element.dataset.value;
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Rule #3: HTML Attribute Context
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
// UNSAFE - event handlers
|
|
42
|
+
element.setAttribute("onclick", userInput);
|
|
43
|
+
|
|
44
|
+
// UNSAFE - dangerous attributes
|
|
45
|
+
element.setAttribute("href", userInput); // javascript: URLs
|
|
46
|
+
element.setAttribute("src", userInput); // script injection
|
|
47
|
+
|
|
48
|
+
// SAFER - only allow attributes whose semantics are plain text in this element.
|
|
49
|
+
// Avoid user-controlled id/name because they can contribute to DOM clobbering.
|
|
50
|
+
const safeAttributes = ["title", "alt", "aria-label", "data-value"];
|
|
51
|
+
if (safeAttributes.includes(attributeName)) {
|
|
52
|
+
element.setAttribute(attributeName, userInput);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Rule #4: CSS Context
|
|
57
|
+
|
|
58
|
+
```javascript
|
|
59
|
+
// UNSAFE - expression injection
|
|
60
|
+
element.style.cssText = userInput;
|
|
61
|
+
element.setAttribute("style", userInput);
|
|
62
|
+
|
|
63
|
+
// SAFE - set specific properties
|
|
64
|
+
element.style.backgroundColor = sanitizeColor(userInput);
|
|
65
|
+
|
|
66
|
+
function sanitizeColor(input) {
|
|
67
|
+
// Only allow safe color values
|
|
68
|
+
const colorRegex = /^#[0-9A-Fa-f]{6}$|^#[0-9A-Fa-f]{3}$|^rgb\(\d{1,3},\s*\d{1,3},\s*\d{1,3}\)$/;
|
|
69
|
+
return colorRegex.test(input) ? input : "inherit";
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Rule #5: URL Context
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
// UNSAFE - unvalidated URLs
|
|
77
|
+
location.href = userInput;
|
|
78
|
+
window.open(userInput);
|
|
79
|
+
element.setAttribute("href", userInput);
|
|
80
|
+
|
|
81
|
+
// SAFER - parse, normalize, and enforce destination policy.
|
|
82
|
+
function normalizeAllowedUrl(input) {
|
|
83
|
+
try {
|
|
84
|
+
const url = new URL(input, window.location.origin);
|
|
85
|
+
if (!["https:", "mailto:"].includes(url.protocol)) return null;
|
|
86
|
+
if (
|
|
87
|
+
url.protocol === "https:" &&
|
|
88
|
+
!["https://example.com", "https://docs.example.com"].includes(url.origin)
|
|
89
|
+
) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return url.href;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const safeUrl = normalizeAllowedUrl(userInput);
|
|
99
|
+
if (safeUrl) {
|
|
100
|
+
location.assign(safeUrl);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// If opening a separate browsing context, also prevent tabnabbing.
|
|
104
|
+
if (safeUrl) {
|
|
105
|
+
window.open(safeUrl, "_blank", "noopener,noreferrer");
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## DOM Clobbering Prevention
|
|
110
|
+
|
|
111
|
+
### What is DOM Clobbering?
|
|
112
|
+
|
|
113
|
+
Named elements (id, name attributes) create global variables:
|
|
114
|
+
|
|
115
|
+
```html
|
|
116
|
+
<!-- This creates window.config -->
|
|
117
|
+
<img id="config" src="x" />
|
|
118
|
+
|
|
119
|
+
<!-- JavaScript that assumes config is an object will break -->
|
|
120
|
+
<script>
|
|
121
|
+
console.log(config.apiKey); // Error: HTMLImageElement has no apiKey
|
|
122
|
+
</script>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Prevention Strategies
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
// 1. Use Object.hasOwn() or hasOwnProperty()
|
|
129
|
+
if (
|
|
130
|
+
Object.hasOwn(window, "config") &&
|
|
131
|
+
typeof window.config === "object" &&
|
|
132
|
+
window.config !== null &&
|
|
133
|
+
!(window.config instanceof Element)
|
|
134
|
+
) {
|
|
135
|
+
// Safe to use window.config
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. Access through document methods
|
|
139
|
+
const element = document.getElementById("config");
|
|
140
|
+
|
|
141
|
+
// 3. Use Map instead of objects for user-controlled keys
|
|
142
|
+
const userConfig = new Map();
|
|
143
|
+
userConfig.set(userKey, userValue);
|
|
144
|
+
|
|
145
|
+
// 4. Freeze only owned objects after validating shape
|
|
146
|
+
if (
|
|
147
|
+
Object.hasOwn(window, "config") &&
|
|
148
|
+
typeof window.config === "object" &&
|
|
149
|
+
window.config !== null &&
|
|
150
|
+
!(window.config instanceof Element)
|
|
151
|
+
) {
|
|
152
|
+
Object.freeze(window.config);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 5. Read only from an owned, validated configuration object
|
|
156
|
+
const config =
|
|
157
|
+
Object.hasOwn(window, "config") &&
|
|
158
|
+
typeof window.config === "object" &&
|
|
159
|
+
window.config !== null &&
|
|
160
|
+
!(window.config instanceof Element)
|
|
161
|
+
? window.config
|
|
162
|
+
: null;
|
|
163
|
+
if (config && typeof config.apiKey === "string") {
|
|
164
|
+
// Safe to use
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### HTML Sanitization Against Clobbering
|
|
169
|
+
|
|
170
|
+
```javascript
|
|
171
|
+
// DOMPurify with clobbering protection
|
|
172
|
+
const clean = DOMPurify.sanitize(dirty, {
|
|
173
|
+
SANITIZE_DOM: true, // Remove clobbering vectors
|
|
174
|
+
SANITIZE_NAMED_PROPS: true,
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Secure DOM APIs
|
|
179
|
+
|
|
180
|
+
### Safe Methods
|
|
181
|
+
|
|
182
|
+
```javascript
|
|
183
|
+
// Text content (no HTML parsing)
|
|
184
|
+
element.textContent = userInput;
|
|
185
|
+
document.createTextNode(userInput);
|
|
186
|
+
|
|
187
|
+
// Attribute manipulation (for plain-text safe attributes)
|
|
188
|
+
element.setAttribute("data-value", userInput);
|
|
189
|
+
element.classList.add(sanitizedClass);
|
|
190
|
+
|
|
191
|
+
// Query selectors (read-only)
|
|
192
|
+
document.querySelector(selector);
|
|
193
|
+
document.querySelectorAll(selector);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Dangerous Methods (Require Sanitization)
|
|
197
|
+
|
|
198
|
+
```javascript
|
|
199
|
+
// HTML parsing methods
|
|
200
|
+
element.innerHTML = sanitized;
|
|
201
|
+
element.outerHTML = sanitized;
|
|
202
|
+
element.insertAdjacentHTML(position, sanitized);
|
|
203
|
+
document.write(sanitized); // Avoid entirely
|
|
204
|
+
|
|
205
|
+
// Script execution
|
|
206
|
+
eval(); // Never use with user input
|
|
207
|
+
new Function(); // Never use with user input
|
|
208
|
+
setTimeout(string); // Never pass strings
|
|
209
|
+
setInterval(string); // Never pass strings
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## postMessage Security
|
|
213
|
+
|
|
214
|
+
```javascript
|
|
215
|
+
// Sender - specify exact origin
|
|
216
|
+
targetWindow.postMessage(data, "https://trusted-domain.com");
|
|
217
|
+
|
|
218
|
+
const trustedWindow = targetWindow;
|
|
219
|
+
const messageValidators = {
|
|
220
|
+
resize: (data) => Number.isInteger(data.height) && data.height >= 0 && data.height <= 10_000,
|
|
221
|
+
navigate: (data) => typeof data.path === "string" && data.path.startsWith("/account/"),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Receiver - validate origin, sender, message type, and type-specific fields.
|
|
225
|
+
window.addEventListener("message", (event) => {
|
|
226
|
+
if (event.origin !== "https://trusted-domain.com" || event.source !== trustedWindow) return;
|
|
227
|
+
|
|
228
|
+
const data = event.data;
|
|
229
|
+
if (data === null || typeof data !== "object" || typeof data.type !== "string") return;
|
|
230
|
+
if (!Object.hasOwn(messageValidators, data.type)) return;
|
|
231
|
+
const validate = messageValidators[data.type];
|
|
232
|
+
if (!validate(data)) return;
|
|
233
|
+
|
|
234
|
+
handleMessage(data);
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Trusted Types (Modern Browsers)
|
|
239
|
+
|
|
240
|
+
```javascript
|
|
241
|
+
// Enable Trusted Types via CSP
|
|
242
|
+
// Content-Security-Policy: require-trusted-types-for 'script'
|
|
243
|
+
|
|
244
|
+
// Create a policy
|
|
245
|
+
const policy = trustedTypes.createPolicy("default", {
|
|
246
|
+
createHTML: (input) => DOMPurify.sanitize(input),
|
|
247
|
+
createScript: () => {
|
|
248
|
+
throw new Error("Scripts not allowed");
|
|
249
|
+
},
|
|
250
|
+
createScriptURL: (input) => {
|
|
251
|
+
const url = new URL(input, location.origin);
|
|
252
|
+
if (url.origin === location.origin && url.pathname.startsWith("/assets/")) {
|
|
253
|
+
return url.href;
|
|
254
|
+
}
|
|
255
|
+
throw new Error("Invalid script URL");
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Usage
|
|
260
|
+
element.innerHTML = policy.createHTML(userInput);
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
OWASP Reference: https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html
|