-
Identify the class, then apply its canonical fix. Do not invent ad-hoc escaping — each class has one correct construct:
| Class | Root cause | Canonical fix (do this) | Never (the band-aid) |
|---|
| SQLi | String-built query | Parameterized query / bound params; ORM with bindings | Escaping quotes, blocklisting '/;/-- |
| Command injection | Shell-interpreted string | No shell: exec with args array; allowlist binaries/flags | escapeshellarg-then-concat into sh -c |
| XSS | Untrusted data in HTML | Framework auto-escaping + context-aware encoding + CSP; sanitize HTML with DOMPurify | Regex strip of <script>, innerHTML of user data |
| CSRF | Ambient cookie auth | SameSite=Lax/Strict + synchronizer token on state-changers | Checking Referer only |
| SSRF | User-controlled fetch URL | Allowlist dest hosts; block link-local/metadata/private ranges; pin resolved IP | Blocklisting localhost/127.0.0.1 strings |
| IDOR / broken access | authN ≠ authZ | Authorize every object by owner/tenant, server-side, on the resolved row | Hiding the ID in the UI; UUIDs as "security" |
| Insecure deserialization | Untrusted bytes → objects | Don't deserialize untrusted data; use data-only formats (JSON) + schema validate | pickle.loads, yaml.load, Java native, unserialize() on input |
-
SQLi — parameterize, never concatenate. Pass user data as bound parameters so it is never parsed as SQL. Identifiers (table/column names) can't be parameters — allowlist them against a fixed set.
db.execute("SELECT * FROM users WHERE email = %s", (email,))
ALLOWED = {"name", "created_at"}
if sort not in ALLOWED: raise ValueError("bad sort column")
-
Command injection — drop the shell, pass an args array. The shell is the vuln; shell=True / sh -c / os.system interpret metacharacters. Pass argv as a list so the OS execs the binary directly with no parsing.
subprocess.run(["convert", path, "out.png"], shell=False, check=True)
If a value must be a flag, allowlist it (if mode not in {"fast","hq"}: reject). Never build a flag string from user input.
-
XSS — encode for the output context, add CSP, sanitize HTML only with a vetted lib. Escaping for HTML body ≠ for an attribute ≠ for JS ≠ for a URL. Let the template engine auto-escape (Jinja autoescape, React {} text nodes) and don't defeat it (| safe, dangerouslySetInnerHTML, v-html, innerHTML). If you must render user HTML, run it through DOMPurify first:
el.textContent = userInput;
el.innerHTML = DOMPurify.sanitize(userHtml);
Add a strict CSP as defense-in-depth: Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'none' — no unsafe-inline/unsafe-eval. CSP is a second wall, not the fix.
-
CSRF — SameSite cookies + synchronizer token. Set session cookies SameSite=Lax (or Strict), Secure, HttpOnly. For every state-changing request (POST/PUT/PATCH/DELETE), require a per-session CSRF token (double-submit cookie or server-stored), compared with a constant-time check. Pure token-auth APIs (Authorization: Bearer, no cookies) are not CSRF-prone — don't bolt tokens onto those.
-
SSRF — allowlist egress, block internal ranges, fetch the pinned IP. If the destination is user-controlled, default-deny:
- Allowlist the exact hostnames/domains you intend to reach; reject everything else.
- Resolve the host, then reject
127.0.0.0/8, ::1, 10/8, 172.16/12, 192.168/16, 169.254.0.0/16 (link-local → cloud metadata 169.254.169.254), fc00::/7, and 0.0.0.0.
- Resolve once, validate that IP, and connect to that IP (pass it explicitly / pin it) to kill DNS-rebinding TOCTOU.
- Disable redirects, or re-validate the target on each hop. Never follow a redirect to an unvalidated host.
-
IDOR / broken access — authorize every object by owner, server-side. Authentication tells you who; you still must check they own this row. Scope the query or assert ownership on the resolved object — never trust an ID from the request as proof of access.
order = Order.get(id=request.params["id"], owner_id=current_user.id)
if order is None: raise NotFound()
This is the single-instance patch. If you're (re)designing roles/policies/multi-tenancy, that's design-authorization-model.
-
Insecure deserialization — never deserialize untrusted bytes. Object-deserializers (pickle, yaml.load, Java ObjectInputStream, PHP unserialize, .NET BinaryFormatter) can execute code or instantiate arbitrary types. Accept only data formats and validate against a schema:
data = json.loads(body)
yaml.safe_load(body)
Payload.model_validate(data)
If signed objects are unavoidable, verify an HMAC over the bytes before deserializing.
-
Sweep the class and write the regression test. Grep for every sibling of the fixed pattern (grep -rn "shell=True", f"SELECT, innerHTML =, pickle.loads, yaml.load(, dangerouslySetInnerHTML). Then write a test that sends the actual exploit payload and asserts it no longer triggers (Verify).
A fix is proven only when an automated test reproduces the original exploit and asserts it's now inert: