-
Notifications
You must be signed in to change notification settings - Fork 1
Security Notes
Output escaping is necessary but not sufficient for safe rendering. This page collects the things the per-context pages only hint at, and gives you the reasoning behind the rules.
It is tempting to escape data once, at the boundary where it enters the system, and store the escaped form in the database. Don't.
- The "escape" is context-dependent. HTML-escaping a value at input time makes it wrong for JSON, CSV, JavaScript, plain-text email, full-text search indexing, …
- Once stored as escaped text, you lose the original value and have to un-escape it for every non-HTML use, which inevitably leaks an unescaped path.
- Re-escaping the same value compounds.
Tom & JerrybecomesTom & JerrybecomesTom & Jerry(see Context-HTML → Double-escaping is not detected).
Rule: store original bytes. Escape them at the place — and in the context — they are about to be rendered.
htmlspecialchars() is not enough on its own. Each of the five contexts has different rules, and applying the wrong one can leave the output exploitable:
| Output location | Wrong escaper | What an attacker can do |
|---|---|---|
| Unquoted attribute | escHtml |
A literal space ends the attribute value — inject onmouseover=…. |
<script> body |
escHtml |
HTML entities are not decoded inside scripts — the payload survives intact. |
<style> body |
escHtml |
Same problem — CSS does not decode HTML entities. |
href / src
|
escHtml only |
escHtml doesn't stop javascript: schemes. URL encoding + scheme validation does. |
| Inline event handler |
escHtmlAttr only |
The attribute parser is happy but the handler's JS contents are not escaped. |
<style> expression(...)
|
escCss alone |
Legacy IE quirk — never let user data drive CSS function names; only values. |
The matrix in Quick Start → Pick the right context shows the correct pairing for each location.
The URL escaper does not — and cannot — prevent javascript:, data:, vbscript:, or other code-running schemes from being honoured by the browser. If the URL itself comes from user input, validate the scheme against a whitelist before escaping:
$url = $userSuppliedUrl;
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https', 'mailto'], true)) {
$url = '#';
}
echo '<a href="' . Esc::esc($url, 'attr') . '">link</a>';A common variant is to allow only relative URLs:
if ($scheme !== '' || str_starts_with(ltrim($url), '//')) {
$url = '#';
}str_starts_with and parse_url together cover the obvious attack shapes; explicit allow-listing covers the obscure ones.
Even with perfect escaping, a CSP header narrows the blast radius of any escaping bug you may still have. A minimal, strict policy:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
A permissive unsafe-inline policy undoes a lot of what escaping bought you. Treat CSP and escaping together, not as substitutes.
The escapers protect values embedded in a fixed code template. They do not protect against building executable code from user input — do not concatenate untrusted text into:
- A JavaScript identifier or keyword.
- A CSS selector.
- A SQL fragment.
- A shell command.
- A regular-expression pattern.
- An HTML tag name or attribute name.
Use placeholders / prepared APIs in each of those contexts: PDO bound parameters, preg_quote() for regex, escapeshellarg() for shell, etc.
Setting Content-Type: text/html; charset=utf-8 is necessary — without it the browser may sniff the document and interpret your "JSON" or "plain text" output as HTML, undoing every escape decision you made. But it is not sufficient: even with the header set correctly, a <script> tag inside an HTML body will still run.
Always combine:
- A correct, explicit
Content-Typeheader (charset=utf-8). -
X-Content-Type-Options: nosniffto disable sniffing. - Context-appropriate output escaping per this package.
Esc::esc($value, 'raw') and Esc::esc($value, '') return the input unchanged. They exist so callers can pass a context name through a template variable without special-casing "no escaping". The cost is that typos look like opting out:
Esc::esc($value, 'htm'); // throws InvalidContextException — correct
Esc::esc($value, 'raw'); // returns $value unchanged — by design
Esc::esc($value, ' raw '); // throws InvalidContextException
// — whitespace is not stripped before lookupIf you find yourself using 'raw' regularly, write the un-escaped flow as direct echo of $value — it's more obviously dangerous and harder to overlook in a code review.
The Esc facade caches Escaper instances in a private static array. Under long-running PHP that cache survives across requests. The escaper instances are stateless — they hold only the configured encoding — so this is normally fine and gives you the performance benefit.
If you want a hard reset at request boundaries (you almost certainly don't, but if you do):
\InitPHP\Escaper\Esc::reset();When in doubt about a specific edge case, defer to:
- OWASP XSS Prevention Cheat Sheet — the rules this package implements.
- OWASP Cross Site Scripting Prevention Cheat Sheet — DOM-side XSS, which output escaping alone cannot address.
- WHATWG HTML — § Restrictions on content models — the canonical HTML parsing rules.
-
RFC 3986 — Uniform Resource Identifier — what
escUrlimplements. -
CSS Syntax Module Level 3 — § Escapes — the
\HEXruleescCssfollows.
If you discover an escaping bug in this package, please report it through the disclosure process in SECURITY.md rather than opening a public issue.
Getting Started
Entry Points
Output Contexts
Reference
Production
Migration & Help