Skip to content
academia.sh

Lesson 12 / 15

Preventing Cross-Site Scripting

Measuring how escaping narrows the result when text from a user is printed onto a page, escaping by context and double escaping; escaping does not change the owner.

Published Updated

In this lesson

  • Escapes user text at output time with htmlspecialchars() and measures the structural difference from raw output
  • Names the context explicitly, showing that the same function does the escaping in a text context and in an attribute context
  • Measures double escaping and justifies escaping in one place, at output time
Contents

In the Preventing SQL Injection lesson, the value was separated from the query text and the owner of the query became the server. Another place the value goes is the page: a user enters a name, and the application prints this name into HTML. The question for preventing cross-site scripting (XSS) is this: who determines the structure of the printed text, that is, which part of it counts as markup?

SE3. The texts escaped in this lesson are four harmless examples: Tom & Jerry, <b>bold</b>, "quote" and O'Brien. The aim is to measure whether markup characters come out as text; producing a running script or making a request from another site is not measured. The code that runs is on the defending side.

Escaping: Printing Text as Text

Escaping replaces characters that carry a special meaning in a markup language with counterparts that show the same character as text. In PHP, htmlspecialchars() does this: for &, <, >, the double quote and the single quote it writes &amp;, &lt;, &gt;, &quot; and &#039;. The file below defines two functions: one that produces raw output and one that escapes. The function that produces raw output exists only for contrast.

<?php
// output.php — raw and escaped output
declare(strict_types=1);

function escape(string $text): string {
    return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function greetingRaw(string $name): string {
    return '<p>Hello ' . $name . '</p>';
}

function greeting(string $name): string {
    return '<p>Hello ' . escape($name) . '</p>';
}

function inputField(string $name): string {
    return '<input value="' . escape($name) . '">';
}

The four example texts pass first through escape(), then through the two greeting functions. In raw output, <b>bold</b> mixes into the structure of the page; in escaped output, the count of < characters stays fixed.

<?php
// escape.php — the escaped characters
declare(strict_types=1);
require 'output.php';

foreach (['Tom & Jerry', '<b>bold</b>', '"quote"', "O'Brien"] as $text) {
    echo $text, ' → ', escape($text), "\n";
}
echo "\n";
foreach (['alice', '<b>bold</b>'] as $name) {
    echo mb_str_pad($name, 14), ' raw ', substr_count(greetingRaw($name), '<'),
        ' · escaped ', substr_count(greeting($name), '<'), " tag starts\n";
}
Tom & Jerry → Tom &amp; Jerry
<b>bold</b> → &lt;b&gt;bold&lt;/b&gt;
"quote" → &quot;quote&quot;
O'Brien → O&#039;Brien

alice          raw 2 · escaped 2 tag starts
<b>bold</b>    raw 4 · escaped 2 tag starts

All four texts came out as text: &, <, >, and the double and single quotes turned into their counterparts. The count shows the structure. For the name alice, raw and escaped output are the same, with two tag starts (<p> and </p>). For the name <b>bold</b>, raw output rises to four tag starts; escaped output stays at two. In raw output the client’s value added two tags to the structure of the page; in escaped output it could not.

Escaping by Context

The same function is not enough in every context; the context in which it is enough is determined by where the output goes. In a text node, < and & are enough. In a double-quoted attribute value, the quote also has to be escaped, because the value ends with the quote. The ENT_QUOTES flag covers both. inputField() uses this context.

<?php
// context.php — escaping inside an attribute
declare(strict_types=1);
require 'output.php';

echo inputField('"quote"'), "\n";
echo inputField("O'Brien"), "\n";
echo greeting("O'Brien"), "\n";
<input value="&quot;quote&quot;">
<input value="O&#039;Brien">
<p>Hello O&#039;Brien</p>

In the attribute context the double quote came out as &quot;, so it cannot end the value early. The single quote becomes &#039; in both contexts. The result is this: without naming the context, one does not say “I escaped”. What is enough for a text node is not carried unchanged into an unquoted attribute or another context (script content, an address); those contexts need separate escaping, and this lesson covers only the text node and the double-quoted attribute.

Party: Escaping Does Not Change the Owner

The party measurement of the same question is done with the party.php core. The decision order is client, third party, environment; if none could change it, the server.

<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}

The result is the greeting HTML, escaped and unescaped. The client’s deviation is a different name: <b>bold</b>.

<?php
// xss.php — the party of the printed HTML
declare(strict_types=1);
require 'party.php';
require 'output.php';

partyReport('raw output', fn(array $d) => greetingRaw($d['client'] ?? 'alice'), ['client' => '<b>bold</b>']);
partyReport('escaped output', fn(array $d) => greeting($d['client'] ?? 'alice'), ['client' => '<b>bold</b>']);
raw output → client
    client changed
escaped output → client
    client changed

The owner of both outputs is the client. Escaping did not change this owner, because the printed text still depends on the name the client sent: when the name changes, the HTML changes. What changed is the kind of this change. In raw output the client could also change the structure of the page; in escaped output it can change only the content of a text node. Escaping does not move the result to the server, it narrows the field in the client’s hands; the owner stays the same.

The Bounding Measurement

Escaping should be done in one place. If a value is escaped when it is stored and escaped again when it is printed, the characters are transformed twice: & first becomes &amp;, then that & becomes &amp; again. The measurement escapes the same text once and twice.

<?php
// double.php — double escaping
declare(strict_types=1);
require 'output.php';

$text = 'Tom & Jerry';
$stored = $text;
echo 'once   ', escape($stored), "\n";
echo 'twice  ', escape(escape($stored)), "\n";
once   Tom &amp; Jerry
twice  Tom &amp;amp; Jerry

The second escaping escaped the & inside the &amp; written by the first, and the text Tom &amp; Jerry became visible in the browser. Double escaping is not a security hole, it is a visible bug; but “fixing” it by turning escaping off or skipping it in some place brings raw output back. The right fix is to put escaping in one place: the value stays raw in storage and is escaped once, at the moment it is printed, according to the context it is printed into.

Summary

  • The measured class of this lesson is the client: even in the escaped greeting, the name the client sent changed the printed HTML.
  • Escaping does not change the owner, it narrows: <b>bold</b> increased the tag count in raw output and did not in escaped output; the structure of the page did not grow with the client’s value.
  • The same function is enough in a text node and in a double-quoted attribute, and ENT_QUOTES escapes both single and double quotes; the context is chosen explicitly.
  • Double escaping breaks the text: Tom &amp;amp; Jerry. Escaping is done only at output time, in one place; the raw value stays in storage.

Next Step

Escaping bounded the text printed onto the page. A request that comes from a page, however, can also come from another site: who determines whether the user really made the request? The Request Forgery Protection lesson measures this with a token.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close