Cross-Site Scripting
Escape user input where you print it: htmlspecialchars for HTML, JSON for JavaScript, and a Content-Security-Policy behind both.
echo htmlspecialchars($value, ENT_QUOTES, 'UTF-8');Cross-site scripting, or XSS, is when input you print ends up running as code in someone else's browser. A comment containing <script> is not dangerous in your database. It becomes dangerous the moment you print it into a page, because the browser cannot tell your HTML from theirs.
There are two shapes. Stored XSS is saved first and served to everyone who views the page. Reflected XSS is bounced straight back out of the request, typically a search term printed above the results.
Escape at the point of output
Do not clean input on the way in. You cannot know yet where it will be printed, and sanitising early destroys legitimate text. Escape on the way out, every time, with htmlspecialchars(). It converts <, >, & and quotes into entities, so they are displayed instead of parsed.
Context decides the escape
HTML text and quoted attributes both want htmlspecialchars(), and the attribute must be wrapped in quotes for that to help. Inside a <script> block the rules are JavaScript's, so pass data through json_encode() instead. A URL in an href needs its scheme checked, because javascript: is a valid URL.
A Content-Security-Policy header is the second line. It tells the browser which scripts it is allowed to run, so an escape you forgot does not automatically become an executed script. It is a safety net, not a replacement for escaping.
Example
When to use it
- Printing a comment, review or profile bio that anyone on the internet can submit.
- Showing the search term back to the visitor above a results list.
- Passing server data into a JavaScript variable without letting it close the script tag.
- Rendering a user-supplied link in an href or an img src.
More examples
One escaping helper for the whole project
A two-letter helper is short enough that you will actually use it in every template, which is the whole point. ENT_SUBSTITUTE replaces invalid UTF-8 byte sequences with a replacement character instead of returning an empty string, which is how a badly encoded field silently blanks a page. <?= is shorthand for <?php echo, and it is always available; the two are the same thing.
<?php
declare(strict_types=1);
function e(?string $value): string
{
// Since PHP 8.1 the default flags are already
// ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, so the arguments below
// are explicit rather than different. Older code passed ENT_QUOTES
// by hand because the old default, ENT_COMPAT, left ' alone.
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<h1>Results for "<?= e($_GET['q'] ?? '') ?>"</h1>
<ul>
<?php foreach ($comments as $comment): ?>
<li title="<?= e($comment['author']) ?>"><?= e($comment['body']) ?></li>
<?php endforeach; ?>
</ul>A Content-Security-Policy with a nonce
A fresh random nonce per response lets your own inline scripts run while injected ones, which cannot guess it, are blocked. Set the header before any output. Adding 'unsafe-inline' to script-src switches this protection off completely, so if you need it to get a page working, treat that as a task rather than a solution.
<?php
declare(strict_types=1);
$nonce = base64_encode(random_bytes(16));
header(
"Content-Security-Policy: "
. "default-src 'self'; "
. "script-src 'self' 'nonce-$nonce'; "
. "object-src 'none'; "
. "base-uri 'self'; "
. "frame-ancestors 'none'"
);
?>
<script nonce="<?= $nonce ?>">
document.title = 'Loaded';
</script>Escaping a URL you did not write
Use this anywhere a user supplies a link: a profile website, a redirect target, an avatar source. The check is on the scheme, because escaping alone does nothing here. Returning '#' rather than throwing keeps a broken link from taking down the page it appears on.
<?php
declare(strict_types=1);
function safeUrl(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
// javascript:alert(1) is a perfectly valid URL, and htmlspecialchars
// leaves it untouched because it contains nothing to escape.
if (!in_array($scheme, ['http', 'https'], true)) {
return '#';
}
return htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
}
echo '<a href="' . safeUrl($profile['website']) . '">Website</a>';
Discussion