---
title: 'Cross-Site Scripting'
source: 'https://academia.sh/en/courses/frontend-quality/cross-site-scripting'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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 &lt; 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.

```js
// escaping.mjs — escapes the same measurement note for four contexts
const BODY = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
const ATTRIBUTE = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };

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 &lt;mark&gt; &amp; "north" slope
Attribute value    12&#39;s &lt;mark&gt; &amp; &quot;north&quot; 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.

```js
// 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:registration@example.test',
  '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:registration@example.test             mailto:registration@example.test
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.
