Files
2025-11-29 18:22:35 +08:00

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:

  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

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:

  • <&lt;
  • >&gt;
  • "&quot;
  • '&#x27;
  • &&amp;

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:

  • $1 is 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) 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:

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:

  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

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

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.

  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.