CWE-80: Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS) - PHP
Overview
CWE-80 is the tag-injection case of XSS: <, > and & reach the page without
being converted to entities, so an attacker's <script> or
<img onerror=...> is parsed as markup rather than shown as text.
The fix is the output encoding described on
CWE-79 - PHP, which is where the encoders live:
htmlspecialchars() with ENT_QUOTES | ENT_HTML5, json_encode() for script
context, urlencode()/rawurlencode() for URLs, Blade and Twig auto-escaping,
and HTML Purifier for content that must keep real markup. This page does not
repeat them.
What belongs here is the failure mode that produces CWE-80 findings specifically - trying to remove the dangerous tags instead of encoding the characters that make them tags.
Common Vulnerable Patterns
Stripping tags with a blocklist
<?php
// VULNERABLE - removing what looks dangerous, rather than encoding
function clean(string $input): string {
$out = preg_replace('#<script>#i', '', $input);
$out = str_replace('javascript:', '', $out);
return $out;
}
echo '<div>' . clean($_POST['comment']) . '</div>';
Why this is vulnerable: every one of these survives it.
<img src=x onerror=alert(1)> no <script> tag at all
<svg onload=alert(1)> same, different element
<scr<script>ipt>alert(1)</script> the inner match is removed, leaving <script>
javascript:alert(1) the literal "javascript:" never appears
The set of ways to execute script is open-ended and grows with the HTML and
browser specifications, so a blocklist is guessing against a list it cannot
enumerate. The str_replace() is worse than useless: removing javascript:
once turns javajavascript:script: into javascript:.
strip_tags() treated as an XSS filter
<?php
// VULNERABLE - strip_tags does not make attributes safe
echo '<div title="' . strip_tags($_GET['note']) . '">note</div>';
Why this is vulnerable: strip_tags() removes elements, not the characters
that break out of an attribute. A value of " onmouseover="alert(1) contains
no tags at all, so it passes through untouched and closes the title
attribute. The function is a content filter, not an encoder, and the PHP manual
does not present it as a security control.
Escaping opt-outs applied to filtered input
Why this is vulnerable: Blade's {!! !!} and Twig's |raw do not
sanitize - they assert the value is already trusted markup and switch off the
escaping that was handling it correctly. Applying either to a filtered user
value hands the parser whatever the filter missed, in a template that would
otherwise have escaped it.
Secure Patterns
Encode, and leave the template engine alone
{{ $comment }} in Blade and {{ comment }} in Twig are already correct for
element content, and htmlspecialchars($v, ENT_QUOTES | ENT_HTML5, 'UTF-8') is
the equivalent when building a string by hand. The worked examples, including
the other output contexts, are on
CWE-79 - PHP.
Encoding is what makes the tag-injection case safe: once < is < there is
no tag left to blocklist, whatever the attacker writes.
When the input really is HTML
If users are meant to submit formatting, encoding it would defeat the feature. Use an allowlist sanitizer, which parses the HTML and rebuilds it from the elements you permit rather than deleting what looks bad. HTML Purifier and its Laravel wrapper are covered on CWE-79 - PHP.
Why this works: the sanitizer parses the input into a document and
re-serialises it from an allowlist, so anything not explicitly permitted cannot
survive. The open-ended set of ways to run script stops mattering, because the
question is no longer "is this dangerous" but "is this on the list" - which is
exactly what a blocklist cannot ask. Restricting the permitted URL schemes in
the policy closes javascript: in an href, which an element allowlist alone
would still let through.
Getting the order backwards is common: sanitize first, then render through the unescaped directive.
{{-- SECURE - purify, then emit as markup --}}
@php($safe = Purifier::clean($user->richBio))
<div class="rich-content">{!! $safe !!}</div>
Passing purified HTML through {{ }} instead escapes it a second time, so the
user sees <p> and <strong> as literal text. That looks like a display bug
rather than a security one, and the usual fix applied under time pressure is to
switch the directive without re-checking that the sanitizer is still in the
path.
Considerations
Whether the finding is CWE-80 or CWE-79 rarely changes the fix. Both are resolved by encoding for the context the value lands in. The label tells you what the scanner matched - tag characters in element content - not the shape of the remediation.
It does change where to look next. CWE-80 only describes element content.
An application that failed to encode there has usually failed in the contexts
CWE-80 does not name: quoted and unquoted attributes, href and src values,
inline <script> blocks, and DOM sinks. Those are CWE-79's territory, and one
CWE-80 instance is a reason to check them.
A filter kept for other reasons is not the control. Teams sometimes strip tags for content-policy or display reasons and then treat it as the security fix. If the filter stays, encoding still has to be what protects the page.
Additional Resources
- CWE-80: Improper Neutralization of Script-Related HTML Tags
- CWE-79 - PHP - the encoders, framework escaping and sanitizer configuration this page defers to
- PHP: strip_tags
- OWASP XSS Prevention Cheat Sheet