Print user data with Blade's {{ $value }}, which runs htmlspecialchars() with ENT_QUOTES, so < > & " and ' become entities in text and quoted attributes. Never use {!! !!} or HtmlString for it. URLs differ: javascript:alert(1) has nothing to escape, so link only values that parse as http: or https: URLs.
- Find every unescaped output: {!! !!} in resources/views, @php echo, new HtmlString(...) and other Htmlable values (Blade prints them raw), Str::markdown() output, and echo in plain PHP views.
- Use {{ $value }} for text and for quoted attribute values; Blade sends it through e(), which calls htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').
- Always quote attribute values in templates: escaping does not stop a space from ending an unquoted attribute.
- Before putting a URL in href or src, check its scheme: parse it with PHP 8.5's Uri\WhatWg\Url::parse(), the parser browsers use, and link it only when getScheme() is http or https.
- Pass data to JavaScript with {{ Js::from($data) }}, which escapes the JSON for an HTML context, or with a data- attribute, never by printing a raw value into a <script> block.
- When users may write formatted text, render Markdown with Str::markdown($text, ['html_input' => 'strip', 'allow_unsafe_links' => false]), or sanitise HTML with an allowlist sanitizer, and keep the result in {!! !!} only after that.
- Add a Content-Security-Policy that disallows inline script as a second layer, and regression tests that parse the rendered page (Dom\HTMLDocument in PHP 8.4 and later) and look for script elements, on* attributes and javascript: links.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
<img src="/avatar.svg" alt="{!! $name !!}" width="64" height="64">
<h1>{!! $name !!}</h1>
<p class="bio">{!! $bio !!}</p>With IMPL=vulnerable, 7 of 9 tests fail (5 exploit tests).
The fix: output encoding
<img src="/avatar.svg" alt="{{ $name }}" width="64" height="64">
<h1>{{ $name }}</h1>
<p class="bio">{{ $bio }}</p>With IMPL=fixed, 9 of 9 tests pass.
The fix: URL scheme allowlist
@if (\App\Support\WebUrl::isWebUrl($website))
<a href="{{ $website }}">Website</a>
@endifWith IMPL=fixed, 9 of 9 tests pass.
The fix: the isWebUrl() check
/**
* True for an absolute http: or https: URL. Escaping cannot make javascript:alert(1) safe: it
* has no characters to escape. Url::parse() is PHP 8.5's WHATWG URL parser, the algorithm
* browsers use, so it strips the same leading spaces and removes the same tabs and newlines
* a browser does before it reads the scheme.
*/
public static function isWebUrl(?string $value): bool
{
$url = $value === null || $value === '' ? null : Url::parse($value);
return $url !== null && in_array($url->getScheme(), ['http', 'https'], true);
}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
{{-- strip_tags() removes every tag, so the text can go out raw and entities in old profiles (é) still render. --}}
<img src="/avatar.svg" alt="{!! strip_tags($name) !!}" width="64" height="64">
<h1>{!! strip_tags($name) !!}</h1>
<p class="bio">{!! strip_tags($bio) !!}</p>With IMPL=wrong, 4 of 9 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: what a browser would run from the page must stay inert.
/** No script elements, no event-handler attributes, and only http(s) links. */
private function assertNothingExecutable(Page $page): void
{
$scripts = array_filter($page->elements(), fn ($e) => $e->localName === 'script');
$this->assertSame([], array_map(fn ($e) => $e->textContent, array_values($scripts)), 'script element');
$handlers = [];
foreach ($page->elements() as $element) {
foreach ($element->attributes as $attribute) {
if (str_starts_with($attribute->name, 'on')) {
$handlers[] = "{$element->localName} {$attribute->name}";
}
}
}
$this->assertSame([], $handlers, 'event-handler attribute');
foreach ($page->links() as $href) {
$this->assertContains(Page::urlScheme($href), ['', 'http', 'https'], 'link to a '.Page::urlScheme($href).": URL: $href");
}
}
public function test_exploit_script_element_in_the_bio_does_not_run(): void
{
$this->assertNothingExecutable($this->profile(['name' => 'Mallory', 'bio' => '<script>alert(document.domain)</script>']));
}
public function test_exploit_event_handler_attribute_in_the_bio_does_not_run(): void
{
$this->assertNothingExecutable($this->profile(['name' => 'Mallory', 'bio' => '<img src=x onerror=alert(document.domain)>']));
}
public function test_exploit_quote_in_the_name_cannot_break_out_of_the_alt_attribute(): void
{
$name = '" onerror="alert(document.domain)';
$page = $this->profile(['name' => $name]);
$this->assertNothingExecutable($page);
$this->assertSame($name, $this->avatarAlt($page));
}
public function test_exploit_javascript_website_is_not_linked(): void
{
$this->assertNothingExecutable($this->profile(['name' => 'Mallory', 'website' => 'javascript:alert(document.domain)']));
}
public function test_exploit_tab_inside_the_scheme_does_not_smuggle_a_javascript_url_through(): void
{
// Browsers delete tabs and newlines from a URL before parsing it.
$this->assertNothingExecutable($this->profile(['name' => 'Mallory', 'website' => "java\tscript:alert(document.domain)"]));
}- strip_tags() removes tags but not quotes, so '" onerror="alert(1)' printed raw into an attribute still adds an event handler. That is this card's wrong implementation; it also mangles legitimate text such as <dana@example.com>.
- Blocking names such as 'javascript:' fails: browsers remove tabs and newlines inside the URL (java\tscript:) and ignore leading spaces before reading the scheme, and data: and vbscript: exist too. Parse the URL and allowlist the scheme.
- {{ }} escapes for HTML, not for JavaScript or CSS: a value printed into an inline <script> needs Js::from(), and one in a style attribute should not go there at all.
- Blade prints Htmlable objects, such as HtmlString and the result of Str::markdown() wrapped in one, without escaping, because e() returns their toHtml().
- Str::markdown() allows raw HTML by default; the Laravel docs say to pass html_input => strip or escape and allow_unsafe_links => false for user input.
- Double escaping is not a security bug but shows &amp; to users; e() double-encodes by default, so escape once, in the template.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs xss/php-laravel does this for you in the pinned composer image, so it needs Docker).
- Against your own pages, store <script>alert(1)</script>, <img src=x onerror=alert(1)>, " onerror="alert(1) and java<TAB>script:alert(1) in each user-editable field, then view the pages and the page source: none may run or appear as markup.
- grep resources/views for {!! and @php echo, and the PHP code for HtmlString and Str::markdown(; neither Semgrep's PHP rules nor Psalm read Blade templates.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | php.lang.security.injection.echoed-request.echoed-request | No findings. It flags echo or print of $_GET-style superglobals in plain PHP (a probe was flagged); it does not read Blade, so {!! $bio !!} in vulnerable.blade.php is invisible to it.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/<id> app routes resources |
| Semgrep | r/php (every PHP rule in the Semgrep registry, 47 rules) | No findings on any implementation. The one Blade rule in the registry, laravel-blade-form-missing-csrf, checks forms for @csrf; no rule looks at {!! !!}.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes resources config bootstrap |
| Psalm | TaintedHtmlPsalm taint analysis with the Laravel plugin | No issues: Psalm analyses PHP files and does not compile Blade templates, so the unescaped output in the templates is out of its reach.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/ |
grep is the practical check here: search resources/views for {!! and @php echo. CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript.
| Component | Version |
|---|---|
| Language | PHP |
| Runtime | PHP 8.5.11 (CLI) |
| Framework | Laravel 13.34.0 (Blade) |
| laravel/framework | 13.34.0 |
| phpunit/phpunit | 13.4.0 |
| uri extension (bundled with PHP 8.5) | PHP 8.5.11 |
| dom extension, Dom\HTMLDocument (bundled with PHP 8.4 and later; used by the tests) | PHP 8.5.11 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/php-laravelfolder.
composer install --no-interaction --no-progress# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xmlWithout PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'composer install --no-interaction --no-progress'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=fixed \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=vulnerable \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=wrong \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xss/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
- Toolchain
- php 8.5.11 (CLI)
- laravel/framework 13.34.0
- phpunit/phpunit 13.4.0
- composer 2.10.3
- semgrep 1.180.0
- psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 7 of 9 tests fail (5 exploit tests); wrong: 4 of 9 tests fail (2 exploit tests)
- Test results
- fixed 9/9 pass; vulnerable 7/9 fail (5 exploit tests); wrong 4/9 fail (2 exploit tests), each test run in a container with --network=none after composer install
- Dependency freshness
- Laravel 13.35.0 (2026-10-06) is newer but was five days old and is not a security release, so the cards pin 13.34.0 (2026-09-29); laravel/prompts is held at 0.3.24 for the same reason (0.3.25 is from 2026-10-06), and PHPUnit 13.4.1 (2026-10-05) was six days old, so the cards use 13.4.0 (2026-10-02). Every other locked package is at least seven days old. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Cross Site Scripting Prevention Cheat Sheet
- Laravel 13.x: Blade, displaying data
- Laravel 13.x: Blade, displaying unescaped data
- Laravel 13.x: Blade, rendering JSON with Js::from()
- Laravel 13.x: Str::markdown() and Markdown security
- PHP manual: htmlspecialchars()
- PHP manual: Uri\WhatWg\Url (PHP 8.5)
- PHP manual: Dom\HTMLDocument (PHP 8.4)
- WHATWG URL Standard: URL parsing (tabs and newlines removed)
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Attack patterns: