Stored XSS through Markdown javascript: links in user comments
Our comment system rendered Markdown and stripped script tags, yet a link with a javascript: URL survived into the page. We fixed it by sanitizing the rendered HTML with an allowlist of tags, attributes and URL schemes, and backed it with a strict Content-Security-Policy.
We run a knowledge base for a B2B product where customers can comment on articles, and comments support Markdown. In May an internal QA engineer, testing a new "copy link" button, noticed something odd in the rendered HTML of an old comment in staging:
<p>See <a href="javascript:void(0)">the docs</a> for details.</p>
That particular comment was harmless, but it proved that any URL scheme in a Markdown link made it into
the page unchanged. A link with a javascript: URL runs its code in our origin when a reader clicks it,
with access to the reader's session in the page. Comments are stored and shown to every visitor of an
article, including our support staff, so this was a stored cross-site scripting bug.
Reproducing it
In staging, with a test account:
- Post a comment containing a Markdown link whose target uses the
javascript:scheme and callsalert(document.domain). - Open the article as a different test user and click the link.
- An alert box shows
staging.example.com.
That is the whole proof we needed. We did not try to build anything beyond that alert.
What we ruled out
- Raw HTML in Markdown. Our renderer escaped raw HTML (
<script>and friends came out as text), and we also stripped script tags with a regex. Neither touched link targets. - The template layer. Django templates auto-escape variables, but we passed the rendered comment
through
|safe, which is necessary to output HTML at all. Escaping thehrefvalue would not help:javascript:contains no characters that HTML escaping changes. - CSP. We had a Content-Security-Policy header, but it included
'unsafe-inline'inscript-srcfor an old analytics snippet, so it did not blockjavascript:URLs.
Root cause
Comments were converted with Python-Markdown, which turns Markdown into HTML but does not sanitize its
output; the project documents that clearly. Years ago we had used bleach to clean the result. When
bleach was deprecated, a dependency cleanup removed it and replaced it with a regex that deleted
<script> blocks, on the reasoning that raw HTML was already escaped. That reasoning missed that
Markdown itself produces <a href> and <img src> attributes from user input, and those URL values
were never checked.
What is happening under the hood
Markdown is a convenient syntax for HTML. [text](url) becomes <a href="url">text</a>, and the URL
is copied as written. Browsers treat a javascript: URL in a link as script to run in the context of
the current page when the link is followed. Because the script runs on our origin, it can read
anything the page can read and make requests as the user.
Filtering by pattern is fragile because browsers are lenient about URL schemes: they ignore letter case,
strip some leading control and whitespace characters and decode HTML entities in attribute values
before interpreting the URL. A regex for the literal string javascript: misses many spellings that
browsers accept. The robust approach is the opposite: parse the rendered HTML, keep only an allowlist of
tags and attributes, and keep URL attributes only if the parsed scheme is in an allowlist such as
http, https and mailto.
Content-Security-Policy is a second layer. Under CSP Level 3, navigating to a javascript: URL counts
as inline script, so a script-src without 'unsafe-inline' blocks it.
Environment
| Component | Version / value |
|---|---|
| Framework | Django 5.1, Python 3.12 |
| Markdown | Python-Markdown 3.7 with extra and sane_lists extensions |
| Sanitizer (before) | None; regex removing <script> tags |
| Sanitizer (after) | nh3 0.2 (Python bindings to the Rust ammonia crate) |
| CSP (before) | script-src 'self' 'unsafe-inline' https://analytics.example.net |
| Affected surface | Article comments, also user profile "about" text |
Minimal reproduction
import re
import markdown
SCRIPT_RE = re.compile(r"<script.*?>.*?</script>", re.I | re.S)
def render_comment(text: str) -> str:
html = markdown.markdown(text, extensions=["extra"])
return SCRIPT_RE.sub("", html) # removes script tags, nothing else
print(render_comment("[docs](javascript:void(0))"))
# <p><a href="javascript:void(0)">docs</a></p>
Who is affected
Any application that renders user-supplied Markdown (or BBCode, wiki syntax, rich text) to HTML on the server or client without sanitizing the output. Some Markdown libraries filter dangerous link schemes by default and others do not, so do not rely on a renderer's defaults; sanitize the HTML either way.
The fix
1. Sanitize after rendering, with allowlists. We render Markdown first, then pass the HTML through nh3 with explicit tags, attributes and URL schemes:
# comments/rendering.py
import markdown
import nh3
ALLOWED_TAGS = {
"p", "br", "strong", "em", "code", "pre", "blockquote",
"ul", "ol", "li", "a", "h3", "h4", "table", "thead", "tbody", "tr", "th", "td",
}
ALLOWED_ATTRIBUTES = {"a": {"href", "title"}}
ALLOWED_SCHEMES = {"http", "https", "mailto"}
def render_comment(text: str) -> str:
html = markdown.markdown(text, extensions=["extra", "sane_lists"])
return nh3.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
url_schemes=ALLOWED_SCHEMES,
link_rel="noopener noreferrer nofollow ugc",
)
We removed img from comments on purpose; images are not needed there and remove a whole class of
problems. Relative links are still allowed, so internal links keep working.
2. Render once, store both. We store the Markdown source and the sanitized HTML, and re-render all existing comments with a management command whenever the sanitizer configuration changes:
python manage.py rerender_comments --batch-size 500
That command re-rendered 48,213 existing comments and profiles, and logged every one whose output changed, so we could review what had been stored.
3. Remove 'unsafe-inline' from CSP. We moved the analytics snippet into a static file and now
send:
Content-Security-Policy: default-src 'self'; script-src 'self' https://analytics.example.net; object-src 'none'; base-uri 'self'; frame-ancestors 'self'
With that header, even if a javascript: link slipped through in the future, clicking it would be
blocked and reported in the console.
Alternatives and trade-offs
- Configure the Markdown library to reject unsafe links. Useful, but it ties security to one renderer's behaviour and extensions. Sanitizing the final HTML protects against all of them.
- Sanitize in the browser with DOMPurify. A good choice for client-side rendering. We render on the server, so we sanitize there and keep the HTML static.
- Disallow links in comments. Safest, but customers share documentation links constantly.
How to test the fix
Unit tests cover the scheme allowlist, including case and entity variations, without needing a browser:
# comments/tests/test_rendering.py
import pytest
from comments.rendering import render_comment
@pytest.mark.parametrize("target", [
"javascript:void(0)",
"JaVaScRiPt:void(0)",
"data:text/html;base64,PGI+aGk8L2I+",
"vbscript:msgbox(1)",
])
def test_unsafe_schemes_are_removed(target):
html = render_comment(f"[x]({target})")
assert "href" not in html
@pytest.mark.parametrize("target", ["https://example.com/docs", "/kb/42", "mailto:help@example.com"])
def test_safe_links_survive(target):
assert f'href="{target}"' in render_comment(f"[x]({target})")
def test_raw_html_is_not_rendered():
assert "<script" not in render_comment("<script>x</script>")
pytest comments/tests/test_rendering.py -q
We also added a Playwright check that loads an article with a fixture comment and asserts the page has
no a[href^="javascript:" i] elements and logs no CSP violations.
Confirming in production
- After deploy, a database query for stored HTML containing
href="javascript:(case-insensitive) returned zero rows; before the re-render it returned three, all created by our own staff during testing years ago. No customer-created comment contained an unsafe scheme. - The CSP
report-toendpoint received noscript-srcviolations from comments in the first two weeks, and the analytics script loaded normally. - A manual retest of the staging steps showed the link rendered as plain text without
href.
Regression guard
- The sanitizer tests run in CI, and the shared
render_commentfunction is the only allowed path to|safefor user content; a custom lint rule flags|safein templates outside an approved list. - Dependency updates for
markdownandnh3require the rendering test suite to pass. - The CSP header is checked in an integration test, which fails if
'unsafe-inline'reappears inscript-src.
Security implications
This was a stored XSS reachable by any logged-in customer, with potential impact on other customers and
on staff viewing comments. We fixed it within a day of discovery, reviewed stored content for abuse,
and found none. Session cookies were already HttpOnly, which limited what injected script could read
directly, but XSS can still act as the user inside the page, so that was not a reason to delay. The fix
reduces attack surface in three ways: an allowlist sanitizer, no images in comments and a CSP that
blocks inline script.
Prevention checklist
- Always sanitize HTML produced from user input, after rendering, with allowlists.
- Allow only explicit URL schemes in
hrefandsrc; never blocklist. - Treat any use of
|safe,dangerouslySetInnerHTMLorv-htmlas a security review trigger. - Keep
'unsafe-inline'out ofscript-src. - Re-render stored content when sanitizer rules change.
References
- OWASP: Cross Site Scripting Prevention Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- Python-Markdown documentation — https://python-markdown.github.io/
- nh3 documentation — https://nh3.readthedocs.io/en/latest/
- W3C: Content Security Policy Level 3 — https://www.w3.org/TR/CSP3/
- Django: automatic HTML escaping — https://docs.djangoproject.com/en/5.1/ref/templates/language/
Add a label
Embed this post
<iframe src="https://hibug.me/stored-xss-markdown-javascript-links" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.