@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,328 @@
|
|
|
1
|
+
# Framework-Specific Security Patterns
|
|
2
|
+
|
|
3
|
+
## React Security
|
|
4
|
+
|
|
5
|
+
### XSS Prevention
|
|
6
|
+
|
|
7
|
+
```jsx
|
|
8
|
+
// DEFAULT SAFE - React escapes by default
|
|
9
|
+
<div>{userInput}</div>
|
|
10
|
+
|
|
11
|
+
// DANGEROUS - bypasses escaping
|
|
12
|
+
<div dangerouslySetInnerHTML={{ __html: userInput }} />
|
|
13
|
+
|
|
14
|
+
// If HTML is required, sanitize first
|
|
15
|
+
import DOMPurify from 'dompurify';
|
|
16
|
+
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### URL Handling
|
|
20
|
+
|
|
21
|
+
```jsx
|
|
22
|
+
// DANGEROUS - javascript: URLs in href
|
|
23
|
+
<a href={userInput}>Link</a>;
|
|
24
|
+
|
|
25
|
+
// SAFER - parse and enforce allowed protocols and destinations
|
|
26
|
+
function SafeLink({ href, children }) {
|
|
27
|
+
const safeHref = useMemo(() => {
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(href, window.location.origin);
|
|
30
|
+
if (!["https:", "mailto:"].includes(url.protocol)) return "#";
|
|
31
|
+
if (
|
|
32
|
+
url.protocol === "https:" &&
|
|
33
|
+
!["https://example.com", "https://docs.example.com"].includes(url.origin)
|
|
34
|
+
) {
|
|
35
|
+
return "#";
|
|
36
|
+
}
|
|
37
|
+
return url.href;
|
|
38
|
+
} catch {}
|
|
39
|
+
return "#";
|
|
40
|
+
}, [href]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<a href={safeHref} target="_blank" rel="noopener noreferrer">
|
|
44
|
+
{children}
|
|
45
|
+
</a>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### State and Props
|
|
51
|
+
|
|
52
|
+
```jsx
|
|
53
|
+
// DANGEROUS - spreading user-controlled props
|
|
54
|
+
<Component {...userControlledObject} />
|
|
55
|
+
|
|
56
|
+
// SAFE - explicitly pass allowed props
|
|
57
|
+
<Component
|
|
58
|
+
title={userControlledObject.title}
|
|
59
|
+
description={userControlledObject.description}
|
|
60
|
+
/>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Server-Side Rendering (SSR)
|
|
64
|
+
|
|
65
|
+
```jsx
|
|
66
|
+
// DANGEROUS - injecting user data into SSR without escaping
|
|
67
|
+
<script>window.__INITIAL_STATE__ = {JSON.stringify(userControlledData)}</script>;
|
|
68
|
+
|
|
69
|
+
// SAFE - serialize with escaping
|
|
70
|
+
import serialize from "serialize-javascript";
|
|
71
|
+
<script
|
|
72
|
+
dangerouslySetInnerHTML={{
|
|
73
|
+
__html: `window.__INITIAL_STATE__ = ${serialize(data, { isJSON: true })}`,
|
|
74
|
+
}}
|
|
75
|
+
/>;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Astro Security
|
|
79
|
+
|
|
80
|
+
### Content Escaping
|
|
81
|
+
|
|
82
|
+
```astro
|
|
83
|
+
---
|
|
84
|
+
const userInput = Astro.props.userInput;
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
<!-- SAFE - auto-escaped -->
|
|
88
|
+
<div>{userInput}</div>
|
|
89
|
+
|
|
90
|
+
<!-- DANGEROUS - bypasses escaping -->
|
|
91
|
+
<div set:html={userInput} />
|
|
92
|
+
|
|
93
|
+
<!-- If HTML required, sanitize -->
|
|
94
|
+
---
|
|
95
|
+
import DOMPurify from 'dompurify';
|
|
96
|
+
const sanitized = DOMPurify.sanitize(userInput);
|
|
97
|
+
---
|
|
98
|
+
<div set:html={sanitized} />
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Dynamic Imports
|
|
102
|
+
|
|
103
|
+
```astro
|
|
104
|
+
---
|
|
105
|
+
// DANGEROUS - user-controlled import path
|
|
106
|
+
const component = await import(userInput);
|
|
107
|
+
|
|
108
|
+
// SAFE - allowlist approach
|
|
109
|
+
const allowedComponents = {
|
|
110
|
+
'card': () => import('./Card.astro'),
|
|
111
|
+
'button': () => import('./Button.astro')
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (!Object.hasOwn(allowedComponents, userInput)) {
|
|
115
|
+
throw new Error('Invalid component');
|
|
116
|
+
}
|
|
117
|
+
const loadComponent = allowedComponents[userInput];
|
|
118
|
+
const Component = await loadComponent();
|
|
119
|
+
---
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### API Endpoints
|
|
123
|
+
|
|
124
|
+
```javascript
|
|
125
|
+
// src/pages/api/data.js
|
|
126
|
+
export async function POST({ request }) {
|
|
127
|
+
// Validate Content-Type
|
|
128
|
+
const contentType = request.headers.get("content-type");
|
|
129
|
+
if (!contentType?.includes("application/json")) {
|
|
130
|
+
return new Response("Invalid content type", { status: 415 });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Validate and sanitize input
|
|
134
|
+
const body = await request.json();
|
|
135
|
+
if (!validateInput(body)) {
|
|
136
|
+
return new Response("Invalid input", { status: 400 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Process request
|
|
140
|
+
const responsePayload = { ok: true };
|
|
141
|
+
|
|
142
|
+
return new Response(JSON.stringify(responsePayload), {
|
|
143
|
+
headers: { "Content-Type": "application/json" },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Twig Security
|
|
149
|
+
|
|
150
|
+
### Output Escaping
|
|
151
|
+
|
|
152
|
+
```twig
|
|
153
|
+
{# SAFE - auto-escaped for HTML context #}
|
|
154
|
+
{{ userInput }}
|
|
155
|
+
|
|
156
|
+
{# DANGEROUS - raw bypasses escaping #}
|
|
157
|
+
{{ userInput|raw }}
|
|
158
|
+
|
|
159
|
+
{# DANGEROUS - autoescape disabled #}
|
|
160
|
+
{% autoescape false %}
|
|
161
|
+
{{ userInput }}
|
|
162
|
+
{% endautoescape %}
|
|
163
|
+
|
|
164
|
+
{# Context-specific escaping #}
|
|
165
|
+
{{ userInput|e('html') }}
|
|
166
|
+
{{ userInput|e('js') }}
|
|
167
|
+
{{ userInput|e('css') }}
|
|
168
|
+
{{ userInput|e('url') }}
|
|
169
|
+
{{ userInput|e('html_attr') }}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Template Inclusion
|
|
173
|
+
|
|
174
|
+
```twig
|
|
175
|
+
{# DANGEROUS - user-controlled template path #}
|
|
176
|
+
{% include userInput %}
|
|
177
|
+
|
|
178
|
+
{# SAFE - use allowlist #}
|
|
179
|
+
{% if templateName in ['header', 'footer', 'sidebar'] %}
|
|
180
|
+
{% include templateName ~ '.html.twig' %}
|
|
181
|
+
{% endif %}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Sandbox Mode (Symfony)
|
|
185
|
+
|
|
186
|
+
```yaml
|
|
187
|
+
# config/packages/twig.yaml
|
|
188
|
+
twig:
|
|
189
|
+
sandbox:
|
|
190
|
+
policy:
|
|
191
|
+
tags: ["if", "for", "set"]
|
|
192
|
+
filters: ["escape", "upper", "lower"]
|
|
193
|
+
methods:
|
|
194
|
+
Symfony\Component\Routing\Generator\UrlGeneratorInterface: ["generate"]
|
|
195
|
+
properties: []
|
|
196
|
+
functions: ["path", "url"]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### CSRF in Forms
|
|
200
|
+
|
|
201
|
+
```twig
|
|
202
|
+
{# Symfony CSRF protection #}
|
|
203
|
+
<form method="post">
|
|
204
|
+
<input type="hidden" name="_csrf_token" value="{{ csrf_token('form_name') }}">
|
|
205
|
+
{# form fields #}
|
|
206
|
+
</form>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Bun Security
|
|
210
|
+
|
|
211
|
+
### Request Handling
|
|
212
|
+
|
|
213
|
+
```javascript
|
|
214
|
+
// Bun HTTP server
|
|
215
|
+
Bun.serve({
|
|
216
|
+
port: 3000,
|
|
217
|
+
fetch(req) {
|
|
218
|
+
const url = new URL(req.url);
|
|
219
|
+
|
|
220
|
+
// Validate origin for CORS
|
|
221
|
+
const origin = req.headers.get("origin");
|
|
222
|
+
if (origin && !isAllowedOrigin(origin)) {
|
|
223
|
+
return new Response("Forbidden", { status: 403 });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Rate limiting
|
|
227
|
+
if (isRateLimited(req)) {
|
|
228
|
+
return new Response("Too Many Requests", { status: 429 });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return handleRequest(req);
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### File Handling
|
|
237
|
+
|
|
238
|
+
```javascript
|
|
239
|
+
const { constants } = require("node:fs");
|
|
240
|
+
const fs = require("node:fs/promises");
|
|
241
|
+
const path = require("node:path");
|
|
242
|
+
|
|
243
|
+
// Keep baseDir trusted and non-writable by the request user.
|
|
244
|
+
async function safeReadFile(userPath) {
|
|
245
|
+
const baseCanonical = await fs.realpath("/app/public");
|
|
246
|
+
const targetCanonical = await fs.realpath(path.resolve(baseCanonical, userPath));
|
|
247
|
+
const relativePath = path.relative(baseCanonical, targetCanonical);
|
|
248
|
+
|
|
249
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
250
|
+
throw new Error("Path traversal detected");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// O_NOFOLLOW rejects a final-component symlink swap. Trusted, non-writable
|
|
254
|
+
// parent directories prevent intermediate-component replacement.
|
|
255
|
+
const file = await fs.open(targetCanonical, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
256
|
+
try {
|
|
257
|
+
return await file.readFile("utf8");
|
|
258
|
+
} finally {
|
|
259
|
+
await file.close();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## HTML5 APIs Security
|
|
265
|
+
|
|
266
|
+
### Web Storage
|
|
267
|
+
|
|
268
|
+
```javascript
|
|
269
|
+
// NEVER store sensitive data in localStorage
|
|
270
|
+
localStorage.setItem("token", jwt); // DANGEROUS
|
|
271
|
+
|
|
272
|
+
// Prefer server-side sessions, httpOnly cookies, or in-memory short-lived tokens.
|
|
273
|
+
// Client-side encryption does not protect sensitive data from XSS when the key
|
|
274
|
+
// is also available to frontend JavaScript.
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### postMessage
|
|
278
|
+
|
|
279
|
+
```javascript
|
|
280
|
+
// Always validate origin and data
|
|
281
|
+
window.addEventListener("message", (event) => {
|
|
282
|
+
// Validate origin
|
|
283
|
+
const allowedOrigins = ["https://trusted.com"];
|
|
284
|
+
if (!allowedOrigins.includes(event.origin)) return;
|
|
285
|
+
|
|
286
|
+
// Validate data structure
|
|
287
|
+
const data = event.data;
|
|
288
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return;
|
|
289
|
+
if (!["action1", "action2"].includes(data.type)) return;
|
|
290
|
+
|
|
291
|
+
handleMessage(data);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Always specify target origin when sending
|
|
295
|
+
iframe.contentWindow.postMessage(data, "https://specific-origin.com");
|
|
296
|
+
// NEVER use '*' for sensitive data
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### WebSockets
|
|
300
|
+
|
|
301
|
+
```javascript
|
|
302
|
+
// Validate WebSocket origin
|
|
303
|
+
const wss = new WebSocket.Server({
|
|
304
|
+
server,
|
|
305
|
+
verifyClient: ({ origin, req }, callback) => {
|
|
306
|
+
const allowed = ["https://myapp.com"];
|
|
307
|
+
callback(allowed.includes(origin));
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Validate messages
|
|
312
|
+
wss.on("connection", (ws) => {
|
|
313
|
+
ws.on("message", (data) => {
|
|
314
|
+
try {
|
|
315
|
+
const msg = JSON.parse(data);
|
|
316
|
+
if (!isValidMessage(msg)) {
|
|
317
|
+
ws.close(1008, "Invalid message");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
handleMessage(msg);
|
|
321
|
+
} catch {
|
|
322
|
+
ws.close(1008, "Invalid JSON");
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
OWASP Reference: https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# Input Validation Reference
|
|
2
|
+
|
|
3
|
+
## Validation Strategy
|
|
4
|
+
|
|
5
|
+
**Always validate on the server.** Client-side validation improves UX but provides no security.
|
|
6
|
+
|
|
7
|
+
Validate input against the exact type, format, and business rule the application
|
|
8
|
+
will use. When parsing is required, validate and then consume the parsed or
|
|
9
|
+
normalized value instead of reusing the original string. Parser confusion is a
|
|
10
|
+
common source of validation bypasses.
|
|
11
|
+
|
|
12
|
+
### Allowlist vs Denylist
|
|
13
|
+
|
|
14
|
+
```javascript
|
|
15
|
+
// PREFERRED: Allowlist (accept known good)
|
|
16
|
+
function validateUsername(input) {
|
|
17
|
+
const allowedPattern = /^[a-zA-Z0-9_]{3,20}$/;
|
|
18
|
+
return typeof input === "string" && allowedPattern.test(input);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// AVOID: Denylist (block known bad)
|
|
22
|
+
function validateInput(input) {
|
|
23
|
+
const blocked = ["<script>", "javascript:", "onerror"];
|
|
24
|
+
return !blocked.some((bad) => input.includes(bad)); // Easily bypassed
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Common Validation Patterns
|
|
29
|
+
|
|
30
|
+
### Email
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
// Basic validation (server should still verify)
|
|
34
|
+
function validateEmail(email) {
|
|
35
|
+
if (typeof email !== "string") return false;
|
|
36
|
+
// Simple pattern - not comprehensive but catches most issues
|
|
37
|
+
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
38
|
+
return pattern.test(email) && email.length <= 254;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Use built-in browser validation
|
|
42
|
+
const input = document.createElement("input");
|
|
43
|
+
input.type = "email";
|
|
44
|
+
input.value = email;
|
|
45
|
+
return input.checkValidity();
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### URL
|
|
49
|
+
|
|
50
|
+
```javascript
|
|
51
|
+
function matchesPathPrefix(pathname, allowedPrefix) {
|
|
52
|
+
return (
|
|
53
|
+
allowedPrefix === "/" || pathname === allowedPrefix || pathname.startsWith(`${allowedPrefix}/`)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseAllowedHttpUrl(input, { baseUrl, allowedOrigins, allowedPathPrefixes = ["/"] }) {
|
|
58
|
+
try {
|
|
59
|
+
const allowedOriginSet = new Set(allowedOrigins.map((origin) => new URL(origin).origin));
|
|
60
|
+
const url = new URL(input, baseUrl);
|
|
61
|
+
|
|
62
|
+
if (!["http:", "https:"].includes(url.protocol)) return null;
|
|
63
|
+
if (url.username || url.password) return null;
|
|
64
|
+
if (!allowedOriginSet.has(url.origin)) return null;
|
|
65
|
+
if (!allowedPathPrefixes.some((prefix) => matchesPathPrefix(url.pathname, prefix))) return null;
|
|
66
|
+
|
|
67
|
+
return url;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const url = parseAllowedHttpUrl(input, {
|
|
74
|
+
baseUrl: "https://example.com",
|
|
75
|
+
allowedOrigins: ["https://example.com", "https://docs.example.com"],
|
|
76
|
+
allowedPathPrefixes: ["/account", "/docs"],
|
|
77
|
+
});
|
|
78
|
+
if (!url) throw new Error("Invalid URL");
|
|
79
|
+
|
|
80
|
+
redirectTo(url.href); // Use the normalized URL returned by the parser.
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Do not validate only the scheme and then use the original string for redirects,
|
|
84
|
+
links, fetches, or storage. Validate the full use case: protocol, origin, path,
|
|
85
|
+
credential-bearing URLs, and whether relative URLs are allowed.
|
|
86
|
+
|
|
87
|
+
### Numbers
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
function parseInteger(input, min, max) {
|
|
91
|
+
if (typeof input !== "string" || !/^-?(0|[1-9]\d*)$/.test(input)) return null;
|
|
92
|
+
const num = Number(input);
|
|
93
|
+
if (!Number.isSafeInteger(num)) return null;
|
|
94
|
+
if (num < min || num > max) return null;
|
|
95
|
+
return num;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseDecimal(input, min, max, decimals) {
|
|
99
|
+
if (typeof input !== "string") return null;
|
|
100
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 20) return null;
|
|
101
|
+
|
|
102
|
+
const decimalPart = decimals === 0 ? "" : `(?:\\.\\d{1,${decimals}})?`;
|
|
103
|
+
const pattern = new RegExp(`^-?(?:0|[1-9]\\d*)${decimalPart}$`);
|
|
104
|
+
if (!pattern.test(input)) return null;
|
|
105
|
+
|
|
106
|
+
const num = Number(input);
|
|
107
|
+
if (!Number.isFinite(num)) return null;
|
|
108
|
+
if (num < min || num > max) return null;
|
|
109
|
+
|
|
110
|
+
return num;
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use full-string format checks before numeric conversion. `parseInt` and
|
|
115
|
+
`parseFloat` are not validators because they can accept partial strings such as
|
|
116
|
+
`"1abc"`. For money, measurements, or other precision-sensitive values, prefer
|
|
117
|
+
minor units or a decimal library instead of JavaScript floating-point numbers.
|
|
118
|
+
|
|
119
|
+
### Date
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
function parseIsoDateOnly(input) {
|
|
123
|
+
if (typeof input !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(input)) return null;
|
|
124
|
+
|
|
125
|
+
const [year, month, day] = input.split("-").map(Number);
|
|
126
|
+
const date = new Date(0);
|
|
127
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
128
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
date.getUTCFullYear() !== year ||
|
|
132
|
+
date.getUTCMonth() !== month - 1 ||
|
|
133
|
+
date.getUTCDate() !== day
|
|
134
|
+
) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return input;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateDateRange(input, minDate, maxDate) {
|
|
142
|
+
const date = parseIsoDateOnly(input);
|
|
143
|
+
const min = parseIsoDateOnly(minDate);
|
|
144
|
+
const max = parseIsoDateOnly(maxDate);
|
|
145
|
+
|
|
146
|
+
if (!date || !min || !max) return false;
|
|
147
|
+
return date >= min && date <= max;
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Avoid free-form `Date.parse()` for validation. JavaScript date parsing accepts
|
|
152
|
+
multiple formats, can normalize invalid calendar dates, and may behave
|
|
153
|
+
differently for ambiguous inputs. Prefer explicit formats and round-trip checks.
|
|
154
|
+
|
|
155
|
+
### Phone Numbers
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
// International format
|
|
159
|
+
function validatePhone(input) {
|
|
160
|
+
if (typeof input !== "string") return false;
|
|
161
|
+
// E.164 format: +[country][number], max 15 digits
|
|
162
|
+
const pattern = /^\+[1-9]\d{1,14}$/;
|
|
163
|
+
return pattern.test(input.replace(/[\s\-()]/g, ""));
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Output Encoding and Sanitization
|
|
168
|
+
|
|
169
|
+
Validation decides whether data is acceptable. Output encoding makes accepted
|
|
170
|
+
data safe for a specific rendering context. HTML sanitization removes unsafe
|
|
171
|
+
markup when the product intentionally accepts rich HTML.
|
|
172
|
+
|
|
173
|
+
| Context | Safer pattern |
|
|
174
|
+
| ------------------- | ------------------------------------------------------------------------ |
|
|
175
|
+
| HTML text | Framework escaping, `textContent`, or an HTML text encoder |
|
|
176
|
+
| HTML attribute | Framework attribute binding or a context-aware encoder |
|
|
177
|
+
| URL attribute | Parse and allowlist URL, then assign through safe DOM/framework APIs |
|
|
178
|
+
| JavaScript string | Avoid inline script data; use JSON script data or framework data binding |
|
|
179
|
+
| CSS value | Avoid dynamic CSS or validate against a strict allowlist |
|
|
180
|
+
| Rich HTML fragments | Sanitize with a maintained HTML sanitizer such as DOMPurify |
|
|
181
|
+
|
|
182
|
+
### HTML Text Encoding
|
|
183
|
+
|
|
184
|
+
```javascript
|
|
185
|
+
// Only use this for HTML text-node content. Attribute, JavaScript, CSS, and URL
|
|
186
|
+
// contexts need their own encoders or framework-supported safe APIs.
|
|
187
|
+
function escapeHtml(input) {
|
|
188
|
+
const map = {
|
|
189
|
+
"&": "&",
|
|
190
|
+
"<": "<",
|
|
191
|
+
">": ">",
|
|
192
|
+
'"': """,
|
|
193
|
+
"'": "'",
|
|
194
|
+
"/": "/",
|
|
195
|
+
};
|
|
196
|
+
return String(input).replace(/[&<>"'/]/g, (char) => map[char]);
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Prefer DOM or framework APIs that encode for you:
|
|
201
|
+
|
|
202
|
+
```javascript
|
|
203
|
+
element.textContent = userInput;
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Manual escaping is easy to misuse outside its intended context. Do not treat
|
|
207
|
+
HTML text encoding as URL validation, JavaScript escaping, CSS escaping, SQL
|
|
208
|
+
escaping, or HTML sanitization.
|
|
209
|
+
|
|
210
|
+
### HTML Sanitization
|
|
211
|
+
|
|
212
|
+
```javascript
|
|
213
|
+
import DOMPurify from "dompurify";
|
|
214
|
+
|
|
215
|
+
const clean = DOMPurify.sanitize(userSuppliedHtml, {
|
|
216
|
+
ALLOWED_TAGS: ["a", "p", "strong", "em", "ul", "ol", "li"],
|
|
217
|
+
ALLOWED_ATTR: ["href"],
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Use sanitization only when users are allowed to provide markup. Plain text should
|
|
222
|
+
be rendered as text, not sanitized and inserted as HTML.
|
|
223
|
+
|
|
224
|
+
### SQL (Use Parameterized Queries Instead)
|
|
225
|
+
|
|
226
|
+
```javascript
|
|
227
|
+
// WRONG - never build SQL strings
|
|
228
|
+
const query = `SELECT * FROM users WHERE name = '${userInput}'`;
|
|
229
|
+
|
|
230
|
+
// RIGHT - use parameterized queries
|
|
231
|
+
const query = "SELECT * FROM users WHERE name = ?";
|
|
232
|
+
db.query(query, [userInput]);
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Path Traversal Prevention
|
|
236
|
+
|
|
237
|
+
```javascript
|
|
238
|
+
const { constants } = require("node:fs");
|
|
239
|
+
const fs = require("node:fs/promises");
|
|
240
|
+
const path = require("node:path");
|
|
241
|
+
|
|
242
|
+
// Keep baseDir trusted and non-writable by the request user.
|
|
243
|
+
async function openValidatedFile(userPath, baseDir) {
|
|
244
|
+
const baseCanonical = await fs.realpath(baseDir);
|
|
245
|
+
const targetCanonical = await fs.realpath(path.resolve(baseCanonical, userPath));
|
|
246
|
+
const relativePath = path.relative(baseCanonical, targetCanonical);
|
|
247
|
+
|
|
248
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
249
|
+
throw new Error("Path traversal detected");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// O_NOFOLLOW rejects a final-component symlink swap. Trusted, non-writable
|
|
253
|
+
// parent directories prevent intermediate-component replacement.
|
|
254
|
+
return fs.open(targetCanonical, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Framework Validation
|
|
259
|
+
|
|
260
|
+
### Node.js with Joi
|
|
261
|
+
|
|
262
|
+
```javascript
|
|
263
|
+
const Joi = require("joi");
|
|
264
|
+
|
|
265
|
+
const userSchema = Joi.object({
|
|
266
|
+
username: Joi.string().alphanum().min(3).max(30).required(),
|
|
267
|
+
email: Joi.string().email().required(),
|
|
268
|
+
age: Joi.number().integer().min(0).max(150),
|
|
269
|
+
website: Joi.string().uri({ scheme: ["http", "https"] }),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
function validateUser(data) {
|
|
273
|
+
const { error, value } = userSchema.validate(data);
|
|
274
|
+
if (error) throw new Error(error.details[0].message);
|
|
275
|
+
return value;
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Express Validator
|
|
280
|
+
|
|
281
|
+
```javascript
|
|
282
|
+
const { body, validationResult } = require("express-validator");
|
|
283
|
+
|
|
284
|
+
app.post(
|
|
285
|
+
"/user",
|
|
286
|
+
body("email").isEmail().normalizeEmail(),
|
|
287
|
+
body("password").isLength({ min: 8 }),
|
|
288
|
+
body("age").isInt({ min: 0, max: 150 }),
|
|
289
|
+
(req, res) => {
|
|
290
|
+
const errors = validationResult(req);
|
|
291
|
+
if (!errors.isEmpty()) {
|
|
292
|
+
return res.status(400).json({ errors: errors.array() });
|
|
293
|
+
}
|
|
294
|
+
// Process valid input
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Zod (TypeScript)
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
import { z } from "zod";
|
|
303
|
+
|
|
304
|
+
const AllowedWebsiteSchema = z.string().transform((value, ctx) => {
|
|
305
|
+
const url = parseAllowedHttpUrl(value, {
|
|
306
|
+
baseUrl: "https://example.com",
|
|
307
|
+
allowedOrigins: ["https://example.com"],
|
|
308
|
+
allowedPathPrefixes: ["/profiles"],
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (!url) {
|
|
312
|
+
ctx.addIssue({ code: "custom", message: "Invalid website URL" });
|
|
313
|
+
return z.NEVER;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return url.href;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const UserSchema = z.object({
|
|
320
|
+
username: z
|
|
321
|
+
.string()
|
|
322
|
+
.min(3)
|
|
323
|
+
.max(30)
|
|
324
|
+
.regex(/^[a-zA-Z0-9_]+$/),
|
|
325
|
+
email: z.string().email(),
|
|
326
|
+
age: z.number().int().min(0).max(150).optional(),
|
|
327
|
+
website: AllowedWebsiteSchema.optional(),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
type User = z.infer<typeof UserSchema>;
|
|
331
|
+
|
|
332
|
+
function validateUser(data: unknown): User {
|
|
333
|
+
return UserSchema.parse(data);
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Schema validators are useful for structured input and typed parsing, but they do
|
|
338
|
+
not replace business rules. Add refinements or transforms for application rules
|
|
339
|
+
such as allowed URL origins, allowed enum transitions, cross-field constraints,
|
|
340
|
+
and authorization-dependent limits.
|
|
341
|
+
|
|
342
|
+
## Validation Checklist
|
|
343
|
+
|
|
344
|
+
- [ ] Validate all input on the server
|
|
345
|
+
- [ ] Use allowlist validation when possible
|
|
346
|
+
- [ ] Consume normalized parsed values after validation
|
|
347
|
+
- [ ] Validate data type, length, format, and range
|
|
348
|
+
- [ ] Avoid `parseInt`, `parseFloat`, and free-form `Date.parse()` as validators
|
|
349
|
+
- [ ] Reject unexpected input rather than sanitizing
|
|
350
|
+
- [ ] Use context-specific output encoding at the rendering boundary
|
|
351
|
+
- [ ] Sanitize only when accepting user-supplied HTML fragments
|
|
352
|
+
- [ ] Use parameterized queries for database operations
|
|
353
|
+
- [ ] Validate file uploads (type, size, content)
|
|
354
|
+
- [ ] Canonicalize paths before validation
|
|
355
|
+
- [ ] Log validation failures for monitoring
|
|
356
|
+
|
|
357
|
+
OWASP References:
|
|
358
|
+
|
|
359
|
+
- https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
|
|
360
|
+
- https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
|