XSS: three contexts, three escapes
Naming "stored vs reflected vs DOM-based" describes where the bug lives. Naming the context the user data lands in tells you how to fix it. The three contexts:
- HTML body: escape
< > & " '. Modern templating defaults handle this. - HTML attribute: same escapes, plus quote the attribute value. Unquoted attributes are vulnerable to event handlers like
onmouseover=.... - JavaScript context: HTML-escaping is not enough. Use JSON encoding:
const data = JSON.parse('<%- JSON.stringify(value) -%>'). Even then, beware of</script>sequences in the value.
DOM XSS specifically: any sink that interprets a string as code or HTML (innerHTML,
document.write, eval, setTimeout(string),
location = string) reading from any source the attacker controls
(location.hash, postMessage, fetch from a third party).
Audit by grepping for the sinks; modern frameworks limit them but do not eliminate them.
CSP: nonces, strict-dynamic, and the trap of unsafe-inline
A Content-Security-Policy header tells the browser where scripts may load from. The modern recommended shape is nonce-based with strict-dynamic:
Content-Security-Policy: default-src 'self';
script-src 'nonce-XYZ' 'strict-dynamic' 'unsafe-inline' https:;
object-src 'none';
base-uri 'none';
frame-ancestors 'self';
How it works: legitimate inline scripts carry the matching nonce attribute.
strict-dynamic tells the browser that any script trusted via nonce can in turn load
other scripts dynamically (this is what makes React or similar work without listing every CDN).
'unsafe-inline' is listed for older browsers that ignore strict-dynamic -
modern browsers ignore 'unsafe-inline' when a nonce is present.
Common pitfalls: forgetting object-src 'none' (allows Flash to bypass), forgetting
base-uri (lets an attacker change the base URL), and using report-only mode forever
without ever enforcing.
CSRF: defense in depth, layer by layer
- SameSite cookies. The default is
Laxin modern browsers, which blocks the cookie on most cross-site POSTs.Strictblocks more at a UX cost; reserve for the most sensitive sessions. - Anti-CSRF tokens. A per-session secret in a hidden form field, checked on the server. The classic defense. Still useful as a second layer.
- Origin / Referer check. Cheap, server-side, last-line defense. Modern browsers always send Origin on cross-origin POSTs.
- For JSON APIs: requiring a custom header (X-Requested-With) or a non-simple content type triggers a CORS preflight that the attacker's origin cannot satisfy. Often sufficient on its own.
SSRF and the cloud metadata endpoint
Server-Side Request Forgery becomes a privilege escalation when the server runs in a cloud
environment. AWS, GCP, and Azure all expose an instance metadata endpoint at
http://169.254.169.254/ that returns the instance's identity, including temporary
credentials.
An SSRF that lets the attacker pick an arbitrary URL goes from "interesting" to "domain admin" in one request. Mitigations:
- IMDSv2 (AWS): requires a session token in a header, which a vanilla SSRF cannot send.
- Egress filter on link-local and private ranges (169.254.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) at the network or library layer.
- Allow-list of permitted destinations in the application code that makes the outbound request.
Note that DNS rebinding can bypass naive allow-lists by returning a public IP at the first lookup and a private IP at the second. Resolve once and pass the IP, not the hostname.
SQL injection past simple strings
Parameterized queries fix string injection. They do not fix every SQLi class:
- Identifier injection: parameters cannot be used for table or column names. Build an allow-list of permitted identifiers and look up against it.
- Second-order injection: data is escaped on the way in, stored, then re-used unescaped later. The parameterized query at storage time does not help if the read site concatenates.
- Stored procedures are not magic. They protect only if they themselves use parameters; concatenated SQL inside a stored procedure is just as injectable.
- NoSQL injection: MongoDB query objects accept operators. Code like
{ user: req.body.user, pass: req.body.pass }lets the attacker send{$ne: null}as the password and authenticate.
Deserialization: the only safe rule
Many languages let you serialize objects to a string and reconstruct them later. Java
ObjectInputStream, Python pickle, PHP unserialize, .NET
BinaryFormatter, Ruby Marshal. Reconstructing arbitrary objects
sometimes triggers code execution via "gadget chains" the attacker assembles from classes that
happen to be on the classpath.
Real defense: do not deserialize untrusted data. Use a data format that cannot describe objects (JSON, MessagePack, protobuf) for anything that crosses a trust boundary. If you must deserialize, restrict to a known type and reject anything else.
The fix that is not
WAF rules block known gadget chain signatures. New gadgets are discovered constantly. Treat the WAF as a tripwire that buys you time, not as the actual defense.