Input Validation, Output Encoding, and Injection Prevention

No single generic “sanitize” function safely handles every context.

4.1 Validation, normalization, encoding, and sanitization are different

Validation: decides whether a value fits the application's expected rules.

Normalization: converts equivalent representations to a consistent form where appropriate.

Output encoding: represents data safely for a specific output context, such as HTML text or an HTML attribute.

Sanitization: removes or transforms disallowed content according to a well-defined policy, often for intentionally supported HTML.

No single generic “sanitize” function safely handles every context. HTML, JavaScript, CSS, URLs, SQL, and shell arguments require different approaches.

4.2 SQL injection

Unsafe pattern — do not use _Not run — deliberately unsafe, shown only so you can recognise the pattern in code review. The parameterized version below is the fix._ Safe parameterized pattern

Output (SQLite via CPython 3.13.1): the call above run in a loop over three inputs against a two-row users table. The last input contains quotes and SQL-looking text; it matches nothing because the database compares it as one literal email address:

The parameter is data, not SQL syntax. Placeholder syntax differs by database driver; use the driver's documented parameterized interface. For dynamic column names or sort directions that cannot be bound as values, map user choices to a strict allowlist of known identifiers.

Test: use input containing quotes and SQL-looking text and confirm it is treated as a literal value, not as query structure.

Identifiers cannot be bound — use an allowlist Placeholders bind *values*. A sort column or direction is part of the SQL text, so map the user's choice onto a fixed set of known identifiers:

Output (CPython 3.13.1): The second call never builds any SQL: the requested sort key is not in the allowlist, so the function rejects it and the table is untouched.

Try it: parameterized versus concatenated queries The lab below runs the same lookup against a small in-browser table both ways. It shows where the value ends up — inside the SQL text or in a separate parameter list — and checks, with a tokenizer, whether the input changed the *structure* of the statement. Nothing is sent to a server.

# UNSAFE: user input is merged into SQL syntax.
query = "SELECT id, display_name FROM users WHERE email = '" + email + "'"
cursor.execute(
    "SELECT id, display_name FROM users WHERE email = ?",
    (email,)
)
'[email protected]' -> [(1, 'Ana')]
"o'[email protected]" -> [(2, 'Aoife')]
"[email protected]' OR '1'='1" -> []
rows in table: 2
import sqlite3

SORT_COLUMNS = {"name": "display_name", "joined": "created_at"}
DIRECTIONS = {"asc": "ASC", "desc": "DESC"}

def list_users(con, sort: str, direction: str):
    column = SORT_COLUMNS.get(sort)
    order = DIRECTIONS.get(direction)
    if column is None or order is None:
        raise ValueError("unsupported sort option")
    # Only allowlisted identifiers reach the SQL text; there are no user values to bind here.
    return con.execute(
        f"SELECT display_name FROM users ORDER BY {column} {order}"
    ).fetchall()

con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users (display_name TEXT, created_at TEXT)")
con.executemany("INSERT INTO users VALUES (?, ?)",
                [("Ana", "2024-03-01"), ("Ben", "2023-11-20"), ("Caro", "2024-01-15")])

print(list_users(con, "joined", "desc"))
try:
    list_users(con, "display_name; DROP TABLE users", "asc")
except ValueError as error:
    print("rejected:", error)
print(con.execute("SELECT count(*) FROM users").fetchone())
[('Ana',), ('Caro',), ('Ben',)]
rejected: unsupported sort option
(3,)

4.3 Cross-site scripting (XSS)

XSS occurs when untrusted content is interpreted as executable browser content. Common contexts include HTML text, attributes, URLs, JavaScript, and CSS.

