Skip to content

Apply safe_href scheme allowlist to images in safe_mode#714

Open
kayaeminn wants to merge 2 commits into
trentm:masterfrom
kayaeminn:harden/safe-mode-image-scheme-allowlist
Open

Apply safe_href scheme allowlist to images in safe_mode#714
kayaeminn wants to merge 2 commits into
trentm:masterfrom
kayaeminn:harden/safe-mode-image-scheme-allowlist

Conversation

@kayaeminn

Copy link
Copy Markdown

Summary

In safe_mode, process_anchor() validates link URLs against _safe_href
(allowlist: http(s), ftp, mailto, tel), but process_image() only passes
the src through _protect_url(), which escapes <>"' but does not restrict the
URL scheme. So in safe_mode:

>>> import markdown2
>>> markdown2.markdown("![x](javascript:alert(1))", safe_mode="escape")
'<p><img src="javascript:alert(1)" alt="x" /></p>\n'

while the equivalent link is correctly neutralized to href="#". This is an
inconsistency within the sanitizer's own model.

Impact

To be clear, this is not an exploitable XSS: javascript:/data: URIs do not
execute in <img src> on modern browsers. I'm raising it as defense-in-depth /
consistency
— the behavior is surprising for a sanitizer and could matter for
non-browser or legacy HTML consumers of the output.

Fix

Apply the same _safe_href scheme allowlist to images that links already use.

Behavior before/after (safe_mode='escape')

Input Before After
![x](javascript:alert(1)) src="javascript:alert(1)" src=""
![x](data:text/html,...) kept src=""
![logo](https://example.com/a.png) kept kept
![local](/img/a.png) kept kept
default mode (no safe_mode) unchanged unchanged

Legitimate absolute/relative image URLs are unaffected, and default (non-safe) mode
is unchanged.

process_anchor() validates link URLs against _safe_href (allowlist:
http(s)/ftp/mailto/tel) in safe_mode, but process_image() only ran the
src through _protect_url(), which escapes <>"' but does not restrict the
URL scheme. As a result, in safe_mode an image like
`![x](javascript:alert(1))` rendered as `<img src="javascript:alert(1)">`
— inconsistent with how `<a href>` is handled.

This is defense-in-depth (javascript: does not execute in <img src> on
modern browsers), but the inconsistency is surprising for a sanitizer and
could matter for non-browser / legacy HTML consumers. Apply the same
allowlist to images; legitimate absolute/relative image URLs are
unaffected, and default (non-safe) mode is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nicholasserra

Copy link
Copy Markdown
Collaborator

Sounds sane but has some failing tests that look a bit concerning

The previous version applied the full `_safe_href` allowlist to images,
which stripped legitimate `data:image/...` and quote-escaped `http` src
values that markdown2 intentionally supports (broke data_urls_in_safe_mode,
basic_safe_mode, issue603_xss).

Keep markdown2's existing image model (quote-escaping via _protect_url) and
only neutralize `javascript:`/`vbscript:` schemes, which are never valid for
an <img src> and exist purely as XSS vectors. All 234 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kayaeminn

Copy link
Copy Markdown
Author

Good catch, thanks — you were right to be wary.

The original diff was too broad: it applied the full _safe_href allowlist to <img>, which stripped legitimate data:image/... and quote-escaped http src values that markdown2 intentionally supports. That is what broke data_urls_in_safe_mode, basic_safe_mode, and issue603_xss.

I have narrowed it to keep markdown2's existing image model (quote-escaping via _protect_url) and only neutralize javascript:/vbscript: schemes, which are never valid for an <img src>. All 234 tests pass locally now (cd test && python test.py).

To be transparent: this is defense-in-depth — javascript: does not execute from <img src> in modern browsers — so if you would rather not carry the extra branch, feel free to close. Pushed the narrowed commit either way.

🤖 Addressed by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants