Security and Trust Boundaries
Overview
Common security mistakes grouped by trust boundary: input concatenated into a query, a token logged "for debugging", an unguarded endpoint, pickle.loads on untrusted bytes. When code crosses a trust boundary, stop and run the matching checks before you commit.
This is a rigid skill. Jump to the sub-section that matches what you're writing and run that sub-section's checks.
These checks matter most when untrusted input is crossing into a system with real users — production endpoint, shared service, anything that touches user data, secrets, or auth state. In MVPs, prototypes, internal dev tools, and one-off scripts, prefer the simplest thing that works and re-invoke this skill before the code reaches users. Three rules apply at every stage, even prototypes: no committed credentials in source, no string-built SQL or shell commands, no pickle.loads (or equivalent) on untrusted input. Surface these in your summary to the user even in throwaway code.
The rules below describe properties of code that crosses a trust boundary, whether you authored that code or encountered it in a file you are touching. When you find an issue in pre-existing code adjacent to your edit, surface it in your summary to the user — don't silently rewrite the file outside the scope you were asked to change.
When to invoke
Invoke when you're about to:
- Parse, validate, or transform input that originated outside your process
- Write or execute SQL, NoSQL, LDAP, OS command, shell command, or any other interpreted query/script string
- Handle secrets, tokens, credentials, API keys, certificates, or any session/auth material
- Hash a password, encrypt data, generate a token, or pick a random value used in a security context
- Add, remove, or change an authentication or authorization check, or expose a new endpoint
- Deserialize data (
pickle,yaml.load, Java/PHP unserialize, XML with entities, JSON merge) - Construct a file path or URL from user-controlled input
- Review or audit code that crosses trust boundaries
Non-triggers — do NOT invoke for
- Renaming a local variable inside a function that happens to live in
auth/orcrypto/ - Adjusting a docstring or formatting in security-adjacent code
- A unit test that pins down already-agreed behavior on validated inputs
- Editing config files where the values are not secrets and the keys are not new auth toggles
- An early-stage MVP or prototype where the architecture is still in flux and no real user data is involved
- An internal dev tool, debugging endpoint, or one-off script
- Throwaway code expected to be replaced before reaching users
If the change touches one of these domains even slightly, invoke anyway — the per-domain check is short and the bugs are not.
Precedence
97/correctness-trapsoverlaps on input validation as error handling. Rule: trust-boundary crossings (untrusted input, secrets, auth, deserialization, code-execution surfaces) → this skill; non-security correctness (errors, floats, concurrency, IPC, perf, singletons) → that skill. When both clearly apply (e.g., parsing a config file from a possibly-malicious source), run this one first.
Checks by domain
Injection
- Parameterize, never concatenate. SQL: use bound parameters (
cursor.execute("SELECT * FROM u WHERE id = ?", (uid,))), not f-strings or+. NoSQL: use the driver's typed query API, not string-templated JSON. LDAP: escape per RFC 4515 with the driver's helper, not by hand. Command: pass an argv list, not a shell string. Example:cursor.execute(f"SELECT * FROM users WHERE name = '{name}'")is exploitable by any user setting their name to' OR '1'='1. shell=Falseis the default;shell=Trueis a vulnerability. Usesubprocess.run(["git", "clone", url], shell=False)(argv form), notsubprocess.run(f"git clone {url}", shell=True). The argv form passes arguments straight to the kernel; the shell form runs a shell first, which expands$(...), backticks,;,|,&&, globs, and substitutions in attacker-controlled strings. If a shell genuinely is required (rare), every interpolated value must be passed through the language's shell-quoting helper (shlex.quote, etc.) — and you should re-justify why a shell is required.- Template engines auto-escape; raw concatenation does not. Building HTML, SQL, JSON, or any structured output by
+or f-string puts the structure-vs-data decision on the developer. Use a template engine with auto-escaping on (Jinja2, ERB with safe defaults, parameterized JSON builders), or generate via the language's typed AST (LXML for XML, the JSON library for JSON). The legitimate exception is templating a known-constant string, never user input.
Untrusted-input boundaries
- Path traversal: validate to a known root.
open(os.path.join(BASE, user_filename))is a vulnerability ifuser_filenamecan be../../etc/passwd. Resolve to a real path and verify the result starts with the intended root (os.path.realpath(p).startswith(os.path.realpath(BASE) + os.sep)). Reject.., absolute paths, null bytes, alternate path separators, and Windows device names (CON,NUL,AUX). - SSRF: don't fetch arbitrary URLs from input. Server-side
requests.get(user_url)lets the attacker pivot into your VPC, hit metadata services (169.254.169.254), and read internal endpoints. If you must fetch user-supplied URLs, allowlist the scheme and host (or DNS-resolve and reject private/loopback/link-local ranges) and disable redirects (allow_redirects=False). - Deserialization: only on trusted sources, only with a safe loader.
pickle.loads,yaml.load(withoutSafeLoader),marshal.loads, Java'sObjectInputStream, PHP'sunserialize, .NETBinaryFormatter— all execute attacker-controlled code on untrusted input. Example: a Flask session cookie unpickled to read a user ID is full RCE. Usepickleonly between processes you control,yaml.safe_loadalways, and prefer JSON for cross-trust data. XML parsers default to resolving external entities (XXE) — disable explicitly (defusedxml,lxmlwithresolve_entities=False). - Validate at the boundary, then trust. Once data is past the validator (typed, ranged, allowlisted, length-capped), downstream code can stop re-checking. Mixing partial trust through the codebase is how the missed check ships. The validator is the single line you can audit; without it, every line below is the audit surface.
Secrets in transit, storage, and logs
- Logging secrets, tokens, PII, or auth headers is a leak. A log line containing
Authorization: Bearer <token>, a stack trace including a credentials object's__repr__, or an exception message containing the connection string ships to log aggregators, support tools, and screenshot inboxes. The pattern: anylog.*,print, orconsole.*call whose arguments include a request object, a session object, an auth header, a password field, or a credentials object — even via interpolation. Example:logger.error(f"login failed for {user}", extra={"request": request})includes the request body, which had a password. Fix by masking in middleware (Authorization → ***, password fields →***) and overriding__repr__on security-relevant types to redact. When you find this in code adjacent to your edit, surface it in your summary to the user. - Secrets in source files, version control, or built images are leaks the moment they land. The pattern: any string literal that looks like a credential — API keys, OAuth client secrets, database passwords, JWT signing secrets, private keys (
-----BEGIN), connection strings with embedded passwords, bearer tokens — assigned to a variable, passed as an argument, or written into a config file checked into git. Equally a problem: keys baked into Docker images, keys in built JS bundles, keys in CI logs. Fix by moving the value to the platform secret store