Rendered with react-dom/server (React 19.2; JSX compiled by the transformer in this site's Vite toolchain) for displayName = 'Ana <b>"A&B"</b>':

The markup characters arrive as text (<b>), not as an element.

React escapes ordinary text interpolation. Avoid dangerouslySetInnerHTML for untrusted content. If user-authored HTML is a product requirement, use a reputable sanitizer configured for that exact purpose and keep it updated. Sanitization does not replace contextual encoding for other output contexts.

Encoding depends on the output context Output (CPython 3.13.1):

Each context has its own rules: HTML attributes also need quotes encoded, URLs use percent-encoding, and data passed to JavaScript should travel as JSON (for example in a <script type="application/json"> element read with JSON.parse), not be pasted into script source. Template engines with contextual auto-escaping apply the right rule for you.

function Greeting({ displayName }: { displayName: string }) {
  return <p>Hello, {displayName}</p>;
}
<p>Hello, Ana &lt;b&gt;&quot;A&amp;B&quot;&lt;/b&gt;</p>
import html
import json
from urllib.parse import quote

value = 'Ana <b>"A&B"</b>'

print("HTML text:      ", html.escape(value, quote=False))
print("HTML attribute: ", html.escape(value, quote=True))
print("URL component:  ", quote(value, safe=""))
print("JSON / JS data: ", json.dumps({"name": value}))
HTML text:       Ana &lt;b&gt;"A&amp;B"&lt;/b&gt;
HTML attribute:  Ana &lt;b&gt;&quot;A&amp;B&quot;&lt;/b&gt;
URL component:   Ana%20%3Cb%3E%22A%26B%22%3C%2Fb%3E
JSON / JS data:  {"name": "Ana <b>\"A&B\"</b>"}

4.4 Cross-site request forgery (CSRF)

CSRF can occur when a browser automatically attaches ambient credentials, such as cookies, to a request triggered by another site.

Controls depend on architecture:

Use appropriate SameSite cookie settings.

Use anti-CSRF tokens for cookie-authenticated state-changing requests when required by the framework and threat model.

Validate Origin/Referer where appropriate as defense in depth.

Do not mutate state through GET requests.

Re-authenticate or require stronger confirmation for sensitive actions.

Understand that CORS alone is not a general CSRF defense.

4.5 Command injection

Prefer a library/API over invoking a shell. If a subprocess is required, pass an executable and argument array, use a fixed executable path where possible, avoid shell interpretation, validate arguments, and constrain the process.

Python safe subprocess pattern Output (Python 3.10.4 on Ubuntu 22.04 (WSL2), file 5.41) for two test files whose names contain a semicolon, spaces and a leading hyphen. Each name reaches file as one argument; no shell ever parses it, and -- stops the hyphenated name being read as an option:

This avoids shell parsing, but it is not a complete file-access policy. The application must still restrict allowed paths, permissions, execution environment, and output handling.

import subprocess

def get_file_metadata(path: str) -> str:
    # The caller must also validate that the path is within an allowed directory.
    result = subprocess.run(
        ["/usr/bin/file", "--", path],
        shell=False,
        check=True,
        capture_output=True,
        text=True,
        timeout=3,
    )
    return result.stdout
notes; v2.txt: ASCII text
-weird name.txt: ASCII text

4.6 Path traversal

Never assume a user-supplied path is safe because it contains a familiar extension. Prefer mapping an opaque file ID to a stored path. If accepting a path is unavoidable, resolve it against a fixed root and verify the resolved target remains inside that root; also consider symlinks and race conditions.

Conceptual Python check:

Output (Python 3.10.4 on Ubuntu 22.04 (WSL2)) with ROOT pointed at a temporary test directory containing notes.txt, a sub/ folder and link.txt — a symlink to a file outside the root:

/etc/passwd is rejected because joining an absolute path *replaces* the root entirely, and the symlink is rejected because resolve() follows it. Both show why the check runs on the resolved result rather than on the input string.

This is an illustrative containment check, not a complete defense for every filesystem or race condition. Prefer opaque IDs and controlled file APIs; use operating-system permissions and safe open patterns where race conditions matter.

Try it: path containment

from pathlib import Path

ROOT = Path("/srv/app/private-files").resolve()

def resolve_private_file(name: str) -> Path:
    candidate = (ROOT / name).resolve()
    if candidate != ROOT and ROOT not in candidate.parents:
        raise ValueError("Path is outside the permitted directory")
    if not candidate.is_file():
        raise FileNotFoundError("File not found")
    return candidate
'notes.txt'          -> allowed: notes.txt
'sub/../notes.txt'   -> allowed: notes.txt
'../secret.txt'      -> ValueError: Path is outside the permitted directory
'/etc/passwd'        -> ValueError: Path is outside the permitted directory
'link.txt'           -> ValueError: Path is outside the permitted directory
'missing.txt'        -> FileNotFoundError: File not found
pathcheck

4.7 Server-side request forgery (SSRF)

SSRF occurs when a server fetches a URL or resource selected by an untrusted party. Defenses include:

Avoid arbitrary URL fetching when possible.

Allowlist schemes, hostnames, ports, and destinations.

Resolve DNS and validate resulting IP addresses against prohibited ranges.

Revalidate redirects and each resolved destination.

Block access to loopback, private, link-local, metadata, and internal control-plane destinations unless explicitly required.

Enforce egress filtering at the network layer.

Use timeouts, response-size limits, and safe content handling.

Hostname validation alone is insufficient because of DNS rebinding, redirects, and alternate IP representations.

Why parsing comes before checking URL parsers normalise alternate spellings of an address. Check the *parsed* destination, not the raw string:

Output (Node.js 24.18): 2130706433 and 0x7f.1 are other spellings of 127.0.0.1; the parser turns them into the canonical address, which the range check then rejects. A hostname that is not an IP literal still has to be resolved and every resolved address checked — and re-checked after redirects — which is why network-level egress filtering is part of the defense.

const BLOCKED = [
  [0x7f000000, 8],   // 127.0.0.0/8 loopback
  [0x0a000000, 8],   // 10.0.0.0/8 private
  [0xac100000, 12],  // 172.16.0.0/12 private
  [0xc0a80000, 16],  // 192.168.0.0/16 private
  [0xa9fe0000, 16],  // 169.254.0.0/16 link-local (includes cloud metadata)
  [0x00000000, 8],   // 0.0.0.0/8
];

function ipv4ToInt(host) {
  const parts = host.split(".").map(Number);
  if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return null;
  return parts.reduce((n, p) => n * 256 + p, 0);
}

function checkDestination(raw) {
  const url = new URL(raw);                      // WHATWG parser, as used by fetch()
  if (url.protocol !== "https:") return `${raw} -> rejected (scheme ${url.protocol})`;
  const ip = ipv4ToInt(url.hostname);
  if (ip !== null && BLOCKED.some(([net, bits]) => (ip >>> (32 - bits)) === (net >>> (32 - bits))))
    return `${raw} -> host ${url.hostname} -> rejected (blocked range)`;
  return `${raw} -> host ${url.hostname} -> needs DNS resolution and an allowlist check`;
}

for (const raw of [
  "https://images.example.com/a.png",
  "https://2130706433/",
  "https://0x7f.1/",
  "https://169.254.169.254/latest/",
  "file://
…
https://images.example.com/a.png -> host images.example.com -> needs DNS resolution and an allowlist check
https://2130706433/ -> host 127.0.0.1 -> rejected (blocked range)
https://0x7f.1/ -> host 127.0.0.1 -> rejected (blocked range)
https://169.254.169.254/latest/ -> host 169.254.169.254 -> rejected (blocked range)
file:///etc/passwd -> rejected (scheme file:)

4.8 Other injection families

LDAP injection: use safe query APIs and escape according to LDAP filter or DN context.

XPath/XQuery injection: use variable binding or safe query APIs.

Template injection: never treat untrusted input as template source; separate data from templates.

Log injection: use structured logging and sanitize control characters when rendering logs.

Header injection: reject CR/LF in values used in HTTP headers; use framework header APIs.

Expression-language injection: do not evaluate untrusted expressions with powerful interpreters.

NoSQL injection: use driver query objects, schema validation, and strict operator/value policies; do not blindly merge client JSON into a query.

GraphQL injection/abuse: use typed inputs, authorization at resolvers/data access, depth/complexity limits, and safe downstream queries.

Header and log values: reject or neutralise line breaks Output (CPython 3.13.1):

Structured logging (one JSON object per event) avoids most of this automatically, because the logger encodes the line breaks inside the string value.

def safe_header_value(value: str) -> str:
    if "\r" in value or "\n" in value:
        raise ValueError("header values must not contain CR or LF")
    return value

def log_field(value: str) -> str:
    # Keep one event on one line when rendering untrusted text in logs.
    return value.replace("\\", "\\\\").replace("\r", "\\r").replace("\n", "\\n")

print(safe_header_value("attachment; filename=report.pdf"))
try:
    safe_header_value("report.pdf\r\nX-Extra: 1")
except ValueError as error:
    print("rejected:", error)

username = "ana\nlogin succeeded for admin"
print(f"login failed user={log_field(username)}")
attachment; filename=report.pdf
rejected: header values must not contain CR or LF
login failed user=ana\nlogin succeeded for admin

Module assessment

For each injection type, identify the interpreter, the trust boundary, the safe API, and a regression test.

Quiz

? Which statement about parameterized queries is accurate?
+ The driver sends the value separately from the SQL text, so it cannot change the statement's structure. :: That is why a quote in the input is just a character.
- They escape quotes, so they are the same as manual escaping. :: Manual escaping is easy to get wrong and context-dependent; parameters avoid mixing data into syntax at all.
- They also make dynamic column names safe. :: Identifiers cannot be bound; use an allowlist.
- They replace the need for authorization. :: A safe query can still return a record the user may not see.

? A comment is shown in an HTML attribute: `<div title="…">`. Which encoding is needed?
- None — React or the database already handled it. :: Contexts must be encoded where the value is written.
- URL encoding :: That is for URL components.
+ HTML attribute encoding (including quotes) :: An unencoded quote would end the attribute.
- Base64 :: Base64 is a transport encoding, not an output-context defense.

? Which measures reduce SSRF risk beyond hostname checks? (select all that apply)
+ Resolve DNS and check every resulting IP against blocked ranges. :: Hostnames can resolve to internal addres
…