Your Admin Panel Renders What Strangers Typed: An XSS We Shipped, and the Fence Around It Now
Our contact form asks for a website. Anyone can submit it; there's no account, no verification, and no rate limit high enough to make the field trustworthy. Later, somebody signed in to the admin panel opens the inquiry and sees that website as a clickable link.
That's the whole setup. Anonymous public text, rendered on an authenticated page, in a place where a person is expected to click. It's the classic stored XSS shape and it was in our own application.
Escaping is not the protection you think it is here
Filament escapes text content, and so does Blade. If somebody submits <script>alert(1)</script> as their company name, it renders as visible characters and nothing runs. Most of the form is covered by that and needs no thought.
Then there's this:
TextEntry::make('website')->url(fn ($record) => $record->website)
->url() puts whatever it's handed straight into the href. Escaping does the right thing to the string and the string is still javascript:alert(document.cookie), which is a perfectly valid URL that runs code when clicked. HTML escaping protects against breaking out of an attribute. It has nothing to say about a value that's dangerous while staying inside one.
So a submitted javascript: URL would have executed in the admin's own session, on a page they had every reason to trust, at a moment the attacker chose by writing the text and waiting.
We found it in review before anyone used it against us, and it's pinned by tests now. Publishing it is more useful than being quiet about it, because the mistake wasn't exotic: it was one Filament method used the obvious way on a field that looked like a URL.
Two layers, because one is a single point of failure
The inbound rule refuses dangerous schemes before anything is stored:
$scheme = parse_url(trim((string) $value), PHP_URL_SCHEME);
if (is_string($scheme) && ! in_array(strtolower($scheme), ['http', 'https'], true)) {
$fail('The website has to be a normal web address.');
}
We deliberately did not use Laravel's url rule for this. People type example.com, the url rule rejects it, and on a public contact form that turns a real lead into a validation error over a missing https://. This rule only refuses the schemes that are dangerous to render and lets everything else through.
The second layer runs on the way back out, when the value is about to become a link:
public function safeWebsiteUrl(): ?string
{
$value = trim((string) $this->website);
// Whitespace and control characters are how `java\tscript:` sneaks past
// a scheme check, and no legitimate URL contains them.
if ($value === '' || preg_match('/[\x00-\x20\x7f]/', $value)) {
return null;
}
$scheme = parse_url($value, PHP_URL_SCHEME);
if ($scheme === null || $scheme === false) {
$value = 'https://'.ltrim($value, '/');
} elseif (! in_array(strtolower($scheme), ['http', 'https'], true)) {
return null;
}
return filter_var($value, FILTER_VALIDATE_URL) === false ? null : $value;
}
Two layers instead of one because the inbound rule only protects rows written after it existed, and because a validation rule is one refactor away from being skipped by a seeder, an import, or a second controller somebody adds next year. The renderer is the layer that runs every time.
The control character check has to come first
The ordering in that method isn't stylistic. Browsers strip certain characters out of a URL before deciding what scheme it has, which means java\tscript:alert(1) — with a real tab in the middle — is not a javascript: URL as far as parse_url() is concerned, and is one as far as the browser is concerned. Same with a newline. Same with a leading space, which shifts the whole string past a naive prefix check.
Check for control characters first and the trick has nowhere to go. No legitimate URL contains them, so there's nothing to lose by refusing outright.
The test file pins the ones we know about:
'javascript' => ['javascript:alert(1)'],
'tab smuggled' => ["java\tscript:alert(1)"],
'newline smuggled' => ["java\nscript:alert(1)"],
'leading space' => [' javascript:alert(1)'],
'data uri' => ['data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=='],
'vbscript' => ['vbscript:msgbox(1)'],
data: deserves its own mention. A data:text/html URL opens a page that the browser treats as its own document, which is enough to build a convincing login prompt on a domain the admin trusts. It isn't as dramatic as javascript:, and it belongs on the same list.
The rules we work to now
Both forms on this site share one trait for the outbound guard and one for the inbound rule. Copying either would mean two places to remember the next scheme trick, and one of them would be missed.
Beyond that, three habits:
Treat every submitted field as hostile, including the boring ones. Not because the text is likely to be an attack — almost none of it is — but because "this field is just a company name" is a claim about a stranger's behavior, and you don't get to make claims about that.
Never add ->html(), ->markdown(), or {!! !!} to anything carrying submitted content. All three exist for content you wrote. Reaching for one to make a submitted message render its line breaks is how this class of bug gets reintroduced by somebody being helpful.
Write the test with the payload in it. A test that asserts href="javascript: never appears in the rendered admin page is a sentence anyone can read, and it fails loudly the day somebody swaps the safe accessor back for the raw column.
Where to look in your own application
Search for the places a value becomes an attribute instead of text. In Filament that's ->url(), and any use of ->html() or ->markdown(). In Blade it's {!! !!}, plus every href, src, action, formaction, style, and on* attribute built from a variable. Then ask, for each one, who was able to write that value.
If the answer is "anyone on the internet", it needs a guard between the column and the page. Ours is about twenty lines and has been sitting there quietly ever since.
We do this kind of review on Laravel applications — custom software and inherited web applications both. If you want somebody to go through what your admin renders and where it came from, tell us what you're running.
Frequently asked questions
Does Laravel protect me from XSS automatically?
Blade's {{ }} escapes what it prints, which covers most output. It does not help when a value is placed into an href, a src, an inline style, or an event attribute — those are contexts where a correctly escaped string can still be dangerous. And {!! !!} turns the protection off entirely.
Why not use Laravel's url validation rule on the field?
Because people type example.com without a scheme, and the url rule rejects that. On a public contact form, turning away a valid lead over a missing https:// is a real cost. We validate the scheme instead — anything that is not http or https is refused — and add the scheme ourselves when there is none.
Is a stored XSS in an admin panel actually serious?
It runs with whatever the signed-in admin can do, in their session, on a page they trust. On a small team the account that reads inquiries is often the account that can do everything. The attacker also picks when to fire it, because they control the text and you click it when it suits them.