Security No. 276

Input Validation skill for Claude Code

Safe sinks first, schemas second, payload tests always. Claude stops relying on character stripping and fixes injection where it happens: bound parameters for SQL and allowlists for identifiers, context-specific encoding and sanitizers for HTML, resolved-IP checks for user-supplied URLs, resolved-path checks for file access, and argument arrays for commands. Boundary schemas in Zod or Pydantic add a second layer, and every fix ships with a payload test.

  • nameinput-validation
  • size202 lines · ≈2,200 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Hardens code against injection by validating input at trust boundaries and encoding output for its context — parameterized SQL and NoSQL queries, contextual output encoding and HTML sanitization for XSS, SSRF-safe URL fetching, path traversal-safe file access, command and template injection, unsafe deserialization, XXE, open redirects, and ReDoS. Use when writing or reviewing code that builds queries, renders user content, fetches user-supplied URLs, opens files by name, shells out, or parses untrusted data, or when fixing an injection finding.

Try asking

  • Our link preview feature fetches any URL users paste — make it SSRF-safe
  • Fix the SQL injection in this report endpoint that sorts by a query param
  • Review this upload download handler for path traversal

Use it when

  • Code builds SQL, shell commands, or templates from request data
  • A feature fetches URLs or opens files chosen by users
  • User-generated HTML or Markdown is rendered to other users
  • A scanner or reviewer reported an injection finding to fix

Skip it when

  • You need a broad audit across all risk categories — use owasp-review instead
  • The problem is form UX and client-side error messages — use forms-validation instead

How to check the result

Each fixed sink has a test sending a real payload such as a quote-based SQL string, an onerror image tag, or a cloud metadata URL, and the test asserts rejection or inert output.

The common mistake it prevents

Validating a URL's hostname as a string and then letting the HTTP client resolve it again, so DNS rebinding or a redirect still reaches internal addresses.

~/.claude/skills/input-validation/SKILL.md

--- name: input-validation description: Hardens code against injection by validating input at trust boundaries and encoding output for its context — parameterized SQL and NoSQL… license: MIT ---

Done well means: untrusted data is parsed into typed, constrained values at the boundary (schema validation, allowlists), and every sink receives it through the safe API for that sink (bound parameters, context-aware encoding, argument arrays, resolved-path checks). Validation reduces attack surface; the safe sink API is what actually prevents injection. You need both.

Principles

  • Validate on the server. Client-side validation is UX.
  • Allowlist over denylist: define what is valid (type, length, range, format, enum) and reject the rest. Do not try to strip “bad characters”.
  • Canonicalize before validating: decode, normalize Unicode (NFC/NFKC where relevant), resolve paths, parse URLs with a real parser — then check.
  • Reject, do not repair. Silently “fixing” input hides attacks and creates parser differentials.
  • Encode at output time, for the specific context, not at input time.
  • Treat data from your own database, third-party APIs, files, and LLM outputs as untrusted when it originally came from users.

Boundary validation

TypeScript (Zod):

import { z } from "zod";
const CreateUser = z.object({
  email: z.string().trim().toLowerCase().email().max(254),
  displayName: z.string().trim().min(1).max(80),
  role: z.enum(["member", "viewer"]),          // never accept "admin" from the client
  age: z.number().int().min(13).max(130).optional(),
}).strict();                                     // reject unknown keys (mass assignment)
const input = CreateUser.parse(req.body);

Python (Pydantic v2):

from pydantic import BaseModel, ConfigDict, EmailStr, Field
from typing import Literal

