@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.
@@ -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