Cross-site Scripting

Concept

A vulnerability where a script somebody else planted runs on our page, with our page's privileges.

Why it exists

Showing text a user wrote looks simple. Take the characters you received and put them on the page. There is an assumption underneath that: that what the user sent is text and nothing else.

That assumption broke in Pagedeck's comments. Somebody typed a fragment of script into the comment box instead of a sentence, and nothing appeared on screen, which is part of why it went unnoticed. But in the browser of everyone who opened that document, the fragment ran, and the value proving their login state went out to an external address.

The server was fine. No intrusion, the database untouched. The damage happened in the browsers of the other people who opened that document, which is not a place most teams are watching.

The cause is that a browser cannot decide for itself whether what arrived is text to display or code to execute. Unless whoever builds the page marks it as text, the browser reads it as code. The place where that marking is missing is the vulnerability.

How it works

It happens when a value a user supplied lands on the page and the browser interprets it as code. Three routes get distinguished.

  1. From a stored value. A script sits inside something saved on the server, like a comment or a document title, and it runs in the browser of everyone who opens that screen. Planted once it keeps spreading, which makes this the most damaging form: one comment reaches every reader of a document for as long as it stays up.
  2. From the address. Where a screen prints a value taken from the URL, an attacker builds a URL carrying a script in that value and sends it to someone. It takes a click, so victims arrive one at a time — but the address is on our domain, so the recipient has no reason to be suspicious, which is the whole point of routing the attack through us.
  3. From inside the browser. Front-end code takes a value and inserts it without the server ever seeing it. Nothing appears in server logs, so discovery comes late and usually arrives as a user report.

All three carry the same weight of risk for the same reason: the script runs inside our page, so it has our page's privileges. It can read and send the value proving a login, draw a fake password field on the screen, or issue requests as that user and delete their documents. The user believes they are using our service, so nothing prompts suspicion — the address bar shows the right domain and the certificate is ours.

Defence comes in four layers.

  1. Mark values as text on output. This is the fundamental one and the rest are support. Modern front-end frameworks apply that treatment by default, so the risky places are only where code inserts a markup string directly. Searching for the name of that function produces the list of places to review, and that list is usually short enough to read in one sitting.
  2. Filter on input. Keep this as a support measure. The same value gets used on several screens, so filtering at the entrance alone leaves gaps that nobody is tracking.
  3. Restrict which sources may execute scripts. Telling the browser to run only what came from our own domain means an external script will not run even where a marking was missed. It is a net under the first layer rather than a replacement for it.
  4. Keep login values out of reach of scripts. Store them so the browser sends them but code cannot read them, and a script that does run cannot take them. That turns a serious incident into a smaller one rather than preventing it.

Where it shows up

The places to review are everywhere a user-supplied value appears on screen. Comments and document titles come to mind first, but the easy ones to miss outnumber them: profile names, uploaded file names, team names, and the screen that echoes a search term back in the results. What actually caught Pagedeck was a file name — nobody had thought of an upload as user-authored text.

The second is code review, where a colleague reads the change before it merges. Whether a function that inserts a raw markup string has appeared becomes a checklist item. Some teams ban that function outright and approve exceptions individually, which moves the decision from memory into tooling.

The third is security assessment. It has been on OWASP Top 10, the list of the most frequently exploited web vulnerabilities, for years, and it is a baseline target in a penetration test where outside specialists actually knock.

The fourth is any editor that allows formatting. Bold text and links have to work, so you cannot turn everything into plain characters. There, the approach is an allowlist of permitted tags and attributes with everything else stripped. Build it as a blocklist and whatever you forgot gets through; the list of dangerous constructs grows and the list of needed ones does not.

The fifth is email and notification copy. User names and document titles go inside those, so the same problem exists off-screen, in a place no front-end framework is protecting.

How it sounds at work

"This is inserting user input directly. That's an XSS."

The output-side escaping is missing.

"File names need escaping too. That's where it got us."

Narrowing the review to comments and titles is not enough.

"Let's go allowlist on the editor. A blocklist always leaks."

Deciding the approach where formatting has to be permitted.

At Pagedeck, Theo keeps this as a fixed checklist item. When a new screen is built, he counts the places a user value lands, and marks separately any of them that permit formatting. The marked ones get looked at again every time the feature changes.

What people get wrong

Believing a safe server is enough is common. No intrusion, data intact, so it reads that way. But the damage here happens in other users' browsers, not on our server. The server logs hold only the trace of one comment being saved, and what actually left happens on the machine of whoever opened that document. Watching server-side signals alone means receiving nothing at all while the incident runs its course.

The second is that filtering on input closes it. Guarding one entrance sounds simpler. But the same value gets used across screens. A document title appears in the list, goes into notification emails, and lands in the browser tab title, and the safe form differs in each place. Converted at input, it renders wrong in some places and remains dangerous in others. Handling it at each point of output, the way that output needs, is why that layer is the fundamental one.

Easily confused with

TermWhat it exploitsHow it differs
CSRFAn existing logged-in sessionBorrows the credentials a browser attaches automatically. XSS runs code inside our page, which gives it a much wider range of actions

Where to go next

Last updated 2026-09-16