@schalkneethling/toolkit 0.2.0 → 0.4.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/README.md +29 -7
- package/commands/rpm-advance.md +13 -0
- package/commands/rpm-checkpoint.md +13 -0
- package/commands/rpm-feedback.md +15 -0
- package/commands/rpm-handoff.md +14 -0
- package/commands/rpm-review.md +13 -0
- package/commands/rpm-start.md +13 -0
- package/dist/index.mjs +90 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/skills/css-coder/SKILL.md +95 -0
- package/skills/css-coder/references/patterns.md +224 -0
- package/skills/css-tokens/README.md +152 -0
- package/skills/css-tokens/SKILL.md +125 -0
- package/skills/css-tokens/references/tokens.css +162 -0
- package/skills/frontend-security/SKILL.md +134 -0
- package/skills/frontend-security/references/csp-configuration.md +191 -0
- package/skills/frontend-security/references/csrf-protection.md +327 -0
- package/skills/frontend-security/references/dom-security.md +229 -0
- package/skills/frontend-security/references/file-upload-security.md +310 -0
- package/skills/frontend-security/references/framework-patterns.md +307 -0
- package/skills/frontend-security/references/input-validation.md +232 -0
- package/skills/frontend-security/references/jwt-security.md +300 -0
- package/skills/frontend-security/references/nodejs-npm-security.md +261 -0
- package/skills/frontend-security/references/xss-prevention.md +163 -0
- package/skills/frontend-testing/SKILL.md +357 -0
- package/skills/frontend-testing/references/accessibility-testing.md +368 -0
- package/skills/frontend-testing/references/aria-snapshots.md +517 -0
- package/skills/frontend-testing/references/locator-strategies.md +295 -0
- package/skills/frontend-testing/references/visual-regression.md +466 -0
- package/skills/refined-plan-mode/SKILL.md +84 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: frontend-security
|
|
3
|
+
description: Audit frontend codebases for security vulnerabilities and bad practices. Use when performing security reviews, auditing code for XSS/CSRF/DOM vulnerabilities, checking Content Security Policy configurations, validating input handling, reviewing file upload security, or examining Node.js/NPM dependencies. Target frameworks include web platform (vanilla HTML/CSS/JS), React, Astro, Twig templates, Node.js, and Bun. Based on OWASP security guidelines.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Frontend Security Audit Skill
|
|
7
|
+
|
|
8
|
+
Perform comprehensive security audits of frontend codebases to identify vulnerabilities, bad practices, and missing protections.
|
|
9
|
+
|
|
10
|
+
## Audit Process
|
|
11
|
+
|
|
12
|
+
1. **Scan for dangerous patterns** - Search codebase for known vulnerability indicators
|
|
13
|
+
2. **Review framework-specific risks** - Check for framework security bypass patterns
|
|
14
|
+
3. **Validate defensive measures** - Verify CSP, CSRF tokens, input validation
|
|
15
|
+
4. **Check dependencies** - Review npm/node dependencies for vulnerabilities
|
|
16
|
+
5. **Report findings** - Categorize by severity with remediation guidance
|
|
17
|
+
|
|
18
|
+
## Critical Vulnerability Patterns to Search
|
|
19
|
+
|
|
20
|
+
### XSS Indicators (Search Priority: HIGH)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# React dangerous patterns
|
|
24
|
+
grep -rn "dangerouslySetInnerHTML" --include="*.jsx" --include="*.tsx" --include="*.js"
|
|
25
|
+
|
|
26
|
+
# Direct DOM manipulation
|
|
27
|
+
grep -rn "\.innerHTML\s*=" --include="*.js" --include="*.ts" --include="*.jsx" --include="*.tsx"
|
|
28
|
+
grep -rn "\.outerHTML\s*=" --include="*.js" --include="*.ts"
|
|
29
|
+
grep -rn "document\.write" --include="*.js" --include="*.ts"
|
|
30
|
+
|
|
31
|
+
# URL-based injection
|
|
32
|
+
grep -rn "location\.href\s*=" --include="*.js" --include="*.ts"
|
|
33
|
+
grep -rn "location\.replace" --include="*.js" --include="*.ts"
|
|
34
|
+
grep -rn "window\.open" --include="*.js" --include="*.ts"
|
|
35
|
+
|
|
36
|
+
# Eval and code execution
|
|
37
|
+
grep -rn "eval\s*(" --include="*.js" --include="*.ts"
|
|
38
|
+
grep -rn "new Function\s*(" --include="*.js" --include="*.ts"
|
|
39
|
+
grep -rn "setTimeout\s*(\s*['\"]" --include="*.js" --include="*.ts"
|
|
40
|
+
grep -rn "setInterval\s*(\s*['\"]" --include="*.js" --include="*.ts"
|
|
41
|
+
|
|
42
|
+
# Twig unescaped output
|
|
43
|
+
grep -rn "|raw" --include="*.twig" --include="*.html.twig"
|
|
44
|
+
grep -rn "{% autoescape false %}" --include="*.twig"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### CSRF Indicators
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Forms without CSRF tokens
|
|
51
|
+
grep -rn "<form" --include="*.html" --include="*.jsx" --include="*.tsx" --include="*.twig"
|
|
52
|
+
|
|
53
|
+
# State-changing requests without protection
|
|
54
|
+
grep -rn "fetch\s*(" --include="*.js" --include="*.ts" | grep -E "(POST|PUT|DELETE|PATCH)"
|
|
55
|
+
grep -rn "axios\.(post|put|delete|patch)" --include="*.js" --include="*.ts"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Sensitive Data Exposure
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# localStorage/sessionStorage with sensitive data
|
|
62
|
+
grep -rn "localStorage\." --include="*.js" --include="*.ts"
|
|
63
|
+
grep -rn "sessionStorage\." --include="*.js" --include="*.ts"
|
|
64
|
+
|
|
65
|
+
# Hardcoded secrets
|
|
66
|
+
grep -rn "api[_-]?key\s*[:=]" --include="*.js" --include="*.ts" --include="*.env"
|
|
67
|
+
grep -rn "secret\s*[:=]" --include="*.js" --include="*.ts"
|
|
68
|
+
grep -rn "password\s*[:=]" --include="*.js" --include="*.ts"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Reference Documentation
|
|
72
|
+
|
|
73
|
+
Load these references based on findings:
|
|
74
|
+
|
|
75
|
+
- **XSS vulnerabilities found**: See `references/xss-prevention.md`
|
|
76
|
+
- **CSRF concerns**: See `references/csrf-protection.md`
|
|
77
|
+
- **DOM manipulation issues**: See `references/dom-security.md`
|
|
78
|
+
- **CSP review needed**: See `references/csp-configuration.md`
|
|
79
|
+
- **Input handling issues**: See `references/input-validation.md`
|
|
80
|
+
- **Node.js/NPM audit**: See `references/nodejs-npm-security.md`
|
|
81
|
+
- **Framework-specific patterns**: See `references/framework-patterns.md`
|
|
82
|
+
- **File upload handling**: See `references/file-upload-security.md`
|
|
83
|
+
- **JWT implementation**: See `references/jwt-security.md`
|
|
84
|
+
|
|
85
|
+
## Severity Classification
|
|
86
|
+
|
|
87
|
+
**CRITICAL** - Exploitable XSS, authentication bypass, secrets exposure
|
|
88
|
+
**HIGH** - Missing CSRF protection, unsafe DOM manipulation, SQL injection vectors
|
|
89
|
+
**MEDIUM** - Weak CSP, missing security headers, improper input validation
|
|
90
|
+
**LOW** - Informational disclosure, deprecated functions, suboptimal practices
|
|
91
|
+
|
|
92
|
+
## Report Format
|
|
93
|
+
|
|
94
|
+
```markdown
|
|
95
|
+
## Security Audit Report
|
|
96
|
+
|
|
97
|
+
### Summary
|
|
98
|
+
- Critical: X findings
|
|
99
|
+
- High: X findings
|
|
100
|
+
- Medium: X findings
|
|
101
|
+
- Low: X findings
|
|
102
|
+
|
|
103
|
+
### Critical Findings
|
|
104
|
+
|
|
105
|
+
#### [CRITICAL-001] Title
|
|
106
|
+
- **Location**: file:line
|
|
107
|
+
- **Pattern**: Code snippet
|
|
108
|
+
- **Risk**: Description of the vulnerability
|
|
109
|
+
- **Remediation**: How to fix
|
|
110
|
+
- **Reference**: OWASP link
|
|
111
|
+
|
|
112
|
+
### High Findings
|
|
113
|
+
[...]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## OWASP Reference Links
|
|
117
|
+
|
|
118
|
+
For comprehensive guidance, consult these OWASP cheatsheets directly:
|
|
119
|
+
|
|
120
|
+
- XSS Prevention: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
|
|
121
|
+
- DOM XSS Prevention: https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html
|
|
122
|
+
- CSRF Prevention: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
|
123
|
+
- CSP: https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html
|
|
124
|
+
- Input Validation: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
|
|
125
|
+
- HTML5 Security: https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html
|
|
126
|
+
- DOM Clobbering: https://cheatsheetseries.owasp.org/cheatsheets/DOM_Clobbering_Prevention_Cheat_Sheet.html
|
|
127
|
+
- Node.js Security: https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html
|
|
128
|
+
- NPM Security: https://cheatsheetseries.owasp.org/cheatsheets/NPM_Security_Cheat_Sheet.html
|
|
129
|
+
- AJAX Security: https://cheatsheetseries.owasp.org/cheatsheets/AJAX_Security_Cheat_Sheet.html
|
|
130
|
+
- File Upload: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
|
|
131
|
+
- Error Handling: https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html
|
|
132
|
+
- JWT Security: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
|
|
133
|
+
- User Privacy: https://cheatsheetseries.owasp.org/cheatsheets/User_Privacy_Protection_Cheat_Sheet.html
|
|
134
|
+
- gRPC Security: https://cheatsheetseries.owasp.org/cheatsheets/gRPC_Security_Cheat_Sheet.html
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Content Security Policy Reference
|
|
2
|
+
|
|
3
|
+
## Strict CSP Configuration
|
|
4
|
+
|
|
5
|
+
### Nonce-Based (Recommended)
|
|
6
|
+
|
|
7
|
+
```http
|
|
8
|
+
Content-Security-Policy:
|
|
9
|
+
script-src 'nonce-{random}' 'strict-dynamic';
|
|
10
|
+
object-src 'none';
|
|
11
|
+
base-uri 'none';
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Implementation:
|
|
15
|
+
|
|
16
|
+
```javascript
|
|
17
|
+
// Generate unique nonce per request
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const nonce = crypto.randomBytes(16).toString('base64');
|
|
20
|
+
|
|
21
|
+
// Set header
|
|
22
|
+
res.setHeader('Content-Security-Policy',
|
|
23
|
+
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Include nonce in script tags
|
|
27
|
+
res.send(`<script nonce="${nonce}">/* safe code */</script>`);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Hash-Based (For Static Scripts)
|
|
31
|
+
|
|
32
|
+
```http
|
|
33
|
+
Content-Security-Policy:
|
|
34
|
+
script-src 'sha256-{hash}' 'strict-dynamic';
|
|
35
|
+
object-src 'none';
|
|
36
|
+
base-uri 'none';
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Generate hash:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
echo -n 'console.log("hello");' | openssl sha256 -binary | openssl base64
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Essential Directives
|
|
46
|
+
|
|
47
|
+
| Directive | Purpose | Recommended Value |
|
|
48
|
+
|-----------|---------|-------------------|
|
|
49
|
+
| `default-src` | Fallback for other directives | `'self'` |
|
|
50
|
+
| `script-src` | JavaScript sources | `'nonce-{random}' 'strict-dynamic'` |
|
|
51
|
+
| `style-src` | CSS sources | `'self' 'unsafe-inline'` (or nonces) |
|
|
52
|
+
| `img-src` | Image sources | `'self' data: https:` |
|
|
53
|
+
| `font-src` | Font sources | `'self'` |
|
|
54
|
+
| `connect-src` | AJAX/WebSocket/Fetch | `'self' https://api.example.com` |
|
|
55
|
+
| `frame-src` | iframe sources | `'none'` or specific origins |
|
|
56
|
+
| `object-src` | Plugin content | `'none'` |
|
|
57
|
+
| `base-uri` | Base URL restrictions | `'none'` |
|
|
58
|
+
| `form-action` | Form submission targets | `'self'` |
|
|
59
|
+
| `frame-ancestors` | Who can embed page | `'self'` or `'none'` |
|
|
60
|
+
|
|
61
|
+
## Framework Integration
|
|
62
|
+
|
|
63
|
+
### Express.js
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
const helmet = require('helmet');
|
|
67
|
+
|
|
68
|
+
// Nonce middleware
|
|
69
|
+
app.use((req, res, next) => {
|
|
70
|
+
res.locals.nonce = crypto.randomBytes(16).toString('base64');
|
|
71
|
+
next();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
app.use(helmet.contentSecurityPolicy({
|
|
75
|
+
directives: {
|
|
76
|
+
defaultSrc: ["'self'"],
|
|
77
|
+
scriptSrc: [(req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'"],
|
|
78
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
79
|
+
imgSrc: ["'self'", "data:", "https:"],
|
|
80
|
+
objectSrc: ["'none'"],
|
|
81
|
+
baseUri: ["'none'"],
|
|
82
|
+
formAction: ["'self'"],
|
|
83
|
+
frameAncestors: ["'self'"]
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Astro
|
|
89
|
+
|
|
90
|
+
```javascript
|
|
91
|
+
// astro.config.mjs
|
|
92
|
+
export default defineConfig({
|
|
93
|
+
vite: {
|
|
94
|
+
plugins: [{
|
|
95
|
+
name: 'csp-plugin',
|
|
96
|
+
configureServer(server) {
|
|
97
|
+
server.middlewares.use((req, res, next) => {
|
|
98
|
+
res.setHeader('Content-Security-Policy',
|
|
99
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
|
|
100
|
+
);
|
|
101
|
+
next();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}]
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Meta Tag (Fallback)
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<meta http-equiv="Content-Security-Policy"
|
|
113
|
+
content="default-src 'self'; script-src 'self' 'nonce-abc123';">
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Note**: Meta tag CSP cannot set `frame-ancestors`, `report-uri`, or `sandbox`.
|
|
117
|
+
|
|
118
|
+
## Report-Only Mode
|
|
119
|
+
|
|
120
|
+
Test policies without enforcement:
|
|
121
|
+
|
|
122
|
+
```http
|
|
123
|
+
Content-Security-Policy-Report-Only:
|
|
124
|
+
default-src 'self';
|
|
125
|
+
script-src 'self';
|
|
126
|
+
report-uri /csp-report;
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Report endpoint:
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
|
|
133
|
+
console.log('CSP Violation:', req.body);
|
|
134
|
+
res.status(204).end();
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Common Violations and Fixes
|
|
139
|
+
|
|
140
|
+
### Inline Scripts
|
|
141
|
+
|
|
142
|
+
```html
|
|
143
|
+
<!-- Violation: inline script -->
|
|
144
|
+
<script>alert('hello');</script>
|
|
145
|
+
|
|
146
|
+
<!-- Fix: add nonce -->
|
|
147
|
+
<script nonce="abc123">alert('hello');</script>
|
|
148
|
+
|
|
149
|
+
<!-- Or: move to external file -->
|
|
150
|
+
<script src="/js/app.js"></script>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Inline Event Handlers
|
|
154
|
+
|
|
155
|
+
```html
|
|
156
|
+
<!-- Violation: inline event handler -->
|
|
157
|
+
<button onclick="handleClick()">Click</button>
|
|
158
|
+
|
|
159
|
+
<!-- Fix: use addEventListener -->
|
|
160
|
+
<button id="myBtn">Click</button>
|
|
161
|
+
<script nonce="abc123">
|
|
162
|
+
document.getElementById('myBtn').addEventListener('click', handleClick);
|
|
163
|
+
</script>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Inline Styles
|
|
167
|
+
|
|
168
|
+
```html
|
|
169
|
+
<!-- Violation: style attribute -->
|
|
170
|
+
<div style="color: red;">Text</div>
|
|
171
|
+
|
|
172
|
+
<!-- Fix: use classes -->
|
|
173
|
+
<div class="text-red">Text</div>
|
|
174
|
+
|
|
175
|
+
<!-- Or: add nonce to style tags -->
|
|
176
|
+
<style nonce="abc123">.text-red { color: red; }</style>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Security Headers Companion
|
|
180
|
+
|
|
181
|
+
Deploy CSP with these additional headers:
|
|
182
|
+
|
|
183
|
+
```http
|
|
184
|
+
X-Content-Type-Options: nosniff
|
|
185
|
+
X-Frame-Options: DENY
|
|
186
|
+
X-XSS-Protection: 0
|
|
187
|
+
Referrer-Policy: strict-origin-when-cross-origin
|
|
188
|
+
Permissions-Policy: geolocation=(), microphone=(), camera=()
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
OWASP Reference: https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html
|
|
@@ -0,0 +1,327 @@
|
|
|
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
|
+
### Double Submit Cookie Pattern
|
|
42
|
+
|
|
43
|
+
```javascript
|
|
44
|
+
// Set CSRF cookie
|
|
45
|
+
res.cookie('csrf_token', token, {
|
|
46
|
+
httpOnly: false, // Must be readable by JavaScript
|
|
47
|
+
secure: true,
|
|
48
|
+
sameSite: 'Strict'
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Client sends token in header
|
|
52
|
+
fetch('/api/action', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'X-CSRF-Token': getCookie('csrf_token')
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Server validates cookie matches header
|
|
60
|
+
function validateDoubleSubmit(req) {
|
|
61
|
+
const cookieToken = req.cookies.csrf_token;
|
|
62
|
+
const headerToken = req.headers['x-csrf-token'];
|
|
63
|
+
return cookieToken && cookieToken === headerToken;
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## SameSite Cookie Attribute
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
// Strict - never sent cross-site
|
|
71
|
+
res.cookie('session', value, { sameSite: 'Strict', secure: true, httpOnly: true });
|
|
72
|
+
|
|
73
|
+
// Lax - sent for top-level GET navigations (default in modern browsers)
|
|
74
|
+
res.cookie('session', value, { sameSite: 'Lax', secure: true, httpOnly: true });
|
|
75
|
+
|
|
76
|
+
// None - requires Secure flag, sent cross-site
|
|
77
|
+
res.cookie('session', value, { sameSite: 'None', secure: true, httpOnly: true });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Recommendation**: Use `SameSite=Strict` for session cookies when possible, `Lax` as minimum.
|
|
81
|
+
|
|
82
|
+
## Fetch Metadata Headers
|
|
83
|
+
|
|
84
|
+
Validate request origin using Sec-Fetch-* headers:
|
|
85
|
+
|
|
86
|
+
```javascript
|
|
87
|
+
function validateFetchMetadata(req, res, next) {
|
|
88
|
+
const site = req.headers['sec-fetch-site'];
|
|
89
|
+
const mode = req.headers['sec-fetch-mode'];
|
|
90
|
+
const dest = req.headers['sec-fetch-dest'];
|
|
91
|
+
const method = req.method;
|
|
92
|
+
|
|
93
|
+
// Allow same-origin requests
|
|
94
|
+
if (site === 'same-origin') return next();
|
|
95
|
+
|
|
96
|
+
// Allow user-initiated browser navigations
|
|
97
|
+
if (site === 'none') return next();
|
|
98
|
+
|
|
99
|
+
// Allow same-site top-level navigations and safe methods
|
|
100
|
+
if (
|
|
101
|
+
site === 'same-site' &&
|
|
102
|
+
(mode === 'navigate' || ['GET', 'HEAD', 'OPTIONS'].includes(method))
|
|
103
|
+
) {
|
|
104
|
+
return next();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return res.status(403).json({ error: 'Fetch metadata validation failed' });
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Framework Integration
|
|
112
|
+
|
|
113
|
+
### Express.js with Signed Double-Submit Tokens
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
const crypto = require('crypto');
|
|
117
|
+
|
|
118
|
+
const CSRF_COOKIE_NAME = 'csrf_token';
|
|
119
|
+
const CSRF_SECRET = Buffer.from(process.env.CSRF_SECRET, 'hex');
|
|
120
|
+
|
|
121
|
+
function signToken(sessionId, nonce) {
|
|
122
|
+
return crypto
|
|
123
|
+
.createHmac('sha256', CSRF_SECRET)
|
|
124
|
+
.update(`${sessionId}:${nonce}`)
|
|
125
|
+
.digest('base64url');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function constantTimeEqual(value, expected) {
|
|
129
|
+
if (typeof value !== 'string' || typeof expected !== 'string') return false;
|
|
130
|
+
|
|
131
|
+
const valueBuffer = Buffer.from(value);
|
|
132
|
+
const expectedBuffer = Buffer.from(expected);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
valueBuffer.length === expectedBuffer.length &&
|
|
136
|
+
crypto.timingSafeEqual(valueBuffer, expectedBuffer)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function generateToken(req) {
|
|
141
|
+
const nonce = crypto.randomBytes(32).toString('base64url');
|
|
142
|
+
const signature = signToken(req.session.id, nonce);
|
|
143
|
+
return `${nonce}.${signature}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sendToken(req, res, next) {
|
|
147
|
+
const token = generateToken(req);
|
|
148
|
+
|
|
149
|
+
res.cookie(CSRF_COOKIE_NAME, token, {
|
|
150
|
+
httpOnly: true,
|
|
151
|
+
secure: true,
|
|
152
|
+
sameSite: 'Strict'
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
res.locals.csrfToken = token;
|
|
156
|
+
next();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function verifyToken(req, res, next) {
|
|
160
|
+
const token = req.headers['x-csrf-token'] || req.body._csrf;
|
|
161
|
+
const cookieToken = req.cookies[CSRF_COOKIE_NAME];
|
|
162
|
+
|
|
163
|
+
if (!constantTimeEqual(token, cookieToken)) {
|
|
164
|
+
return res.status(403).json({ error: 'Invalid CSRF token' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const [nonce, signature, extra] = cookieToken.split('.');
|
|
168
|
+
if (extra || !nonce || !signature) {
|
|
169
|
+
return res.status(403).json({ error: 'Invalid CSRF token' });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!constantTimeEqual(signature, signToken(req.session.id, nonce))) {
|
|
173
|
+
return res.status(403).json({ error: 'Invalid CSRF token' });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
next();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
app.get('/form', sendToken, (req, res) => {
|
|
180
|
+
res.render('form', { csrfToken: res.locals.csrfToken });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
app.post('/submit', verifyToken, (req, res) => {
|
|
184
|
+
res.json({ ok: true });
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Example tests for token issuance and verification:
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
const assert = require('node:assert/strict');
|
|
192
|
+
const test = require('node:test');
|
|
193
|
+
|
|
194
|
+
test('sendToken issues a cookie and exposes a form token', () => {
|
|
195
|
+
const req = { session: { id: 'session-123' } };
|
|
196
|
+
const res = {
|
|
197
|
+
locals: {},
|
|
198
|
+
cookie(name, value, options) {
|
|
199
|
+
this.cookieArgs = { name, value, options };
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
sendToken(req, res, () => {});
|
|
204
|
+
|
|
205
|
+
assert.equal(res.cookieArgs.name, CSRF_COOKIE_NAME);
|
|
206
|
+
assert.equal(res.locals.csrfToken, res.cookieArgs.value);
|
|
207
|
+
assert.equal(res.cookieArgs.options.httpOnly, true);
|
|
208
|
+
assert.equal(res.cookieArgs.options.sameSite, 'Strict');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('verifyToken accepts a matching signed token', () => {
|
|
212
|
+
const req = { session: { id: 'session-123' } };
|
|
213
|
+
const token = generateToken(req);
|
|
214
|
+
let called = false;
|
|
215
|
+
|
|
216
|
+
verifyToken(
|
|
217
|
+
{
|
|
218
|
+
...req,
|
|
219
|
+
headers: { 'x-csrf-token': token },
|
|
220
|
+
body: {},
|
|
221
|
+
cookies: { [CSRF_COOKIE_NAME]: token }
|
|
222
|
+
},
|
|
223
|
+
{},
|
|
224
|
+
() => {
|
|
225
|
+
called = true;
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
assert.equal(called, true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('verifyToken rejects mismatched or tampered tokens', () => {
|
|
233
|
+
const req = {
|
|
234
|
+
session: { id: 'session-123' },
|
|
235
|
+
headers: { 'x-csrf-token': 'tampered.token' },
|
|
236
|
+
body: {},
|
|
237
|
+
cookies: { [CSRF_COOKIE_NAME]: generateToken({ session: { id: 'session-123' } }) }
|
|
238
|
+
};
|
|
239
|
+
const res = {
|
|
240
|
+
status(code) {
|
|
241
|
+
this.statusCode = code;
|
|
242
|
+
return this;
|
|
243
|
+
},
|
|
244
|
+
json(body) {
|
|
245
|
+
this.body = body;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
verifyToken(req, res, () => {});
|
|
250
|
+
|
|
251
|
+
assert.equal(res.statusCode, 403);
|
|
252
|
+
assert.deepEqual(res.body, { error: 'Invalid CSRF token' });
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### React Forms
|
|
257
|
+
|
|
258
|
+
```jsx
|
|
259
|
+
function Form({ csrfToken }) {
|
|
260
|
+
const handleSubmit = async (e) => {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
await fetch('/api/submit', {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers: {
|
|
265
|
+
'Content-Type': 'application/json',
|
|
266
|
+
'X-CSRF-Token': csrfToken
|
|
267
|
+
},
|
|
268
|
+
body: JSON.stringify(formData)
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<form onSubmit={handleSubmit}>
|
|
274
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
275
|
+
{/* form fields */}
|
|
276
|
+
</form>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Twig Forms
|
|
282
|
+
|
|
283
|
+
```twig
|
|
284
|
+
<form method="post" action="/submit">
|
|
285
|
+
<input type="hidden" name="_csrf_token" value="{{ csrf_token('form_name') }}">
|
|
286
|
+
<!-- form fields -->
|
|
287
|
+
</form>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Client-Side CSRF (AJAX)
|
|
291
|
+
|
|
292
|
+
Protect against CSRF in single-page applications:
|
|
293
|
+
|
|
294
|
+
```javascript
|
|
295
|
+
// Set up axios defaults
|
|
296
|
+
import axios from 'axios';
|
|
297
|
+
|
|
298
|
+
axios.defaults.xsrfCookieName = 'csrf_token';
|
|
299
|
+
axios.defaults.xsrfHeaderName = 'X-CSRF-Token';
|
|
300
|
+
axios.defaults.withCredentials = true;
|
|
301
|
+
|
|
302
|
+
// Or with fetch
|
|
303
|
+
async function secureFetch(url, options = {}) {
|
|
304
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
305
|
+
|
|
306
|
+
return fetch(url, {
|
|
307
|
+
...options,
|
|
308
|
+
credentials: 'same-origin',
|
|
309
|
+
headers: {
|
|
310
|
+
...options.headers,
|
|
311
|
+
'X-CSRF-Token': csrfToken
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Verification Checklist
|
|
318
|
+
|
|
319
|
+
- [ ] All state-changing endpoints require CSRF tokens
|
|
320
|
+
- [ ] Tokens are cryptographically random (≥128 bits)
|
|
321
|
+
- [ ] Tokens are tied to user session
|
|
322
|
+
- [ ] Tokens validated server-side before processing
|
|
323
|
+
- [ ] SameSite cookie attribute set appropriately
|
|
324
|
+
- [ ] Fetch Metadata headers validated for sensitive endpoints
|
|
325
|
+
- [ ] GET requests are idempotent (no state changes)
|
|
326
|
+
|
|
327
|
+
OWASP Reference: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|