class CreateUser(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
    email: EmailStr
    display_name: str = Field(min_length=1, max_length=80)
    role: Literal["member", "viewer"]

Also bound: request body size, array lengths, nesting depth, file sizes, pagination limit (cap at e.g. 100).

Sink-by-sink rules

SQL

cur.execute("SELECT * FROM orders WHERE id = %s AND account_id = %s", (order_id, account_id))  # psycopg
await db.query("SELECT * FROM orders WHERE id = $1 AND account_id = $2", [orderId, accountId]); // pg
  • Identifiers (column, table, sort direction) cannot be bound. Map through an allowlist: ORDER_COLUMNS = {"created": "created_at", "total": "total_cents"}; reject unknown keys.
  • LIKE patterns: bind the value and escape % and _ in user input if literal match is intended.
  • ORM escape hatches (raw, extra, whereRaw, $queryRawUnsafe, sequelize.literal) need the same binding discipline. Prisma’s tagged $queryRaw\…${x}`binds; theUnsafe` variant does not.

NoSQL (MongoDB)

Operator injection: {"password": {"$ne": null}} in a JSON body bypasses checks. Validate that fields expected to be strings are strings (schema validation does this), and never pass raw request objects into find(). Avoid $where and $function with user input.

HTML / XSS — encode for the context

ContextSafe approach
HTML body textFramework auto-escaping (React JSX, Jinja2 autoescape, Django, ERB)
HTML attributeAuto-escaping with quoted attributes; never build attribute names from input
URL in href/srcParse and allow only https:/http: (and mailto: if needed); block javascript:, data:, vbscript:
Inside <script>Do not interpolate. Serialize JSON into a type="application/json" element with < escaped as <, then JSON.parse it
CSSDo not interpolate user input into style blocks; allowlist values (e.g. color hex regex)
User-supplied rich HTMLSanitize with DOMPurify (browser/Node with jsdom) or nh3 (Python); allowlist tags and attributes

Escape hatches to audit: dangerouslySetInnerHTML, innerHTML, outerHTML, insertAdjacentHTML, document.write, v-html, |safe, mark_safe, html_safe, {{{ }}}, <%-. Pair with a strict CSP (see security-headers) as defense in depth.

SSRF — fetching user-supplied URLs

Webhook targets, image import, link previews, PDF renderers, and import-from-URL features are SSRF sinks. Default: allowlist destination hosts. If arbitrary hosts are required:

import ipaddress, socket
from urllib.parse import urlsplit

def resolve_public(url: str) -> tuple[str, str, int]:
    parts = urlsplit(url)
    if parts.scheme not in ("https", "http") or not parts.hostname:
        raise ValueError("scheme not allowed")
    port = parts.port or (443 if parts.scheme == "https" else 80)
    if port not in (80, 443):
        raise ValueError("port not allowed")
    infos = socket.getaddrinfo(parts.hostname, port, proto=socket.IPPROTO_TCP)
    for *_, sockaddr in infos:
        ip = ipaddress.ip_address(sockaddr[0])
        if not ip.is_global or ip.is_multicast:
            raise ValueError(f"blocked address {ip}")
    return parts.hostname, infos[0][4][0], port

Then connect to the resolved IP (pinning it, with the original Host header/SNI), so DNS rebinding cannot swap the address between check and connect. Disable automatic redirects or re-run the check on every hop. Set short timeouts and a response size cap. On AWS require IMDSv2; better, run fetchers in a network segment with no route to internal services. Libraries that implement this pattern (e.g. ssrf-req-filter/request-filtering-agent for Node agents) are preferable to hand-rolled checks — verify they check post-resolution IPs.

Path traversal

from pathlib import Path
BASE = Path("/srv/uploads").resolve()

def safe_path(user_name: str) -> Path:
    p = (BASE / user_name).resolve()
    if not p.is_relative_to(BASE):
        raise ValueError("path escapes base directory")
    return p
import path from "node:path";
const BASE = path.resolve("/srv/uploads");
const p = path.resolve(BASE, userName);
if (p !== BASE && !p.startsWith(BASE + path.sep)) throw new Error("path escapes base");

Better still: never use user input as a filename. Store files under generated IDs and keep the original name as metadata. Watch for symlinks inside the base (resolve follows them — that is the point) and for archive extraction (zip slip): check every entry’s resolved path.

OS commands

Use argument arrays, never a shell: subprocess.run(["pdftotext", src, dst], check=True), execFile("pdftotext", [src, dst]). Arguments starting with - can still inject options; validate or prefix with -- where supported. Prefer a library over shelling out.

Templates, eval, deserialization, XML

  • Never render user input as a template (render_template_string(user), new Function, eval, vm.runInContext is not a sandbox).
  • Deserialization: JSON + schema. Never pickle, Java native serialization, yaml.load without SafeLoader, PHP unserialize, Ruby Marshal/unsafe YAML on untrusted data.
  • XML: disable DTDs/external entities (Python defusedxml; Java disallow-doctype-decl; .NET DtdProcessing.Prohibit).

Redirects and headers

  • Open redirect: allow only relative paths starting with a single / (reject // and /\), or match against an allowlist of absolute URLs.
  • CRLF/header injection: reject \r and \n in any value placed in headers, filenames for Content-Disposition, or log lines.

ReDoS

Regexes with nested quantifiers ((a+)+, (\w+\s?)*$) on attacker input can take exponential time. Bound input length before matching, avoid nested quantifiers, and use linear-time engines where available (RE2 / re2 bindings, Rust regex, Go regexp).

Review workflow

- [ ] 1. List entry points and their schemas; flag handlers with no validation
- [ ] 2. Grep for each sink family (SQL raw, HTML escape hatches, fetch/requests with variables,
         open/readFile with variables, exec/spawn/system, eval, deserializers, XML parsers)
- [ ] 3. For each hit, trace source -> sink; note validation and encoding on the path
- [ ] 4. Fix at the sink with the safe API; add boundary validation as a second layer
- [ ] 5. Add a negative test per fix with a real payload

Negative test payloads (use in unit/integration tests against your own code): ' OR '1'='1, {"$ne": null}, <img src=x onerror=alert(1)>, javascript:alert(1), ../../etc/passwd, ..%2f..%2fetc%2fpasswd, http://169.254.169.254/latest/meta-data/, http://[::1]/, http://2130706433/, ; id, --help, {{7*7}}, /\evil.example.

Verification

  • Each fixed sink has a test that sends the relevant payload and asserts rejection or inert output (escaped text, 400 response, blocked address).
  • Re-run the sink greps: every remaining hit uses the safe API or has a justification comment.
  • Schema validation rejects unknown fields and oversized inputs in tests.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc