Skip to content

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>
java&#115;cript: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 &lt;, 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