Skip to content

Application security

Injection, XSS, CSRF, SSRF, the failures that turn a bug into a breach.

01

Injection and parameterised queries

beginner

Injection happens when input is concatenated into something that will be parsed as a command: SQL, a shell line, an LDAP filter, an XPath expression, a template. The input ends the data and starts being instruction, and the parser cannot tell the difference because by the time it sees the string, there is no difference to see.

Escaping is the intuitive fix and the wrong one, because it requires getting every context right forever, and the contexts nest. A value escaped for SQL and then interpolated into a LIKE pattern, or into a JSON document, or into a shell argument, needs different escaping at each layer, and one missed call site is enough. The defence has to remove the possibility rather than handle each case.

Parameterised queries do that by sending the statement and the values on separate channels. The database parses the statement first, then binds values into slots, so a value can never be parsed as syntax no matter what it contains. This is not escaping done well; it is a different mechanism, and it is why the advice is absolute rather than contextual.

Identifiers are the exception that catches experienced people. Table names, column names and sort directions cannot be bound as parameters, because they are part of the statement rather than values in it. A dynamic ORDER BY built from user input therefore needs an allowlist mapping permitted inputs to known identifiers, and nothing else will do.

An ORM is not automatic protection. Most expose a raw query escape hatch, and that is where injection reappears, usually in the reporting endpoint written under time pressure. The same applies beyond SQL: a shell command assembled by concatenation, a template rendered from user-supplied source, a NoSQL query built from a request body that arrives as an object rather than a string.

Defence in depth belongs here even though parameterisation is complete. Least-privilege database accounts so an injection cannot read tables the feature never needed, no dynamic SQL where a static query would do, and monitoring for queries whose shape does not match anything the application should emit.

Check yourself

What does a least-privilege database account add when queries are already parameterised?

the intuitive waythe only wayinjectionsafe by constructionthe exceptionUser inputmay contain anythingConcatenatedone string, parsed asoneParameterisedstatement, thenvaluesData becomes syntaxthe parser cannottellBound into a slotnever reparsedIdentifierscannot be bound:allowlist them
ClientServiceData storeExternalDashed = asynchronousConsidered, not chosen
Two channels: the parser sees the statement before it sees any value
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

02

Cross-site scripting

intermediate

Cross-site scripting is injection into a page rather than into a query. Attacker-controlled text is rendered as markup, so their script runs with your origin's privileges: it can read the DOM, call your API as the user, and exfiltrate anything the session can reach. It is not a defacement bug; it is arbitrary code execution in your users' browsers. The three shapes are worth naming. Stored XSS is persisted and served to everyone who views it, which is the worst case. Reflected XSS comes back in a response from something in the request, and needs a victim to follow a link. DOM-based XSS never touches the server at all: client-side code takes something from the URL and writes it into the page, so a server-side filter never sees it.

Modern frameworks close most of it. React escapes interpolated values by default, which means the common path is safe without anyone thinking about it. What remains are the deliberate bypasses, and they are few enough to audit by name: dangerouslySetInnerHTML and its equivalents, values used as a href or src where a javascript: URL executes on click, and anything written into a script, style or event-handler context where HTML escaping is not the right escaping.

A Content Security Policy is the layer that limits damage when something slips through. Refusing inline script and restricting which origins may execute turns a working injection into a blocked one, and modern policies use a nonce or a hash rather than an origin allowlist, because an allowlisted CDN hosting a vulnerable library is a hole in the policy. Report-only mode exists so a policy can be measured before it is enforced.

Cookies deserve a mention here because they decide what XSS costs. A session cookie marked HttpOnly cannot be read by script, so an injection cannot simply steal it, and the attacker is reduced to acting through the page while it is open. That does not make XSS acceptable; it makes the difference between a stolen session that outlives the visit and one that does not.

Sanitisation is a last resort with one correct implementation: a maintained library parsing to a tree and allowlisting elements and attributes, never a regular expression. If a feature needs to accept rich text, that is the tool. If it does not, escaping everything and rendering as text is both simpler and safer.

Check yourself

Why do modern CSPs prefer a nonce or hash over an origin allowlist?

