Lesson 14 / 27
Cross-Site Scripting
The problem of text entering a page and running as code; how the reflected, stored, and DOM-based types arise, context-sensitive output escaping, and a scheme allowlist for link targets.
Contents
The first two topics in this course treated quality along two measurable axes. The Performance topic tied how quickly the page becomes usable to a budget; the Accessibility topic, in the Accessibility Testing lesson, showed how far automated auditing can go and where manual verification is required. Both axes aimed to improve the user’s relationship with the interface.
The third axis runs in the opposite direction: protecting the user from the interface itself. In the North Slope Measurement Station application, every measurement record accepts a free-text measurement note. This note is written to the database, appears on other users’ screens, and shows up in exported reports. Even when the person writing the note has no bad intent, how that text enters the page is a security decision. This lesson builds the layer that keeps that text data.
The Moment Text Becomes Code
A browser interprets text according to context while parsing a document. The distinction
built in the Web Fundamentals and HTML course is decisive here: the < character is the
start of a tag to the parser, not an ordinary letter. When an application places a
user-supplied string into the document as-is, it never tells the parser “there is data
here”; the parser reads the characters it sees by its own rules and treats any markup
inside the string as structure.
Cross-site scripting is the name for this boundary slip: code the application never
wrote runs inside a document belonging to some origin, with that origin’s authority. The
word authority is critical. Because the running code shares an origin with the document, it
can read that origin’s local storage, reach cookies that lack the HttpOnly flag, and send
requests with the user’s session. The same-origin policy does not treat this code as
foreign, because the code lives inside the document.
The root of the problem is not a parser bug. It is that the application places data somewhere it can be interpreted as code without applying a transform that blocks that interpretation.
Three Paths to the Same Result
The same outcome is reached by three different paths, and where the defense belongs depends on which path is in play.
In the reflected form, the input comes from the request itself and is written back into the same response. A search field on the measurement list is written into the query string, and the server places that value into the page while producing a “search term: …” heading. The damage occurs in a single response, and the victim has to open a crafted address.
In the stored form, the input is saved once and served again on every later read. The measurement note is exactly this class: the user who saves the note and the user who is affected by it are different people, and the affected person just browses normally. Because it persists, this type has the widest reach.
In the DOM-based form, the data never reaches the server at all. Client code reads the address fragment, a record in local storage, or the content of a message event, and writes it into the document. No trace is left in server logs; the defense can only be built into client code. The storage APIs introduced in the Browser and the Web Platform course are therefore not trusted sources for this purpose: whatever writes to local storage is also code running on the same page.
Where the Defense Belongs: Output, Not Input
The intuitive fix is to filter input: drop characters that look dangerous at save time. This approach fails for two reasons. First, which character is dangerous depends on where the input will be written, and that is not known at save time; the same note can appear in an HTML body, in an attribute, and in an exported address. Second, filtering corrupts the data: “the 12-inch anemometer read < 3 m/s” is a valid measurement note and must be stored exactly as written.
The right place is the output. The raw text stays in the data store; it is transformed according to the rules of its destination context at the moment it is written into the document. This transform is called escaping: replacing meaningful characters with representations that lose their meaning in that context while keeping their appearance.
Escaping is not context-independent. Four separate contexts have four separate rules, and applying one context’s rule in another context provides no protection.
Four Contexts, Four Rules
The file below escapes the same measurement note separately for four contexts. The note is a harmless marker string carrying a meaningful character for each of the four contexts.
// escaping.mjs — escapes the same measurement note for four contexts const BODY = { '&': '&', '<': '<', '>': '>' }; const ATTRIBUTE = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; function bodyEscape(text) { return String(text).replace(/[&<>]/g, (c) => BODY[c]); } function attributeEscape(text) { return String(text).replace(/[&<>"']/g, (c) => ATTRIBUTE[c]); } function urlEscape(text) { return encodeURIComponent(String(text)); } function scriptEscape(text) { return JSON.stringify(String(text)) .replace(/</g, '\\u003c') .replace(/>/g, '\\u003e') .replace(/&/g, '\\u0026'); } const note = `12's <mark> & "north" slope`; const contexts = [ ['HTML body', bodyEscape(note)], ['Attribute value', attributeEscape(note)], ['URL component', urlEscape(note)], ['Script literal', scriptEscape(note)], ]; console.log('input:', note); for (const [name, result] of contexts) { console.log(name.padEnd(18), result); }
$ node escaping.mjs input: 12's <mark> & "north" slope HTML body 12's <mark> & "north" slope Attribute value 12's <mark> & "north" slope URL component 12's%20%3Cmark%3E%20%26%20%22north%22%20slope Script literal "12's \u003cmark\u003e \u0026 \"north\" slope"
The four lines produce four different outputs, and each difference corresponds to a rule.
In the body context, converting only three characters is enough; quote marks carry no meaning inside a text node. In the attribute context, quotes are also converted, because a quote marks the boundary of an attribute value. Mixing up these two rules is a common defect: writing a value escaped with the body rule into a quoted attribute lets the value close the attribute early.
The rule in the URL context is entirely different. The & character is not converted to
an HTML entity but to percent-encoding, because the parser at work here is not an HTML
parser but a URL resolver. In the script context, the string delimiter and backslash
characters are converted; turning < into < keeps the text inside the string from
ending the enclosing script element early.
This nesting produces a rule of its own: if a value passes through more than one context, escaping must also be applied in a nested order. For an address written inside a script, URL encoding comes first and script escaping second; reversing the order lets the second transform corrupt the characters the first one produced.
Where Escaping Falls Short: Scheme Validation
Some attributes interpret their value not as text but as an address. Even if a link’s target has been escaped, if it points to an executable scheme, escaping changes nothing: none of the characters are meaningful to HTML. Code that places user-supplied links from the measurement note into the page therefore checks the scheme separately.
// scheme.mjs — validates a link target's scheme against an allowlist const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'mailto:']); function linkTarget(raw, base) { let url; try { url = new URL(raw, base); } catch { return { allowed: false, reason: 'unresolvable' }; } if (!ALLOWED_SCHEMES.has(url.protocol)) { return { allowed: false, reason: `scheme not in list: ${url.protocol}` }; } return { allowed: true, target: url.href }; } const base = 'https://measurements.example.test/notes/'; const candidates = [ '/station/north-slope', 'https://data.example.test/report.csv', 'mailto:[email protected]', 'data-scheme:text/html;base64,SSBhbSB0ZXh0', 'measurements://north', '::broken::', ]; for (const candidate of candidates) { const result = linkTarget(candidate, base); console.log(String(result.allowed).padEnd(6), candidate.padEnd(44), result.target ?? result.reason); }
$ node scheme.mjs true /station/north-slope https://measurements.example.test/station/north-slope true https://data.example.test/report.csv https://data.example.test/report.csv true mailto:[email protected] mailto:[email protected] false data-scheme:text/html;base64,SSBhbSB0ZXh0 scheme not in list: data-scheme: false measurements://north scheme not in list: measurements: true ::broken:: https://measurements.example.test/notes/::broken::
The last line shows how the rule has to be written. ::broken:: is not a valid absolute
address, but once resolved against a base address it counts as a relative path and inherits
the page’s own scheme. The check is applied to the resolved address, not the raw
string, and the allowlist counts what is accepted rather than what is rejected. Counting
what is rejected silently lets through every scheme not yet on the list.
Not Escaping by Hand
The functions above were written to show the rule; they are not expected to be called by hand at every write site in production code. This is the most important security property of the declarative rendering model introduced in the Component-Based Interface Development course: values placed into an expression slot are treated as text nodes by default and escaped according to their context. Escaping is the default; adding raw markup is an explicitly requested escape hatch.
What these hatches have in common is that their names or documentation announce the
danger: a property that assigns raw HTML, a method that parses and inserts markup, helpers
that build a component from a string. The audit rule is simple: the places these hatches
are used must be countable, and each one needs a written answer to “where does the data
reaching this point come from.” If writing text is enough, textContent is used;
innerHTML is not a necessity but a decision.
If a user genuinely needs to enter formatted text, the problem stops being an escaping problem and becomes a sanitization problem: a layer is needed that parses the markup and keeps only the elements, attributes, and schemes on an allowlist. Sanitization is noticeably harder than escaping, because it is sensitive to differences in parsing behavior. As a rule, a sanitizer is not written by hand; a maintained implementation is used, and its output is defended together with this lesson’s next layer, policy enforcement.
Summary
- Cross-site scripting arises when data is placed, without a transform, somewhere it can be interpreted as code; the running code inherits the authority of the document’s origin.
- There are three paths of origin: reflected, stored, and DOM-based. In the last one, the data never reaches the server, so the defense can only be built into client code.
- The defense is not filtering at input but context-appropriate escaping at output; the rules for the HTML body, attribute, URL, and script contexts do not substitute for one another.
- Escaping is not enough for attributes that interpret an address; the scheme of the resolved address is checked against an allowlist.
- Declarative rendering makes escaping the default; escape hatches that insert raw markup must be countable and justified.
Next Step
Escaping depends on every write site the application itself writes behaving correctly. A single missed spot invalidates the whole defense, and assuming this will not go unnoticed in a large interface is not realistic. The next lesson places a second layer beneath escaping: a policy that tells the browser which origin’s scripts may run, and that makes this decision independent of the document itself — the content security policy.
To keep your progress and take notes, Log in
My notes
Log in to take notes.