Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · May 27, 2025 · 9 min read · 13 Views
Banner: stored cross-site scripting through a Markdown link with an unsafe URL scheme

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:

  1. Post a comment containing a Markdown link whose target uses the javascript: scheme and calls alert(document.domain).
  2. Open the article as a different test user and click the link.
  3. 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 the href value would not help: javascript: contains no characters that HTML escaping changes.
  • CSP. We had a Content-Security-Policy header, but it included 'unsafe-inline' in script-src for an old analytics snippet, so it did not block javascript: 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-to endpoint received no script-src violations 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_comment function is the only allowed path to |safe for user content; a custom lint rule flags |safe in templates outside an approved list.
  • Dependency updates for markdown and nh3 require the rendering test suite to pass.
  • The CSP header is checked in an integration test, which fails if 'unsafe-inline' reappears in script-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 href and src; never blocklist.
  • Treat any use of |safe, dangerouslySetInnerHTML or v-html as a security review trigger.
  • Keep 'unsafe-inline' out of script-src.
  • Re-render stored content when sanitizer rules change.

References

Add a label

#django #csp #xss #markdown #nh3
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.