14 KiB
name, description
| name | description |
|---|---|
| sanitizing-user-inputs | 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:
- Validation: Check if input matches expected format/type
- 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
const validRoles = ['admin', 'user', 'guest'] as const;
if (!validRoles.includes(role)) {
throw new ValidationError('Invalid role');
}
Blocklist (bad): Try to block known-bad inputs
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
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:
const email = z.string().email().toLowerCase();
Phone number (US format):
const phone = z.string().regex(/^\+1[0-9]{10}$/);
URL validation:
const url = z.string().url();
Enum values (allowlist):
const role = z.enum(['admin', 'user', 'guest']);
Password requirements:
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:
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:
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):
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):
function UnsafeComponent({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
If you must render HTML, sanitize it first:
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:
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:
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:
SELECT * FROM users WHERE id = 1; DROP TABLE users; --
Safe Parameterized Queries
Use parameterized queries (prepared statements):
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:
$1is a placeholder (not string concatenation)- Database driver escapes parameters safely
- SQL injection is impossible
TypeScript type safety with query builders:
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:
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:
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:
convert input.jpg; rm -rf / output.jpg
Safe Command Execution
Use array-based execution:
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) vsexec(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:
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
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:
- Validate filename format (no
.., no/) - Resolve absolute paths
- Check final path is within expected directory
API Request Validation
Express/Node.js Example
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
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
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
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
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
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
function LoginForm() {
const handleSubmit = (e) => {
if (email.includes('@')) {
}
};
}
Problem: Client-side validation can be bypassed. Always validate on server.
Mistake 2: Regex Bypasses
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
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
Summary
Input validation is your first and most critical line of defense.
- Validate everything - Never trust user input
- Use Zod - Type-safe validation at runtime
- Sanitize HTML - Use DOMPurify for any user-provided HTML
- Parameterize SQL - Never concatenate queries
- Validate paths - Prevent directory traversal
- Use allowlists - More secure than blocklists
- Validate server-side - Client validation is UX, not security
Security is not optional. Every unvalidated input is a potential breach.