rendered as textrendered as textclient writes itblocked at executionand it cannot steal thesessionStoredserved to everyoneReflectedneeds a followed linkDOM-basedserver never sees itFramework escapingthe default path issafeDeliberate bypassinnerHTML, href,script contextCSP with a nonceinline script refusedHttpOnly cookiesession cannot beread
ClientServiceEdge / CDNData storeDashed = asynchronousConsidered, not chosen
Three shapes, and the two layers that decide what an injection costs
Evidence · 1 source
  1. 01
    MDN: Content Security Policy

    That a nonce or hash based policy is preferred over an origin allowlist, and that Content-Security-Policy-Report-Only allows a policy to be measured before enforcement.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

03

CSRF and SameSite

intermediate

Cross-site request forgery abuses ambient authority: the browser attaches cookies to requests for your origin regardless of which page caused them. Another site submits a form to yours, the session cookie rides along, and the request arrives authenticated even though the user never intended to make it. The attacker cannot read the response, which is why CSRF is about actions rather than about theft.

SameSite is the mechanism that fixed most of it. Lax, now the default in current browsers, means the cookie is not sent on cross-site requests except top-level navigations that are safe methods, which kills the cross-site form post. Strict withholds it even on ordinary navigation, so a user following a link from elsewhere arrives logged out, which is usually too blunt. None sends it everywhere and requires the Secure attribute.

A synchroniser token is the explicit defence and remains worth having for sensitive actions: a random value tied to the session, rendered into the form, and required on submission. The attacker's page cannot read it, because the same-origin policy prevents reading your pages, so it cannot construct a valid request. The double-submit variant compares a cookie against a header and avoids server-side state, at the cost of being weaker if any subdomain can set cookies.

Whether you need any of this follows directly from how the session travels. Cookies are ambient authority and need protection. A bearer token in an Authorization header that your own script sets is not attached by the browser to a third-party form post, so classic CSRF does not apply. Mirror that token into a cookie for convenience and the exposure returns immediately.

There are two adjacent cases worth remembering. Simple cross-origin requests do not trigger a preflight, so a form post with a plain content type reaches your endpoint before CORS has any say, which is why CORS is not a CSRF defence. And a GET that changes state is exposed to a bare image tag, which is one of several reasons safe methods should be safe.

The practical setup is short: SameSite=Lax plus Secure and HttpOnly on session cookies, tokens on state-changing forms, no state changes behind GET, and CORS configured as a separate concern that answers a different question.

Check yourself

Why is a permissive CORS policy not the cause of a CSRF vulnerability?

rides alongcannot be setthe mechanism that fixed itfor sensitive actionsreaches the endpoint firstAttacker's pagesubmits a form toyour originSession cookieattachedautomaticallyAuth headeronly your script setsitSameSite=Laxwithheld oncross-site postsSynchroniser tokenunreadable acrossoriginsCORS is not ita simple post needsno preflight
ClientEdge / CDNServiceExternalDashed = asynchronousConsidered, not chosen
The browser attaches the cookie; it does not attach a header
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

04

SSRF and the metadata endpoint

advanced

Server-side request forgery is making your server fetch a URL an attacker chose. Any feature that accepts a URL is a candidate, and there are more of them than anyone expects: webhook registration, image import from a link, link preview generation, a PDF renderer fetching stylesheets, an avatar imported from a third party, an XML parser resolving an external entity, a health check whose target is configurable. Each was added for a good reason by somebody who was not thinking about this.

The damage comes from where your server sits rather than from what it fetches. It is inside the network, past the firewall, holding an identity, and a request it makes on an attacker's behalf inherits all of that. So the reachable surface is internal services with no authentication because they were never exposed, admin panels bound to localhost, a Redis or Elasticsearch instance listening on a private address, and on a cloud instance the metadata endpoint, which hands out role credentials to anything that asks from that machine because being on the machine is the authentication.

That metadata endpoint deserves naming precisely because the defence is available and often unenabled. The first version of the AWS service answers a plain unauthenticated GET, which is exactly what an SSRF gives an attacker. Version two requires a PUT to obtain a token first, and sets a hop limit so a response cannot be relayed onward, which defeats the ordinary case. Requiring version two is a single instance setting and it closes the specific path the 2019 Capital One breach took.

Blocklisting is the intuitive defence and it does not hold, for a reason worth understanding rather than memorising: you validate a string, and the connection is made to whatever the name resolves to at the moment it is made. A hostname the attacker controls can resolve to a private address. It can resolve to a public address when you check and a private one when you connect, which is a DNS rebind. A response can be a redirect to an internal address after validation has already passed. And the address space is larger than the obvious ranges, with IPv6 forms, the many spellings of the loopback address, and decimal or octal encodings of the same host.

