Skip to main content
ANVISoftware Solutions
Lesson 7 of 14Intermediate18 min

Preventing Cross-Site Scripting

By the end of this lesson

Encode output for its destination context.

A browser receiving a page does not distinguish between markup you wrote and characters that arrived from a database field. It parses the whole document with one parser. If a value from a delivery note lands in the page at a position where the parser is expecting markup, the parser reads it as markup.

That is cross-site scripting: untrusted data rendered into a page in a way that lets the browser interpret it as code rather than content. Script running inside your page runs with your page's privileges. It can read what the page can read, act on behalf of the signed-in employee, and send requests that look entirely ordinary from the server's side. That is why this one undermines other controls instead of sitting alongside them.

The fix is not a filter on the way in. It is encoding on the way out, matched to the exact place the value is going. Modern frameworks do most of it for you, which makes the small number of places they do not the important thing to know.

Three shapes this takes. The fix is the same in each case — the shapes matter because they change where you look:

  • Stored — the value is saved and rendered later to whoever views it: a delivery note, an employee display name, a supplier's address. The most serious of the three, because every viewer of that record is affected and the value sits there until somebody notices
  • Reflected — the value arrives in a request and is written straight back into the response, typically a search term echoed above the results, or a message in an error page
  • DOM-based — the server's rendering is never involved. Client-side script reads a value from the URL, from storage or from a message, and writes it into the page. Server-side encoding cannot help, because the server did not produce the markup

Encoding has to match the destination. The same value needs different treatment depending on where in the document it lands, and this is the part most often missed:

HTML body text
Between tags, as in a paragraph or a table cell. The characters that begin markup are replaced with their entity equivalents so the parser reads them as text. This is the case framework auto-escaping handles, and it covers the large majority of your output.
HTML attribute value
Inside a tag, as in a title or a data attribute. Two requirements, not one: encode for attribute context, and always quote the attribute. An unquoted value is terminated by a space, which lets a value add further attributes rather than merely filling the one you intended.
Inside a script block
A value written into JavaScript source needs JavaScript string encoding, and HTML encoding does not provide it. The reliable answer is to avoid the context: serialise the data to JSON, put it in a data attribute or a non-executable script tag, and have your script read it from there.
A URL, or part of one
Percent-encode the value for the position it occupies, and when the whole URL comes from input, check its scheme as well. A link built from a supplied string can carry a scheme that executes rather than navigates, so accept http and https and reject everything else.
CSS
A value placed into a style attribute or a stylesheet is another context with its own rules. Avoid it. Map input to a fixed set of class names you control instead.
OrderDetails.cshtml — what escapes automatically, and what does not
C#
@model OrderDetailsViewModel

@* Encoded automatically. Any value written with @ is HTML-encoded by Razor. *@
<p>Note from customer: @Model.DeliveryNote</p>

@* Encoded for the attribute, AND the attribute is quoted. Both matter. *@
<span title="@Model.DeliveryNote">Order @Model.Reference</span>

@* NOT encoded. Html.Raw writes the string into the document as markup.
   Correct only for markup your own code produced. *@
<div class="banner">@Html.Raw(Model.SystemGeneratedBannerHtml)</div>

@* Handing data to script: put it in an attribute, which Razor encodes,
   and let the script read and parse it from there. *@
<div id="order-data" data-order="@Model.OrderJson"></div>

<p id="note"></p>
  • Razor encodes any value written with @ before it reaches the page. That default is why most .cshtml files never have this problem, and it means your review attention goes to the exceptions rather than to every line.
  • Quoting the attribute is not a formatting preference. Attribute encoding assumes the value sits inside quotes; without them, a value containing a space is no longer confined to the attribute you wrote.
  • Html.Raw switches the default off for that expression and writes the string as markup. Use it for markup your own code generated, never for anything that came from a request, a database column or a third-party feed. Blazor's MarkupString does the same thing, and React's dangerouslySetInnerHTML is the same idea in another framework — that name is a warning, and it is an accurate one.
  • The data attribute is a deliberate choice over writing JSON into a script block. An attribute is a context Razor knows how to encode, so the JSON travels through a checked path. Inside a script block the HTML parser is still looking for a closing tag, and getting that right needs an encoder configured for the job.
  • Nothing here inspects the note's contents. It may contain angle brackets, quotes or anything else an employee typed, and it will be displayed as exactly that, which is also what the reader wanted.
orderDetails.js — the two APIs side by side
JavaScript
const container = document.querySelector("#note");
const order = JSON.parse(document.querySelector("#order-data").dataset.order);

// FLAWED SHAPE, commented out on purpose: innerHTML asks the browser to
// parse the string as markup.
// container.innerHTML = order.deliveryNote;

// Correct: textContent sets text. The browser inserts characters and
// never parses them as markup.
container.textContent = order.deliveryNote;

// Building an element: set properties rather than assembling a markup string.
const link = document.createElement("a");
link.textContent = "Track this delivery";

// A URL from data you did not write is its own context. Parse it and
// check the scheme instead of inspecting the string by hand.
const tracking = new URL(order.trackingUrl, window.location.origin);

if (tracking.protocol === "https:") {
  link.href = tracking.href;
  container.after(link);
}
  • innerHTML hands the string to the HTML parser. Assigning a value you did not author to it is the most common route to this problem in front-end code. It is commented out here because the point is to recognise the line in a review, not to run it.
  • textContent sets the text of the element. A note containing angle brackets displays angle brackets. No encoding call is needed, because the value never enters a parsing context.
  • Creating an element and setting its properties keeps every value in a context the browser treats as data. Assembling a markup string and inserting it puts you back where you started, however carefully the string is built.
  • Parsing the tracking URL and testing protocol is a structural check. Looking for particular prefixes in the raw string is the deny-list approach from the validation lesson, and it fails the same way.
  • React, Vue and Angular all escape interpolated values in their templates by default, and each has an opt-out — dangerouslySetInnerHTML, v-html, bypassSecurityTrustHtml. Those names, plus innerHTML and Html.Raw, are the short list worth searching your codebase for.

A Content Security Policy is a response header telling the browser which sources of script, style and other resources the page may use. A restrictive policy can stop injected inline script from running even when a mistake let it into the document, which is worth having.

It is a second layer, not a replacement for the first. Policies are also easy to weaken by accident: an allowance for inline script, or a broad host allowance added to make a third-party widget work, can remove most of the benefit while the header still looks present in the response. A policy has to be written for your application and tested against it, and the practical route in is report-only mode first, then enforcement once the reports are clean.

Two things worth setting alongside it. Mark session cookies HttpOnly so page script cannot read them, which limits what injected script can take. And keep your own scripts in files rather than inline, because that is what makes a strict policy achievable in the first place.

The order of work is the point here. Encode at every output, because that removes the flaw. Add a policy because the encoding will occasionally be got wrong, and you would rather that mistake be contained than exploited.

Summary

  • Cross-site scripting is untrusted data rendered into a page where the browser interprets it as markup or script
  • Encoding must match the destination: HTML body, HTML attribute, script, URL and CSS each need different treatment
  • Frameworks escape by default; the risk concentrates in the APIs that bypass that, such as innerHTML and Html.Raw
  • Prefer APIs that never parse — textContent and element properties — over encoding before an API that does
  • A Content Security Policy limits the damage when encoding is got wrong, and does not remove the need for it

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Fix the orders dashboard

A dashboard builds each row of a table by assembling a markup string and assigning it to innerHTML. Two values come from the database: the delivery note an employee typed, and a tracking URL supplied by the courier's feed.

Rewrite the row construction safely, and say what each change prevents.

Show solution

Build the elements instead of the string. Create the row and cells with createElement, set the note with textContent, and append. That removes the parsing context entirely: the browser is told to insert text, so nothing in the note can be read as markup no matter what an employee typed into it.

The tracking URL needs a different treatment, because a link's destination is its own context. Parse it with the URL constructor and set href only when the scheme is https. That protects against a value in the courier feed that is not the kind of URL you assumed — and the feed is a third party, so the assumption was never yours to make.

Two things worth noticing about the result. The note is stored exactly as typed, so an export or a PDF still has the real text — the fix is at the point of rendering, where it belongs. And there is no encoding call anywhere in the rewritten version, because choosing an API that does not parse is stronger than remembering to encode before one that does.

JavaScript
function buildRow(order) {
  const row = document.createElement("tr");

  const noteCell = document.createElement("td");
  noteCell.textContent = order.deliveryNote;
  row.append(noteCell);

  const linkCell = document.createElement("td");
  const tracking = new URL(order.trackingUrl, window.location.origin);

  if (tracking.protocol === "https:") {
    const link = document.createElement("a");
    link.href = tracking.href;
    link.textContent = "Track";
    linkCell.append(link);
  } else {
    linkCell.textContent = "No tracking available";
  }

  row.append(linkCell);
  return row;
}

Think about it

Why encode at output rather than input?

Encoding at input sounds more efficient. You do it once, at the boundary, and everything downstream is safe.

Give two reasons it does not work, using a delivery note that is displayed on a web page, exported to CSV, and included in an email to the customer.

Show solution

First, one value has several destinations and each needs different treatment. HTML encoding is right for the web page and wrong for the CSV, where the characters that matter are commas, quotes and line breaks, and wrong again for the email depending on whether it is HTML or plain text. Encoding once, at input, means encoding for one destination and getting the others wrong.

Second, the boundary code does not know where the value will go. The CSV export was added a year after the input handler, by someone else. Any decision about how to render a value has to be made where the rendering happens, because that is the only place the destination is known.

There is a third reason worth mentioning: encoding at input changes the stored data. The delivery note in your database is no longer what the employee typed, so searching it fails to match, a reader of the raw record sees entity references, and re-rendering it can double-encode. You have lost the original for no gain.

Encode at output, per destination, every time. It sounds like more work and is mostly done for you by whatever renders your pages.

Saved in this browser only.