coding-input-sanitization
Overview
Untrusted input is sanitized at the TRUST BOUNDARY - the edge of an application or a public/facing
API - in two directions: validate-and-bound on the way IN, escape-for-the-sink on the way OUT.
Core principle: sanitize at the boundary, not in the libraries between boundaries. A library
called by your own trusted code assumes its inputs were already validated at the edge; re-sanitizing
on every internal call is waste and false confidence. Two distinct defenses, both required: input
validation does NOT make output safe, and output escaping does NOT replace input validation.
Where this applies (and where it does NOT)
APPLIES - an untrusted boundary, data from outside your control:
- HTTP request body / query params / headers / cookies; web form fields; multipart file uploads
- webhook payloads; queue / broker / pub-sub messages
- CLI arguments and stdin carrying user data
- responses from a third-party API; scraped data; rows from a foreign / legacy system
DOES NOT APPLY - internal seams between trusted code:
- a domain/application function called by your own validated code
- a library/package boundary between your own modules
- These rely on the TYPE CONTRACT (the edge already validated). At most assert/typecheck; do not
re-run input sanitization. Sanitizing everywhere is the anti-pattern this skill prevents.
On the way IN - validate at the edge
- Parse into a typed model, never inspect a raw dict. A boundary parser (Pydantic in Python)
validates type and shape, coerces, and REJECTS what does not fit. Never pass a raw dict / JSON /
ORM row inward; convert to a typed domain object immediately.
- Bound length and size. Max string length, collection size, numeric range, request-body size.
Unbounded input is a DoS vector; stream or paginate large data, never materialize unbounded. For
the wider self-healing/resource-guard patterns (bounding concurrency/memory, headroom checks), see
bitranox:coding-resilience.
- Handle arbitrary bytes/chars. non-ASCII, emoji, CJK, control chars, NUL, binary: reject,
normalize (e.g. Unicode NFC), or escape - never trust raw. Decide the allowed charset explicitly.
Test the edge with the adversarial input battery (UTF/emoji/CJK/binary/wrong-type/oversized) in
bitranox:process-test-design.
On the way OUT - escape at the SINK, for that sink's context
The same value is safe for one sink and dangerous for another, so escaping belongs at the sink, not
once at the edge.
| Sink |
Rule |
Never |
| SQL |
parametrized query / bound parameters (driver placeholders or the ORM query API) |
string-concat or f-string external data into SQL |
| HTML / templates |
autoescape ON (select_autoescape(default=True, default_for_string=True)); a trusted rich-text field opts out per-interpolation with |safe |
disable autoescape globally; mark untrusted data |safe |
| Shell / subprocess |
subprocess.run([argv...]) no shell; shlex.split a user template into list elements |
shell=True with untrusted input; f-string into a shell line |
| File path |
confine to a base dir by resolving THEN checking: p = Path(base, name).resolve(), keep only if p.is_relative_to(base.resolve()); reject NUL |
trust the join to confine - Path(base, "../../etc/passwd") and Path(base, "/etc/passwd") both resolve OUTSIDE base |
| Deserialization |
a safe format (JSON) parsed into a typed model |
pickle / yaml.load / eval on untrusted data |
| Outbound URL (SSRF) |
allowlist host + scheme; block internal/link-local ranges and redirects to them |
fetch a user-supplied URL unrestricted |
| Log / response header |
strip CR/LF + control chars before writing |
write user data into a header/log line raw (injection/forging) |
Quick checklist
Common mistakes
- Sanitizing everywhere. Re-validating in internal libs between trusted callers. Validate once at
the boundary; trust the typed value within.
- "I validated the input, so output is safe." No - a name validated as a string is still XSS in
HTML and SQLi in a concatenated query. Escape at each sink.
- Blocklist instead of allowlist. Stripping
<script> or quoting one metachar is bypassable.
Validate what is ALLOWED (type, charset, range) and use the sink's real escaping primitive.
- One global
sanitize() that strips characters. Context-free stripping corrupts valid data and
still misses context-specific attacks. There is no universal sanitizer; escape per sink.
- "It takes an argv, so there is no shell to inject into." Removing the shell removes ONE sink,
not the need to validate.
chpasswd reads user:password one entry PER LINE from stdin, so a
newline inside a password smuggles in a second entry - run as root, that sets root's password, with
no shell anywhere. Likewise a name beginning with - is parsed as an OPTION by useradd/gpasswd
rather than as a name. Reject \n in a password at the boundary and allowlist account names to a
POSIX pattern. The sink here is a line-oriented parser; treat it like any other sink.
Language notes
Examples are Python (the primary stack); the principle is language-agnostic. Rust:
bitranox:coding-rust. Bash: bitranox:coding-bash-reference (quoting, never eval untrusted
input); shlex is Python's, for building an argv from a string - see the shell sink above. Boundary-parsing architecture (where the edge is, typed models flowing inward):
bitranox:coding-python-clean-architecture, bitranox:coding-python-enforce-data-architecture-strict.
1---2name: coding-input-sanitization3description: Use when handling untrusted or external input at an application or facing-API boundary - an HTTP/REST endpoint, web form, file upload, webhook, CLI taking user data, queue/broker message, or data from a third-party or legacy system - or when emitting into SQL, HTML, a shell, a file path, or another sink. Keywords - SQL injection, XSS, command injection, path traversal, deserialization, SSRF, unbounded-input DoS. For boundary-parsing architecture see coding-python-clean-architecture and coding-python-enforce-data-architecture-strict.4---56# coding-input-sanitization78## Overview910Untrusted input is sanitized at the TRUST BOUNDARY - the edge of an application or a public/facing11API - in two directions: validate-and-bound on the way IN, escape-for-the-sink on the way OUT.1213**Core principle: sanitize at the boundary, not in the libraries between boundaries.** A library14called by your own trusted code assumes its inputs were already validated at the edge; re-sanitizing15on every internal call is waste and false confidence. Two distinct defenses, both required: input16validation does NOT make output safe, and output escaping does NOT replace input validation.1718## Where this applies (and where it does NOT)1920APPLIES - an untrusted boundary, data from outside your control:21- HTTP request body / query params / headers / cookies; web form fields; multipart file uploads22- webhook payloads; queue / broker / pub-sub messages23- CLI arguments and stdin carrying user data24- responses from a third-party API; scraped data; rows from a foreign / legacy system2526DOES NOT APPLY - internal seams between trusted code:27- a domain/application function called by your own validated code28- a library/package boundary between your own modules29- These rely on the TYPE CONTRACT (the edge already validated). At most assert/typecheck; do not30 re-run input sanitization. Sanitizing everywhere is the anti-pattern this skill prevents.3132## On the way IN - validate at the edge3334- **Parse into a typed model, never inspect a raw dict.** A boundary parser (Pydantic in Python)35 validates type and shape, coerces, and REJECTS what does not fit. Never pass a raw dict / JSON /36 ORM row inward; convert to a typed domain object immediately.37- **Bound length and size.** Max string length, collection size, numeric range, request-body size.38 Unbounded input is a DoS vector; stream or paginate large data, never materialize unbounded. For39 the wider self-healing/resource-guard patterns (bounding concurrency/memory, headroom checks), see40 `bitranox:coding-resilience`.41- **Handle arbitrary bytes/chars.** non-ASCII, emoji, CJK, control chars, NUL, binary: reject,42 normalize (e.g. Unicode NFC), or escape - never trust raw. Decide the allowed charset explicitly.4344Test the edge with the adversarial input battery (UTF/emoji/CJK/binary/wrong-type/oversized) in45`bitranox:process-test-design`.4647## On the way OUT - escape at the SINK, for that sink's context4849The same value is safe for one sink and dangerous for another, so escaping belongs at the sink, not50once at the edge.5152| Sink | Rule | Never |53|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|54| SQL | parametrized query / bound parameters (driver placeholders or the ORM query API) | string-concat or f-string external data into SQL |55| HTML / templates | autoescape ON (`select_autoescape(default=True, default_for_string=True)`); a trusted rich-text field opts out per-interpolation with `\|safe` | disable autoescape globally; mark untrusted data `\|safe` |56| Shell / subprocess | `subprocess.run([argv...])` no shell; `shlex.split` a user template into list elements | `shell=True` with untrusted input; f-string into a shell line |57| File path | confine to a base dir by resolving THEN checking: `p = Path(base, name).resolve()`, keep only if `p.is_relative_to(base.resolve())`; reject NUL | trust the join to confine - `Path(base, "../../etc/passwd")` and `Path(base, "/etc/passwd")` both resolve OUTSIDE base |58| Deserialization | a safe format (JSON) parsed into a typed model | `pickle` / `yaml.load` / `eval` on untrusted data |59| Outbound URL (SSRF) | allowlist host + scheme; block internal/link-local ranges and redirects to them | fetch a user-supplied URL unrestricted |60| Log / response header | strip CR/LF + control chars before writing | write user data into a header/log line raw (injection/forging) |6162## Quick checklist6364- [ ] Every external input parsed into a typed model at the edge; raw dict/JSON never flows inward65- [ ] Length / size / range bounded; large data streamed, not fully materialized66- [ ] Allowed charset decided explicitly; arbitrary bytes/chars rejected, normalized (NFC), or escaped67- [ ] SQL via bound parameters only; no string-built queries68- [ ] HTML autoescape ON; only trusted rich-text opts out, never a global disable69- [ ] Shell via argv, never `shell=True` on untrusted input70- [ ] File paths confined to a base dir; `..` / absolute rejected71- [ ] No `pickle` / `yaml.load` / `eval` on untrusted data72- [ ] Outbound URLs allowlisted (host + scheme); internal/link-local ranges and redirects to them blocked73- [ ] CR/LF + control chars stripped before writing user data to a log line or response header74- [ ] Internal library calls NOT re-sanitized (type contract trusted)7576## Common mistakes7778- **Sanitizing everywhere.** Re-validating in internal libs between trusted callers. Validate once at79 the boundary; trust the typed value within.80- **"I validated the input, so output is safe."** No - a name validated as a string is still XSS in81 HTML and SQLi in a concatenated query. Escape at each sink.82- **Blocklist instead of allowlist.** Stripping `<script>` or quoting one metachar is bypassable.83 Validate what is ALLOWED (type, charset, range) and use the sink's real escaping primitive.84- **One global `sanitize()` that strips characters.** Context-free stripping corrupts valid data and85 still misses context-specific attacks. There is no universal sanitizer; escape per sink.86- **"It takes an argv, so there is no shell to inject into."** Removing the shell removes ONE sink,87 not the need to validate. `chpasswd` reads `user:password` one entry PER LINE from stdin, so a88 newline inside a password smuggles in a second entry - run as root, that sets root's password, with89 no shell anywhere. Likewise a name beginning with `-` is parsed as an OPTION by `useradd`/`gpasswd`90 rather than as a name. Reject `\n` in a password at the boundary and allowlist account names to a91 POSIX pattern. The sink here is a line-oriented parser; treat it like any other sink.9293## Language notes9495Examples are Python (the primary stack); the principle is language-agnostic. Rust:96`bitranox:coding-rust`. Bash: `bitranox:coding-bash-reference` (quoting, never eval untrusted97input); `shlex` is Python's, for building an argv from a string - see the shell sink above. Boundary-parsing architecture (where the edge is, typed models flowing inward):98`bitranox:coding-python-clean-architecture`, `bitranox:coding-python-enforce-data-architecture-strict`.