Blog
Escape It on the Way Out
- laravel
- typescript
- react
- wordpress
- security
A while back I wrote that you should validate data at the door. I still believe that. But validation answers one question: is this data allowed in?
It doesn't answer the other one: is this data safe to print here?
A display name like <img src=x onerror=alert(1)> can be a perfectly valid string. It passes required|string|max:255 all day long. The problem only shows up when you drop it into HTML without thinking, and now it's not a name anymore. It's a script running in your admin's browser.
That's XSS, and the fix isn't at the door. It's on the way out.
Escape for where it's going
Escaping isn't one thing. It depends on the destination.
Text inside an HTML element, text inside an attribute, a URL in an href, a value inside a <script> tag. Each one has different characters that matter. htmlspecialchars is great for the first two and does nothing useful for a javascript: URL.
So the rule isn't "escape everything." It's "escape for the context you're printing into, at the moment you print it."
Blade already does the boring part
In Blade, {{ }} runs your value through htmlspecialchars for you. That's the default, and it's the right one.
<p>Welcome back, {{ $user->name }}</p>The thing to watch for is the other syntax:
{!! $comment->body !!}That prints raw HTML. Sometimes you need it. When you do, the content should have been sanitized first, or generated entirely by your own code. If you're grepping a codebase for XSS, {!! is the first thing I'd search.
Passing data into JavaScript is its own context. Don't hand-build it with string concatenation:
<script>
// Don't
const name = '{{ $user->name }}';
// Do
const user = @json($user->only(['id', 'name']));
</script>@json (or Js::from()) encodes for the script context, which is not the same as encoding for HTML. Also notice the only(). Dumping a whole model into the page is how fields you forgot about end up in view source.
React escapes text, not everything
React escapes anything you render as text in JSX. That's a big reason XSS feels rarer in React apps. Feels.
<p>{comment.body}</p> // escaped, fineThe escape hatches are where it goes wrong. The obvious one is named to scare you:
<div dangerouslySetInnerHTML={{ __html: comment.body }} />If that HTML came from a user, sanitize it first. DOMPurify is the usual pick:
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.body) }} />The less obvious one is URLs. Escaping a URL doesn't stop javascript:alert(1) from being a URL. Depending on your React version you might get a warning instead of a block, and I wouldn't bet a user's session on either. Check the scheme yourself:
export function safeHref(input: string): string {
try {
const url = new URL(input, window.location.origin);
return ["http:", "https:", "mailto:"].includes(url.protocol)
? url.toString()
: "#";
} catch {
return "#";
}
}<a href={safeHref(profile.website)}>Website</a>An allowlist of schemes. Not a blocklist of the scary ones. There's always one more scary one.
WordPress gives you the right functions. Use them.
WordPress has a function for each context, and the names are honest about it:
<h2><?php echo esc_html( $title ); ?></h2>
<input value="<?php echo esc_attr( $value ); ?>">
<a href="<?php echo esc_url( $link ); ?>">Read more</a>
<div><?php echo wp_kses_post( $content ); ?></div>esc_html for text, esc_attr for attributes, esc_url for links, and wp_kses_post when you actually want to allow some HTML (the same set allowed in post content).
The bug I see most in older themes and plugins is a bare echo $something sitting in a template. Usually it's a custom field or a query string value, and usually somebody assumed only admins could set it. Admins get phished too.
Markdown is HTML with extra steps
User-written Markdown feels safe because it doesn't look like HTML. But most Markdown renderers will pass raw HTML straight through unless you tell them not to.
In Laravel, Str::markdown() takes CommonMark options, so you can say so:
$html = Str::markdown($comment->body, [
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);In JavaScript land, render the Markdown and then run the result through a sanitizer before it touches the DOM. Same idea as the dangerouslySetInnerHTML example above.
Don't escape on the way in
It's tempting to "fix" this once by escaping when you save. Don't.
You'll end up with &amp; in your database after the second edit, broken text in your API responses and emails, and a false sense of safety in the one template that decodes it back. The data in your database should be the actual data. Escaping belongs to the output, because only the output knows its context.
Validate at the door. Store what's real. Escape on the way out.
Add a seatbelt
A Content Security Policy header won't fix an escaping bug, but it can turn a working exploit into a blocked request. Even a modest policy that refuses inline scripts and limits where scripts load from cuts off a lot of the damage.
Think of it as the thing that saves you from the one {!! you missed. Not a reason to keep writing them.
The short version
{{ }}in Blade, plain JSX in React, andesc_*in WordPress cover most of it.- Every raw output (
{!! !!},dangerouslySetInnerHTML, bareecho) should be either your own HTML or sanitized HTML. - URLs need a scheme allowlist, not just escaping.
- Data going into a
<script>tag gets JSON encoding, not HTML escaping. - Store real data. Escape at render time.
None of this is new. It's just easy to forget on the one template you wrote in a hurry, which is exactly the template someone will find.