Sanitize HTML to Prevent XSS

Learn how to sanitize HTML to prevent XSS attacks in this Secure development lesson. Understand the concept, apply it in a hands-on exercise, and connect it to next steps.

Focus: sanitize html to prevent xss

Sponsored

You have a comment form, a profile bio field, or a rich-text editor that renders user input as HTML. It works beautifully until someone pastes <script>alert('owned')</script> into the payload — and suddenly every visitor to your page is running their code in your users' browsers. That's Cross-Site Scripting (XSS), one of the most common and damaging web vulnerabilities, and it can steal session cookies, deface pages, or perform actions as logged-in users. The fix isn't to stop accepting HTML — it's to sanitize HTML to prevent XSS, a core skill in the Secure development track. This lesson gives you a bulletproof mental model, a step-by-step process, and a complete hands-on exercise you can run today.

The problem this lesson solves

XSS is an injection attack: untrusted user input is treated as executable code by the browser. When your app echoes back a comment like <img src=x onerror=alert(1)> without cleaning it, the browser treats it as markup — and executes the onerror handler. The damage is real:

  • Session hijacking — the attacker steals cookies and impersonates victims.
  • Account takeover — the attacker can change passwords or emails.
  • Malware distribution — the page silently loads drive-by-downloads.
  • Defacement — the attacker replaces content with their own.

If you build any web app that renders user content, you are exposed. The </ injection is not a theoretical hobby project issue; it's the #1 vector in OWASP's Top 10.

The root cause is confusing data with code. When you insert a string into HTML, the browser interprets it as markup unless you explicitly escape it or sanitize it. A naive str.replace or a simple html.escape won't cover every vector — you need a purpose-built sanitizer that knows the HTML specification.

Core concept / mental model

Think of HTML sanitization as an airlock between untrusted input and your live page. Raw user input arrives; the sanitizer inspects every tag, attribute, and style property; it strips anything dangerous; it outputs safe, clean HTML that the browser can render without executing scripts.

The golden rule: Never trust user input — even “trusted” users. Sanitization is not the same as escaping:

  • Escaping converts special characters to entities (e.g., < to &lt;). It makes text display as text, but it destroys markup — you lose bold, links, images.
  • Sanitization removes dangerous constructs while preserving safe HTML — you keep formatting but drop scripts, event handlers, and risky URLs.

Why not escape everything?

If you escape everything, a comment like I <3 Python becomes I &lt;3 Python — technically safe but ugly. More importantly, if you need to allow rich text (e.g., a markdown editor), escaping is not enough because you must allow some HTML. The sanitizer knows the difference between <b> (safe) and <script> (dangerous).

Allowlist vs denylist

A denylist tries to block known bad patterns (<script>, onerror, javascript:). Attackers evade denylists daily with obfuscation like <scr<script>ipt> or java&#x73;cript:. A allowlist starts from zero and only permits known-safe tags and attributes — vastly more secure. Always prefer allowlisting.

How it works step by step

The process of sanitizing HTML happens in layers, mirroring how browsers parse the document. Here's the step-by-step flow:

  1. Parse the HTML — Feed the input into a real HTML parser (e.g., Python's html.parser or a library like bleach that uses html5lib). This creates a DOM-like tree. The parser also handles malformed HTML the same way a browser would.
  2. Traverse every node — For each element, compare the tag against an allowlist (e.g., p, b, a, img, ul, li). If a tag is not allowed, you can either strip it (and keep its children) or escape it.
  3. Filter attributes — For allowed tags, inspect each attribute. For example, href on <a> must pass a URL scheme check (only http, https, mailto, not javascript:). src on <img> must similarly be safe. Event-handler attributes like onclick, onerror are always stripped.
  4. Sanitize style and CSS — Inline styles can hide attacks like background-image: url(javascript:...). A sanitizer should only allow a whitelisted set of CSS properties and reject expression() or url() with dangerous schemes.
  5. Escape or drop other content — Attributes that could be interpreted as code, or unrecognized tags, are escaped or removed. The result is stored and rendered as safe HTML.
  6. Validate output — Run the sanitized HTML through a check; it should not contain <script, on*, javascript:, or any dangerous CSS. A good library does all of this internally.

The key insight

Sanitization is not a single regex — it's a semantic understanding of HTML. Always use a proven library over a hand-rolled solution.

Hands-on walkthrough

Setup

Ensure Python 3.10+ and install Bleach (based on html5lib):

pip install bleach

Example 1: Basic sanitization

import bleach

raw_comment = """
<p>Hello <b>world</b>!</p>
<script>alert('xss')</script>
<a href="javascript:alert(1)">Click me</a>
<img src="x" onerror="alert('xss')">
"""

# Allow only a few tags and attributes
allowed_tags = ['p', 'b', 'i', 'a', 'img']
allowed_attrs = {'a': ['href'], 'img': ['src', 'alt']}

clean = bleach.clean(raw_comment, tags=allowed_tags, attributes=allowed_attrs)
print(clean)

Expected output:

<p>Hello <b>world</b>!</p>
<a href="Click me"></a>
<img src="x" alt="">

Notice: <script> and onerror are gone, the javascript: href is stripped (leaving only the text), and the <img> kept src (even though it's broken) — but the handler is gone.

Example 2: URL scheme filtering

import bleach

samples = [
    '<a href="https://safe.example.com">Safe link</a>',
    '<a href="javascript:alert(1)">Evil link</a>',
    '<a href="mailto:user@example.com">Email</a>',
    '<a href="data:text/html;base64,...">Data URI</a>',
]

for s in samples:
    cleaned = bleach.clean(s, tags=['a'], attributes={'a': ['href']},
                          protocols=['http', 'https', 'mailto'])
    print(cleaned)

Expected output:

<a href="https://safe.example.com">Safe link</a>
<a>Evil link</a>
<a href="mailto:user@example.com">Email</a>
<a>Data URI</a>

The protocols list blocks all schemes except those allowed.

Example 3: Sanitizing with linkification

Bleach also has a linkify function that turns raw URLs into clickable links safely:

import bleach

text = "Check https://example.com for details"
linked = bleach.linkify(text, callbacks=[bleach.callbacks.nofollow])
print(linked)

Expected output:

Check <a href="https://example.com" rel="nofollow">https://example.com</a> for details

rel="nofollow" is added to discourage search engines from following user-provided links — a nice extra security touch.

Production pattern

Always sanitize on output (or before render), not only at input. Here's a Django-style view:

# views.py (conceptual)
from django.shortcuts import render
import bleach

ALLOWED_TAGS = ['p', 'b', 'i', 'u', 'a', 'ul', 'ol', 'li', 'img', 'br']
ALLOWED_ATTRS = {'a': ['href', 'title'], 'img': ['src', 'alt']}
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']

def show_comment(request, comment_id):
    comment = Comment.objects.get(pk=comment_id)
    safe_html = bleach.clean(
        comment.body,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRS,
        protocols=ALLOWED_PROTOCOLS,
        strip=True
    )
    return render(request, 'comment.html', {'safe_html': safe_html})

Pro tip: Always sanitize just before rendering in the template, not at input, because data may come from many sources. Server-side sanitization is the only reliable layer — client-side JS sanitizers can be bypassed.

Compare options / when to choose what

Approach Pros Cons Best for
Bleach (Python) Battle-tested, allowlist-driven, handles URLs and CSS Requires understanding of options Server-side Python apps (Django, Flask)
html5lib + custom DOM walk Full HTML5 spec compliance, total control More code to maintain Custom sanitizers in exotic scenarios
Django escape + safe Simple for basic text Doesn't allow rich HTML, easy to misuse Plain text comments
Cooked input (markdown) Safe by default if you avoid raw HTML May not cover all use cases Markdown editors
Third-party SaaS (e.g., DOMPurify in JS) Offloads maintenance Latency, cost Heavy client-side rendering

When to choose what:

  • For Python backends, Bleach is the de facto standard — use it.
  • If you need maximum control and know the HTML spec deeply, a custom allowlist walker may be justified.
  • If your content is plain text only, you may not need a sanitizer at all — but you still need escaping.
  • For client-side apps (React/Vue), libraries like DOMPurify exist, but always pair with server-side validation.

Troubleshooting & edge cases

Even with a sanitizer, mistakes happen. Here are common pitfalls and fixes:

1. Using a blacklist instead of an allowlist

# BAD: Blacklist can be bypassed
bad = raw.replace('<script>', '')
# GOOD: Allowlist
clean = bleach.clean(raw, tags=['p', 'b'])

Fix: Start with an allowlist and keep it minimal.

2. Forgetting to sanitize style attributes

<div style="background-image:url(javascript:...)"> bypasses tag filtering if you allow div but not style. Use bleach.clean with styles=[] (empty default) to strip all inline styles, or a strict whitelist.

3. Allowing href without protocol filtering

If you allow a[href] but don't set protocols, javascript: slips through. Always pass protocols=['http','https','mailto'].

4. Sanitizing only on input

If you sanitize once and store the clean HTML, later edits might re-introduce danger. Sanitize on output or re-sanitize after any database change.

5. Using mark_safe in Django without sanitization

# BAD
return mark_safe(user_input)
# GOOD
return mark_safe(bleach.clean(user_input, tags=[...]))

mark_safe tells Django not to escape — if you don't sanitize first, you just made XSS possible.

6. Encoding errors with non-ASCII characters

Always decode input to Unicode (str in Python 3) before sanitizing. Bleach handles this, but if you pass bytes, you may get warnings or corrupted output.

Debugging tip: If you suspect an XSS is still possible, paste your sanitized output into an HTML validator or check for on*, `

Practice recap

Open a fresh Python file and use Bleach to sanitize a list of raw HTML snippets that include script tags, event handlers, and javascript: links. Print the clean results and verify the dangerous patterns are gone. Then extend the example by adding an allowed style whitelist and observe how inline styles are handled.

Common mistakes

  • Using a blacklist to remove instead of an allowlist — bypasses like ipt> or encoding slip through.
  • Forgetting to filter URL protocols, e.g., allowing without protocols and letting javascript: sneak in.
  • Sanitizing only at input time, so later edits or data from other sources re-insert malicious markup.
  • Using mark_safe or equivalent without sanitizer, telling Django to skip escaping on unsanitized content.
  • Re-inventing the wheel with regex — HTML parsing requires a real parser (html5lib), not a pattern match.

Variations

  1. Using DOMPurify in client-side JavaScript for single-page apps.
  2. Defining a custom whitelist with html5lib and DOM traversal for full control.
  3. Choosing a mature framework's built-in sanitizers, such as Ruby on Rails' sanitize helper.

Real-world use cases

  • Sanitizing user-generated comments in a Django blog to prevent stored XSS in comment sections.
  • Cleaning rich-text editor output in a CMS (e.g., TinyMCE) to allow formatting but block script injection.
  • Filtering user-submitted HTML in email templates to avoid active content attacks when rendering messages.

Key takeaways

  • XSS occurs when untrusted input is rendered as HTML/JS without sanitization or escaping.
  • Always use an allowlist-based sanitizer (like Bleach) — never blacklists or regex.
  • Filter tags, attributes, URL protocols, and CSS properties in layers.
  • Sanitize at output/rendering, not only at input, to cover all data sources.
  • Sanitization complements escaping — both are needed for different contexts.
  • Confirm your sanitized output contains no on* attributes or javascript: schemes.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.