What works is checking the address rather than the name, at the point the socket is opened, on every hop of every redirect, against an allowlist where one is possible and a deny list of the whole private space where it is not. Better still, do not make the check the developer's job at all: send outbound fetches through an egress proxy that permits only known destination hosts, so a new URL-fetching feature is covered by the network rather than by whoever wrote it having heard of the rule. The proxy is the only one of these that stays correct as the codebase grows.

Two habits complete it. Treat the response as hostile too, because an attacker who cannot see the response can still learn from it: distinguishable timings and error messages turn a blind SSRF into a port scanner, so failures should look the same whatever went wrong. And give the fetching component its own network egress rules and its own credentials, so that even a successful request reaches as little as possible, which is the same bulkhead argument applied to the network rather than to a thread pool.

Check yourself

Why is the cloud metadata endpoint the classic SSRF target?

the intuitive fixbypassedbypassedwhat workswhat keeps workingrole credentialsAttacker's URLwebhook, import,previewValidate the stringhost not in a privaterangeValidate at connectthe resolved address,every hopEgress proxyknown hosts onlyDNS points inwardor rebinds after thecheck302 to internalafter validationpassedMetadata endpointIMDSv2 requires atoken
ClientServiceData storeExternalDashed = asynchronousConsidered, not chosen
The string was validated; the socket connects to whatever resolves
Evidence · 2 sources
  1. 01
    AWS: use IMDSv2

    That version two of the metadata service requires a PUT to obtain a session token and applies a hop limit, which defeats the ordinary SSRF path that version one permits.

  2. 02
    Krebs on Security, What we can learn from the Capital One hack (2019)

    That the 2019 breach used a server-side request forgery, through a misconfigured web application firewall, to reach the instance metadata service and obtain role credentials with more permission than the task needed.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

05

Dependencies and supply chain

advanced

Most of what ships is code nobody on your team wrote, and the threat model follows from one fact: a postinstall script in any transitive dependency runs with your build's privileges. That is a direct path to CI secrets, deploy credentials and the artefact you are about to publish, and it does not require anyone to import the package or call a function in it.

That reframes the question. It is not whether a library is any good; it is whether you trust its author, and everyone its author trusts, with execution on your build machine. A tree of a thousand packages is a thousand maintainer accounts, several of which will be handed to a new maintainer this year without anyone downstream noticing.

Lockfiles are necessary and not sufficient. They pin what you resolved, with integrity hashes so the bytes cannot change under a pinned version, which is genuinely valuable. They do not help when a legitimate maintainer publishes a malicious version you then upgrade to, and they do nothing about a typosquatted name that sat one keystroke away from the real one when it was first added.

The controls that pay for a small team are unglamorous. Fewer dependencies, treated as a cost rather than a convenience. Automated updates so you are never far behind, because staying current is also how you get the fix. CI tokens scoped to exactly what the job needs and expiring quickly. Builds that do not run arbitrary install scripts where the ecosystem allows that to be disabled. And a review step for new dependencies that asks who maintains this rather than does it work.

Beyond that, provenance is where the ecosystem is going. A software bill of materials records what actually went into a build, so the question after a disclosure is a lookup rather than an investigation. Signed provenance attestations, from npm and from Sigstore, let a consumer verify that a package was built from the repository it claims and by the pipeline it claims, which closes the gap that lockfiles cannot. The lesson from the incidents that made the news, the event-stream package, the ua-parser-js compromise, the left-pad removal, is that all three were dependency-of-a-dependency problems. Nobody chose them and everybody shipped them, which is the reason the count itself is the number worth managing.

Check yourself

What does a signed provenance attestation let a consumer verify?

invisible in the importany one of themyour build's privilegeswhat a lockfile cannot giveOne dependencyfor one helperfunction1,000 packages1,000 maintaineraccountspostinstallruns without beingimportedCI secretsdeploy credentials,signing keysSigned provenancebuilt from theclaimed repo
ClientData storeServiceExternalDashed = asynchronousConsidered, not chosen
A postinstall script anywhere in the tree runs with your build's privileges
Evidence · 1 source
  1. 01
    npm: generating provenance statements

    That signed provenance attestations, built on Sigstore, let a consumer verify the source repository and build pipeline a package came from.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.