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
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<). 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 <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 javascript:. 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:
- Parse the HTML — Feed the input into a real HTML parser (e.g., Python's
html.parseror a library likebleachthat useshtml5lib). This creates a DOM-like tree. The parser also handles malformed HTML the same way a browser would. - 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. - Filter attributes — For allowed tags, inspect each attribute. For example,
hrefon<a>must pass a URL scheme check (onlyhttp,https,mailto, notjavascript:).srcon<img>must similarly be safe. Event-handler attributes likeonclick,onerrorare always stripped. - 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 rejectexpression()orurl()with dangerous schemes. - 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.
- 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
- Using DOMPurify in client-side JavaScript for single-page apps.
- Defining a custom whitelist with html5lib and DOM traversal for full control.
- 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.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.