CWE-80: Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS) - Python
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 - Python, and that page is where the
encoders live: Django and Jinja auto-escaping, html.escape(), escapejs for
script context, URL encoding, and nh3 for content that has to keep real
markup.
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
import re
# VULNERABLE - removing what looks dangerous, rather than encoding
def clean(user_input):
out = re.sub(r"<script>", "", user_input, flags=re.I)
out = out.replace("javascript:", "")
return out
# Rendered with autoescaping off, or marked safe:
# {{ clean(comment) | safe }}
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
<SCRIPT >alert(1)</SCRIPT > matches only if every variant was anticipated
The list of ways to execute script is open-ended and grows with the HTML and
browser specifications, so a blocklist is guessing against a set it cannot
enumerate. The second substitution is worse than useless: removing
javascript: once turns javajavascript:script: into javascript:.
Marking filtered input as safe
from markupsafe import Markup
# VULNERABLE - |safe and Markup() both disable the escaping that was working
return Markup(clean(user_comment))
Why this is vulnerable: Django's |safe, mark_safe(), and Jinja's
Markup() do not sanitize - they assert that the value is already trusted
markup and switch off the auto-escaping that would otherwise have handled it.
Applying one to a value that came from a user hands the parser whatever the
blocklist missed.
Secure Patterns
Encode, and let the template do it
Use the auto-escaping your framework already applies, and do not switch it off.
The worked examples are on
CWE-79 - Python; the short
version is that {{ value }} in a Django or Jinja template is already correct
for element content, and html.escape(value, quote=True) is the standard
library equivalent when you are building a string yourself.
Encoding is what makes the tag-injection case safe: once < is <, there
is no tag 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-based sanitizer, which parses the HTML and rebuilds it from the elements and attributes you permit, rather than deleting what looks bad:
import nh3
# SECURE - allowlist parse-and-rebuild, not a blocklist filter
SAFE_TAGS = {"p", "br", "strong", "em", "ul", "ol", "li", "a", "code"}
SAFE_ATTRS = {"a": {"href", "title"}}
def render_user_html(dirty: str) -> str:
return nh3.clean(
dirty,
tags=SAFE_TAGS,
attributes=SAFE_ATTRS,
url_schemes={"http", "https", "mailto"},
)
Why this works: nh3 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". Restricting
url_schemes is not what closes javascript:, which nh3 rejects under its
default scheme list anyway. Measured on nh3 0.3.7, the narrower set is what
drops the schemes this application has no use for - ftp:, tel:, sms: and
magnet: keep their href under the default list and lose it under this one -
so every URL that survives is one a reviewer can enumerate.
Install with pip install nh3. It is the maintained successor to bleach,
which was retired by its authors and is classified inactive on PyPI.
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
finding one CWE-80 instance is a reason to check them.
A blocklist kept for other reasons is not the control. Teams sometimes
strip <script> for content-policy or display reasons and end up treating 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 - Python - the encoders and framework escaping this page defers to
- nh3 documentation
- OWASP XSS Prevention Cheat Sheet