A preview card has four authors
A team chat app adds link previews. A member pastes a URL, a worker fetches the page and extracts its title, description and image, and the whole channel sees a card. Besides the app's own developers, three parties now write into the system: the member who pasted the URL, the owner of the fetched page, and any other site open in a member's browser.
Their input is harmless while it stays data. It turns harmful when it reaches something that interprets it (a SQL parser, a shell, a browser) or that acts with privileges the sender lacks (the server's place on the internal network, a member's signed-in browser). Injection, XSS, CSRF and SSRF are that one mistake in four places, each fixed where the data meets the interpreter or the privilege. Filtering at the door helps less than it seems: a web application firewall catches known attack shapes but cannot know which interpreter a value will reach (Edge protection: WAF, DDoS & defence in depth).
When a search box writes SQL
Members can search old previews by title. If the code pastes the search text into the SQL string, the database parses one piece of text and cannot tell where the developer's query ends and the member's text begins, so a quote in the search can turn the rest into SQL. That is SQL injection. Passing the pasted URL to a command-line tool through a shell is the same mistake, command injection.
The root fix is a parameterized query, or prepared statement: the query goes with placeholders, the search text travels separately, and the database binds it as a value after parsing, so it cannot change the query's structure. Escaping quotes by hand fails on the case nobody thought of.
It has limits. Placeholders stand only for values: a table name, a sort column or a sort direction cannot be a parameter, so map the member's choice to one of a fixed list of names in code. An ORM's raw-query escape hatch fed by string formatting, or a stored procedure that assembles SQL from its arguments, is concatenation again. For commands, skip the shell and pass the arguments as a list; a value starting with a dash can still read as an option, which -- prevents where the tool supports it.
A title that runs in someone else's browser
The page's title is written by whoever owns the page. If the client inserts it into the card as HTML, any markup in it becomes part of the chat app's page, and a script there runs as the chat app: it can read the open channel and send requests with the member's session. That is cross-site scripting (XSS), and because the preview is stored, it reaches everyone who scrolls past.
What fixes it is contextual output encoding: where untrusted text is written into a page, convert the characters that mean something in that spot, so the browser shows them instead of acting on them. Element text and quoted attribute values each need their own encoding, and HTML encoding does not make untrusted text safe inside an event-handler attribute, a script or a style, so keep it out of those. A URL in a link needs its scheme checked against http and https, because encoding does not stop a link with another scheme from running script when clicked. Encode at output, not on the way in: the same title also feeds a JSON API and push notifications, which need different treatment.
Frameworks do much of this: React escapes text it renders as a child, but not what goes through its raw-HTML escape hatch (dangerouslySetInnerHTML), and whether it refuses a script URL in href depends on the version, so check the scheme yourself. Where formatting must survive, use a maintained HTML sanitizer with an allowlist of tags, attributes and URL schemes, never a regular expression. A preview needs none: store plain text, with a length cap.
A Content-Security-Policy header is the second layer. A strict one lets a script run only if it carries a random nonce issued with that response or matches a hash listed in the policy, so a script injected into the page is refused. It limits the damage of an encoding mistake without replacing encoding, since it does not stop every form of XSS, and a policy that allows any inline script ('unsafe-inline' with no nonce or hash) gives most of it up.
A request the member never meant to send
Members stay signed in with a session cookie, and the browser attaches a site's cookies to requests to that site even when a page on another site triggers them, say with a form it submits. The server sees a valid session and acts: posts a message, changes the account email. That is cross-site request forgery (CSRF). The other site cannot read the response, and does not need to. The cookie proves who is calling (Authentication: passwords, MFA & identity providers), not that they meant to; cookies as credentials are covered in Sessions vs tokens: JWT, refresh & revocation.
- SameSite cookies.
Strictwithholds the cookie from every request another site starts.Laxstill sends it when another site links to the app (a top-level GET navigation) and withholds it from other cross-site requests such as a form POST, so keep state changes off GET. Same site means the same scheme and registrable domain, not the same origin: an https page on a sibling subdomain is same-site and is not stopped. Set the attribute explicitly rather than rely on a browser default. - An anti-CSRF token. The server ties a random value to the session, the app's own pages send it back in a field or header, and state changes without it are refused. Another site's scripts cannot read the app's pages to learn it; an XSS flaw on the app's own origin can, one reason XSS undoes CSRF defences.
- Check the request's origin. Browsers send an
Originheader on cross-site POSTs, and current onesSec-Fetch-Site. Refuse a cookie-authenticated state change whoseOriginis not the app's own or whoseSec-Fetch-Siteis notsame-origin, or that carries neither header.
A bearer token in the Authorization header is never added by the browser on its own, so an API that accepts only that is not exposed this way; a token page scripts can read is exposed to XSS instead. CORS on its own does not stop CSRF: it decides whether another site's script may read a response, and a plain form is sent without a preflight. A member opening another workspace's preview by changing an id is a different flaw, a missing permission check: Authorization: roles, relationships & policies.
The fetcher can reach what the member cannot
The unfurl worker runs inside the company network and fetches whatever URL is pasted. A member cannot reach the internal admin service or the cloud's instance metadata endpoint, which the major clouds serve on the link-local address 169.254.169.254 and which can hand out the machine's temporary credentials. The worker can reach both, and would show what it read in a preview. That is server-side request forgery (SSRF): untrusted input picks the destination of a request the server sends, and the request reaches what the outsider cannot, through the server's place on the network or the trust and credentials it carries.
Checking the URL's text is not enough. A public-looking hostname can resolve to a private address, the answer can change between check and fetch, and a public page can redirect to an internal one. So the check applies to the address the worker actually connects to, on every hop:
- The worker accepts only http and https on ports 80 and 443, resolves the hostname, and checks every returned address against ranges it must never reach: loopback, the unspecified address
0.0.0.0, private (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16), link-local, their IPv6 counterparts and its own internal ranges. - It connects to the address it checked, not to the name again, while keeping the name for the Host header, TLS SNI and the certificate check, and reads at most a fixed number of bytes within a fixed time.
- On a redirect it does not follow automatically: it repeats both steps for the new URL, and gives up after three hops.
The network is the second layer: give the worker an egress path to the internet through a NAT gateway or proxy and no route to internal services, a layout drawn in Network topology: VPCs, NAT, VPN & bastions.
The worker fetches only addresses it has checked, has no route to the internal admin service, and stores plain text. The API searches with bound parameters; the client encodes the text when it draws the card.
Twelve nodes, every check on an edge
The diagram above fits inside the 12 nodes of the Link Preview Unfurler Challenge, whose brief asks for the address checks and the sanitizing by name. Practise making them visible: a worker behind a queue whose edges say what it refuses after resolving, that each redirect is checked again, and how long it waits (Timeout), with previews stored as plain text and cached by URL (Caching layers).