Skip to content
← All articles

The trust-boundary checklist

Every injection class is the same mistake at a different boundary: data from outside is a string until you have parsed it into a type. Ten rules on one page, and the NewType tainted/trusted split that turns nine of them from things a reviewer must remember into something CI proves.

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 strNewType 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:

  1. What type does it have after the boundary function? If it is still dict, str or Any, there is no boundary function.
  2. Can this format execute anything?
  3. Is this string being interpolated into a language — SQL, shell, HTML, a path, a log format?
  4. Is anything here unbounded — depth, size, time, expansion ratio?
  5. 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.

The Edge of the System · step 12 of 12

That's the end of this track. Review it or pick another.

← Back to The Edge of the System