Initial commit
This commit is contained in:
611
skills/sanitizing-user-inputs/SKILL.md
Normal file
611
skills/sanitizing-user-inputs/SKILL.md
Normal file
@@ -0,0 +1,611 @@
|
||||
---
|
||||
name: sanitizing-user-inputs
|
||||
description: Sanitizing and validating user input to prevent XSS, injection attacks, and security vulnerabilities in TypeScript applications
|
||||
---
|
||||
|
||||
# Security: Input Validation and Sanitization
|
||||
|
||||
**Purpose:** Prevent security vulnerabilities by properly validating and sanitizing all user input, preventing XSS, SQL injection, command injection, and other attack vectors.
|
||||
|
||||
**When to use:** Any time you process user input, API requests, URL parameters, form data, file uploads, or any external data source.
|
||||
|
||||
## Core Security Principles
|
||||
|
||||
### 1. Never Trust User Input
|
||||
|
||||
**All user input is potentially malicious until proven safe.**
|
||||
|
||||
This includes:
|
||||
- Form submissions
|
||||
- URL query parameters
|
||||
- Request headers
|
||||
- Cookies
|
||||
- File uploads
|
||||
- WebSocket messages
|
||||
- GraphQL queries
|
||||
- API request bodies
|
||||
|
||||
### 2. Validate Then Sanitize
|
||||
|
||||
**Two-step process:**
|
||||
|
||||
1. **Validation:** Check if input matches expected format/type
|
||||
2. **Sanitization:** Remove or escape dangerous characters
|
||||
|
||||
**Never sanitize without validating first** - sanitization can hide malicious patterns.
|
||||
|
||||
### 3. Allowlist Over Blocklist
|
||||
|
||||
**Allowlist (good):** Accept only known-safe inputs
|
||||
```typescript
|
||||
const validRoles = ['admin', 'user', 'guest'] as const;
|
||||
if (!validRoles.includes(role)) {
|
||||
throw new ValidationError('Invalid role');
|
||||
}
|
||||
```
|
||||
|
||||
**Blocklist (bad):** Try to block known-bad inputs
|
||||
```typescript
|
||||
if (input.includes('<script>') || input.includes('DROP')) {
|
||||
throw new Error('Suspicious input');
|
||||
}
|
||||
```
|
||||
|
||||
Blocklists are always incomplete. Attackers find new bypass techniques.
|
||||
|
||||
## Input Validation with Zod
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
const UserInputSchema = z.object({
|
||||
username: z.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must be at most 20 characters')
|
||||
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
|
||||
email: z.string().email('Invalid email format'),
|
||||
age: z.number().int().positive().max(120),
|
||||
});
|
||||
|
||||
type UserInput = z.infer<typeof UserInputSchema>;
|
||||
|
||||
function handleUserRegistration(input: unknown): UserInput {
|
||||
return UserInputSchema.parse(input);
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Input type is `unknown` (never trust it)
|
||||
- Schema enforces exact constraints (allowlist)
|
||||
- Parse throws on invalid input (fail-fast)
|
||||
- Return type is fully typed after validation
|
||||
|
||||
### Common Validation Patterns
|
||||
|
||||
**Email validation:**
|
||||
```typescript
|
||||
const email = z.string().email().toLowerCase();
|
||||
```
|
||||
|
||||
**Phone number (US format):**
|
||||
```typescript
|
||||
const phone = z.string().regex(/^\+1[0-9]{10}$/);
|
||||
```
|
||||
|
||||
**URL validation:**
|
||||
```typescript
|
||||
const url = z.string().url();
|
||||
```
|
||||
|
||||
**Enum values (allowlist):**
|
||||
```typescript
|
||||
const role = z.enum(['admin', 'user', 'guest']);
|
||||
```
|
||||
|
||||
**Password requirements:**
|
||||
```typescript
|
||||
const password = z.string()
|
||||
.min(12, 'Password must be at least 12 characters')
|
||||
.regex(/[A-Z]/, 'Must contain uppercase letter')
|
||||
.regex(/[a-z]/, 'Must contain lowercase letter')
|
||||
.regex(/[0-9]/, 'Must contain number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Must contain special character');
|
||||
```
|
||||
|
||||
**File upload validation:**
|
||||
```typescript
|
||||
const fileUpload = z.object({
|
||||
filename: z.string()
|
||||
.regex(/^[a-zA-Z0-9_\-\.]+$/, 'Invalid filename'),
|
||||
mimetype: z.enum(['image/jpeg', 'image/png', 'image/gif']),
|
||||
size: z.number().max(5 * 1024 * 1024, 'File too large (max 5MB)'),
|
||||
});
|
||||
```
|
||||
|
||||
## XSS Prevention
|
||||
|
||||
### Understanding XSS
|
||||
|
||||
**Cross-Site Scripting (XSS)** injects malicious JavaScript into your application:
|
||||
|
||||
```typescript
|
||||
const userInput = '<script>alert("XSS")</script>';
|
||||
document.innerHTML = userInput;
|
||||
```
|
||||
|
||||
This executes the script in the user's browser, potentially:
|
||||
- Stealing cookies/session tokens
|
||||
- Performing actions as the user
|
||||
- Defacing the page
|
||||
- Redirecting to phishing sites
|
||||
|
||||
### Preventing XSS in TypeScript/React
|
||||
|
||||
**React (safe by default):**
|
||||
|
||||
```typescript
|
||||
function UserProfile({ username }: { username: string }) {
|
||||
return <div>{username}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
React automatically escapes `{username}`. This is safe even if `username` contains `<script>`.
|
||||
|
||||
**Unsafe (DO NOT DO):**
|
||||
|
||||
```typescript
|
||||
function UnsafeComponent({ html }: { html: string }) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
```
|
||||
|
||||
**If you must render HTML, sanitize it first:**
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
function SafeHTMLComponent({ html }: { html: string }) {
|
||||
const clean = DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
|
||||
ALLOWED_ATTR: ['href'],
|
||||
});
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
|
||||
}
|
||||
```
|
||||
|
||||
**DOMPurify** removes dangerous tags and attributes:
|
||||
- Removes `<script>`, `<iframe>`, `<object>`
|
||||
- Removes event handlers (`onclick`, `onerror`)
|
||||
- Removes `javascript:` URLs
|
||||
- Configurable allowlist of safe tags/attributes
|
||||
|
||||
### Server-Side Rendering (SSR) Safety
|
||||
|
||||
**Express/Node.js:**
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import { escape } from 'html-escaper';
|
||||
|
||||
app.get('/profile', (req, res) => {
|
||||
const username = escape(req.query.username as string);
|
||||
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Welcome ${username}</h1>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
```
|
||||
|
||||
**`html-escaper`** converts dangerous characters:
|
||||
- `<` → `<`
|
||||
- `>` → `>`
|
||||
- `"` → `"`
|
||||
- `'` → `'`
|
||||
- `&` → `&`
|
||||
|
||||
## SQL Injection Prevention
|
||||
|
||||
### Understanding SQL Injection
|
||||
|
||||
**Vulnerable code:**
|
||||
|
||||
```typescript
|
||||
const userId = req.params.id;
|
||||
const query = `SELECT * FROM users WHERE id = ${userId}`;
|
||||
db.query(query);
|
||||
```
|
||||
|
||||
**Attack:**
|
||||
```
|
||||
GET /users/1; DROP TABLE users; --
|
||||
```
|
||||
|
||||
Results in:
|
||||
```sql
|
||||
SELECT * FROM users WHERE id = 1; DROP TABLE users; --
|
||||
```
|
||||
|
||||
### Safe Parameterized Queries
|
||||
|
||||
**Use parameterized queries (prepared statements):**
|
||||
|
||||
```typescript
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const pool = new Pool();
|
||||
|
||||
async function getUser(userId: string) {
|
||||
const query = 'SELECT * FROM users WHERE id = $1';
|
||||
|
||||
const result = await pool.query(query, [userId]);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- `$1` is a placeholder (not string concatenation)
|
||||
- Database driver escapes parameters safely
|
||||
- SQL injection is impossible
|
||||
|
||||
**TypeScript type safety with query builders:**
|
||||
|
||||
```typescript
|
||||
import { Kysely, PostgresDialect } from 'kysely';
|
||||
|
||||
interface Database {
|
||||
users: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
const db = new Kysely<Database>({
|
||||
dialect: new PostgresDialect({ pool }),
|
||||
});
|
||||
|
||||
async function getUser(userId: string) {
|
||||
return await db
|
||||
.selectFrom('users')
|
||||
.where('id', '=', userId)
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Type-safe queries
|
||||
- Automatic parameterization
|
||||
- Compile-time checking
|
||||
|
||||
### ORM Safety
|
||||
|
||||
**TypeORM:**
|
||||
|
||||
```typescript
|
||||
import { getRepository } from 'typeorm';
|
||||
|
||||
async function getUser(userId: string) {
|
||||
return await getRepository(User).findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Prisma:**
|
||||
|
||||
If preventing SQL injection with Prisma ORM, use the preventing-sql-injection skill from prisma-6 for comprehensive $queryRaw vs $queryRawUnsafe guidance and parameterization strategies.
|
||||
|
||||
## Command Injection Prevention
|
||||
|
||||
### Understanding Command Injection
|
||||
|
||||
**Vulnerable code:**
|
||||
|
||||
```typescript
|
||||
import { exec } from 'child_process';
|
||||
|
||||
function processFile(filename: string) {
|
||||
exec(`convert ${filename} output.jpg`, (error, stdout) => {
|
||||
console.log(stdout);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Attack:**
|
||||
```
|
||||
filename = "input.jpg; rm -rf /"
|
||||
```
|
||||
|
||||
Results in:
|
||||
```bash
|
||||
convert input.jpg; rm -rf / output.jpg
|
||||
```
|
||||
|
||||
### Safe Command Execution
|
||||
|
||||
**Use array-based execution:**
|
||||
|
||||
```typescript
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
function processFile(filename: string) {
|
||||
const FileNameSchema = z.string().regex(/^[a-zA-Z0-9_\-\.]+$/);
|
||||
const validFilename = FileNameSchema.parse(filename);
|
||||
|
||||
execFile('convert', [validFilename, 'output.jpg'], (error, stdout) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
console.log(stdout);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Key differences:**
|
||||
- `execFile` (safe) vs `exec` (unsafe)
|
||||
- Arguments passed as array, not string concatenation
|
||||
- Shell is not invoked, no command injection possible
|
||||
- Validate filename format first
|
||||
|
||||
## Path Traversal Prevention
|
||||
|
||||
### Understanding Path Traversal
|
||||
|
||||
**Vulnerable code:**
|
||||
|
||||
```typescript
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
async function getFile(filename: string) {
|
||||
return await readFile(`./uploads/${filename}`, 'utf-8');
|
||||
}
|
||||
```
|
||||
|
||||
**Attack:**
|
||||
```
|
||||
filename = "../../etc/passwd"
|
||||
```
|
||||
|
||||
Results in reading:
|
||||
```
|
||||
./uploads/../../etc/passwd
|
||||
```
|
||||
|
||||
### Safe Path Handling
|
||||
|
||||
```typescript
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
async function getFile(filename: string) {
|
||||
const FileNameSchema = z.string()
|
||||
.regex(/^[a-zA-Z0-9_\-\.]+$/, 'Invalid filename');
|
||||
|
||||
const validFilename = FileNameSchema.parse(filename);
|
||||
|
||||
const uploadDir = path.resolve('./uploads');
|
||||
const fullPath = path.resolve(uploadDir, validFilename);
|
||||
|
||||
if (!fullPath.startsWith(uploadDir)) {
|
||||
throw new Error('Path traversal detected');
|
||||
}
|
||||
|
||||
return await readFile(fullPath, 'utf-8');
|
||||
}
|
||||
```
|
||||
|
||||
**Protection layers:**
|
||||
1. Validate filename format (no `..`, no `/`)
|
||||
2. Resolve absolute paths
|
||||
3. Check final path is within expected directory
|
||||
|
||||
## API Request Validation
|
||||
|
||||
### Express/Node.js Example
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
username: z.string().min(3).max(20),
|
||||
email: z.string().email(),
|
||||
age: z.number().int().positive().max(120),
|
||||
});
|
||||
|
||||
app.post('/users', async (req, res) => {
|
||||
try {
|
||||
const validData = CreateUserSchema.parse(req.body);
|
||||
|
||||
const user = await createUser(validData);
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: error.errors,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Query Parameter Validation
|
||||
|
||||
```typescript
|
||||
const QuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
sort: z.enum(['asc', 'desc']).default('asc'),
|
||||
});
|
||||
|
||||
app.get('/users', (req, res) => {
|
||||
const params = QuerySchema.parse(req.query);
|
||||
|
||||
const users = await getUsers(params);
|
||||
|
||||
res.json(users);
|
||||
});
|
||||
```
|
||||
|
||||
**`z.coerce.number()`** converts string query params to numbers safely.
|
||||
|
||||
## Header Validation
|
||||
|
||||
### Accept Only Expected Headers
|
||||
|
||||
```typescript
|
||||
const HeadersSchema = z.object({
|
||||
'content-type': z.literal('application/json'),
|
||||
'x-api-key': z.string().uuid(),
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
try {
|
||||
HeadersSchema.parse(req.headers);
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: 'Invalid headers' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### CSRF Token Validation
|
||||
|
||||
```typescript
|
||||
import csrf from 'csurf';
|
||||
|
||||
const csrfProtection = csrf({ cookie: true });
|
||||
|
||||
app.post('/transfer', csrfProtection, (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Prevent Abuse and DoS
|
||||
|
||||
```typescript
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
message: 'Too many requests, please try again later.',
|
||||
});
|
||||
|
||||
app.use('/api/', limiter);
|
||||
```
|
||||
|
||||
## Content Security Policy (CSP)
|
||||
|
||||
### Prevent XSS via Headers
|
||||
|
||||
```typescript
|
||||
import helmet from 'helmet';
|
||||
|
||||
app.use(helmet.contentSecurityPolicy({
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
This prevents loading scripts from untrusted sources.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### Mistake 1: Client-Side Validation Only
|
||||
|
||||
```typescript
|
||||
function LoginForm() {
|
||||
const handleSubmit = (e) => {
|
||||
if (email.includes('@')) {
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Problem:** Client-side validation can be bypassed. Always validate on server.
|
||||
|
||||
### Mistake 2: Regex Bypasses
|
||||
|
||||
```typescript
|
||||
if (input.match(/^[a-z]+$/)) {
|
||||
}
|
||||
```
|
||||
|
||||
**Problem:** `^` and `$` match line start/end, not string start/end. Use `\A` and `\z` or Zod.
|
||||
|
||||
### Mistake 3: String Blacklists
|
||||
|
||||
```typescript
|
||||
const sanitized = input.replace('<script>', '');
|
||||
```
|
||||
|
||||
**Attack:** `<scr<script>ipt>` becomes `<script>` after replacement.
|
||||
|
||||
**Solution:** Use proper sanitization libraries like DOMPurify.
|
||||
|
||||
## Security Checklist
|
||||
|
||||
Before deploying any endpoint that processes user input:
|
||||
|
||||
- [ ] All input validated with Zod schemas
|
||||
- [ ] Allowlist validation (not blocklist)
|
||||
- [ ] HTML escaped or sanitized with DOMPurify
|
||||
- [ ] SQL queries parameterized (never concatenated)
|
||||
- [ ] File operations validate paths (prevent traversal)
|
||||
- [ ] Command execution uses array arguments (not shell)
|
||||
- [ ] Rate limiting enabled
|
||||
- [ ] CSRF protection on state-changing endpoints
|
||||
- [ ] Content Security Policy headers set
|
||||
- [ ] Error messages don't leak sensitive info
|
||||
|
||||
## Related Skills
|
||||
|
||||
**Zod v4 Validation:**
|
||||
- If validating common input formats, use the validating-string-formats skill for top-level string format functions (z.email(), z.uuid(), z.url(), z.ipv4(), z.base64())
|
||||
- If transforming user input strings, use the transforming-string-methods skill for built-in string transformations (trim, toLowerCase, toUpperCase)
|
||||
- If constructing Zod schemas for user input, use the validating-schema-basics skill for comprehensive schema patterns
|
||||
|
||||
**Prisma 6 Security:**
|
||||
- If preventing SQL injection with Prisma 6, use the preventing-sql-injection skill from prisma-6 for database-specific sanitization patterns, $queryRaw vs $queryRawUnsafe parameterization, and safe query construction
|
||||
- If validating inputs for Prisma database operations with type-safe Zod schemas, use the validating-query-inputs skill from prisma-6 for Prisma-specific validation patterns.
|
||||
|
||||
## Resources
|
||||
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/)
|
||||
- [Zod Documentation](https://zod.dev/)
|
||||
- [DOMPurify](https://github.com/cure53/DOMPurify)
|
||||
|
||||
## Summary
|
||||
|
||||
**Input validation is your first and most critical line of defense.**
|
||||
|
||||
1. **Validate everything** - Never trust user input
|
||||
2. **Use Zod** - Type-safe validation at runtime
|
||||
3. **Sanitize HTML** - Use DOMPurify for any user-provided HTML
|
||||
4. **Parameterize SQL** - Never concatenate queries
|
||||
5. **Validate paths** - Prevent directory traversal
|
||||
6. **Use allowlists** - More secure than blocklists
|
||||
7. **Validate server-side** - Client validation is UX, not security
|
||||
|
||||
Security is not optional. Every unvalidated input is a potential breach.
|
||||
Reference in New Issue
Block a user