Every injection class in this track is the same mistake at a different boundary.
SQL injection, shell injection, path traversal, template injection, log injection, header injection, deserialisation RCE — the mechanism differs, the mitigations look unrelated, and the underlying error is one sentence:
Data from outside is a string until you have parsed it into a type.
The string is the problem. Not because strings are dangerous, but because a string is the same type whether it came from your source code or from a request body, and the type system therefore cannot tell you which one you are holding. Every technique below is a way of making that distinction visible — to the runtime, to the type checker, or to the reviewer.
This is the one page. Keep it.
1. Parse at the edge into a distinct type
def validate(d: dict) -> dict leaves the caller unable to tell validated data
from unvalidated data. The boundary function’s signature is
Mapping[str, object] -> Config, and after it returns, the type is the proof
that the check happened.
Reject unknown keys. Name the key path in the error. Do the parse once, at startup or at the request edge, not lazily on the path nobody exercises.
2. Never pickle external data
pickle.loads is arbitrary code execution by design — the format has an opcode
that means “call this callable”. Signing does not fix it, it just moves the
vulnerability to the key. JSON or msgpack, with a schema on top.
The same applies to yaml.load with the default loader (safe_load instead),
marshal, shelve, and any format advertising that it restores arbitrary
Python objects.
3. Parameterise every query
cursor.execute("SELECT * FROM t WHERE k = ?", (key,)) # values
An identifier cannot be parameterised, so when you must interpolate a table
or column name, the only safe source is a closed allowlist you wrote down.
LiteralString (PEP 675) is the standard type-level defence — but check whether
your checker implements it: mypy 2.3 does not, treating it as plain str.
A NewType tainted/trusted split works everywhere:
Sql = NewType("Sql", str)
def render(table: str) -> Sql: ... # the only minting site
def execute(q: Sql, params: Sequence[object]) -> Cursor: ...
4. List-form subprocess, always
subprocess.run([tool, filename], check=True, capture_output=True,
text=True, timeout=30.0)
No shell means shell metacharacters are just characters — not escaped,
never parsed. check=True because a non-zero exit otherwise sails on;
timeout= because a hang is an outage. Windows caveat: .bat/.cmd go through
a shell regardless.
5. Bound recursion, size and time
Deep nesting, oversized bodies, compression bombs, catastrophic regex backtracking. Cap the body at the edge; cap the member count and the total uncompressed size before extracting; and write the depth guard iteratively, because a recursive one overflows on the input it exists to reject.
Extracting archives: filter="data" explicitly on tarfile.extractall if you
support anything before 3.14, where the default is still the unsafe one.
6. Explicit encoding=
open(path) uses locale.getpreferredencoding(), which is UTF-8 on your
laptop, UTF-8 in the container, and cp1252 on the Windows machine where the
support ticket comes from. Pass encoding="utf-8" at every boundary, and decide
deliberately between errors="strict" (fail loudly on bad bytes) and
errors="replace" (keep going with mojibake). Both are defensible; the default
is neither, it is a coin flip decided by the host.
7. Timezone-aware datetimes at every boundary
datetime.now() is naive, in whatever zone the machine happens to be.
datetime.utcnow() is worse — naive and pretending to be UTC, so
.timestamp() reinterprets it as local time. Store and transport aware UTC;
convert to a local zone only for display. The type system cannot help here
(aware and naive are the same type), which makes it one of the strongest cases
in the language for a validated boundary function or a NewType.
8. secrets, not random
random is a Mersenne Twister; a few hundred observed outputs reveal the state
and every future output. secrets.token_urlsafe(32) for anything a user must
not be able to guess.
9. Constant-time comparison for secrets
== short-circuits at the first differing byte, which leaks the secret one byte
at a time to anyone who can average over enough requests.
hmac.compare_digest(a, b) instead. No linter catches this; it lives on the
review checklist.
10. No secrets in reprs, logs or exception messages
A redacting __repr__ handles the first. The second and third are one rule:
never put a value in an exception message. Put the key path and the type
name. "expected int, got str" is a good message;
f"expected int, got {value!r}" is a credential rotation.
Prefer file-mounted or broker-issued credentials over environment variables: an
env var is readable in /proc/<pid>/environ, is inherited by every child
process you spawn, and lands in crash dumps.
The technique underneath all ten: make it a type
Nine of those are rules a reviewer has to remember. The tenth idea is what turns them into something a machine remembers for you.
from typing import NewType
RawHtml = NewType("RawHtml", str) # came from outside; never rendered directly
SafeHtml = NewType("SafeHtml", str) # escaped or authored by us
def escape(raw: RawHtml) -> SafeHtml: ...
def render(page: SafeHtml) -> bytes: ...
At runtime both are str — NewType has zero cost, it does not even create a
class. Statically they are different types, so render(user_input) is an error,
and the only way to obtain a SafeHtml is to call escape. The rule “escape
before rendering” has stopped being a convention and become a thing your CI
proves on every commit.
The pattern generalises to every boundary on this page:
| tainted | trusted | the only bridge |
|---|---|---|
str from a request |
Sql |
render_query() with an allowlist |
RawHtml |
SafeHtml |
escape() |
Mapping[str, object] |
Config |
load_config() |
str path fragment |
SafePath |
resolve, then check it is under the root |
Draft |
Sealed |
seal() |
💡NewType is erased at runtime — SafeHtml("<script>") produces an ordinary str and nothing checks anything. So what has actually been achieved?
click to reveal
Something narrow and genuinely valuable: the number of places where a mistake is possible has gone from “everywhere” to “the lines that call the constructor”.
Before, every call to render was a potential injection, and reviewing for it
meant tracing where each argument came from. After, render cannot be called
with anything but a SafeHtml, so the only way a tainted string gets in is if
someone wrote SafeHtml(x) explicitly. That is a grep-able, reviewable, small
set of lines — and each one is a deliberate assertion by a human that this
particular string is safe, which is exactly the review you wanted and could not
previously locate.
Compare it with the alternative: a runtime wrapper class that validates in
__init__. That does check, and it costs an allocation per value, an attribute
access at every use site, and a serialisation problem at every edge. Sometimes
worth it. But notice what it buys you — it catches the mistake at runtime, in
production, on the path that was not tested. NewType catches it in CI, before
the code exists in a release.
The honest framing is that NewType is not a security control. It is a
review-surface reducer, and reducing the surface where a security control
must be applied by hand is most of what secure design is.
The five-minute version
When you are reviewing code and you see data crossing into your process, ask:
-
What type does it have after the boundary function? If it is still
dict,strorAny, there is no boundary function. - Can this format execute anything?
- Is this string being interpolated into a language — SQL, shell, HTML, a path, a log format?
- Is anything here unbounded — depth, size, time, expansion ratio?
- Could a secret reach a repr, a log line or an exception message?
Five questions. Every item on this page is one of them, asked at a different boundary.