ProvenPin Install

bleach HTML sanitizer + MarkupSafe Markup: working versions (tested 2026-10-15)

Sanitize untrusted HTML with bleach, then wrap the safe result in MarkupSafe Markup so it embeds in templates without double escaping.

passed6 passed · 0 failed2026-10-15 15:57 UTC
Status
passed
Last passed
2026-10-15 15:57 UTC
Tests
6 / 6
Language
Python
Runtime
Python 3.13.16
pinned versions3
bleach@6.4.0markupsafe@3.0.4pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38764699834

The code

Exactly the files that passed. Your agent can pull them with get_recipe("bleach-markupsafe-html") (install), or see them on GitHub.

requirements.txt
bleach==6.4.0
markupsafe==3.0.4
pytest==9.1.1
sanitizer.py
import bleach
from markupsafe import Markup

ALLOWED_TAGS = ["a", "b", "code", "em", "i", "li", "ol", "p", "strong", "ul"]
ALLOWED_ATTRS = {"a": ["href", "title"]}
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]


def sanitize(html: str) -> str:
    # strip=False escapes disallowed tags, strip=True deletes them.
    return bleach.clean(
        html,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRS,
        protocols=ALLOWED_PROTOCOLS,
        strip=False,
        strip_comments=True,
    )


def sanitize_strict(html: str) -> str:
    return bleach.clean(html, tags=[], attributes={}, protocols=[], strip=True)


def safe(html: str) -> Markup:
    # bleach always returns a plain str, so mark it safe AFTER cleaning.
    return Markup(sanitize(html))


def render_card(title: str, body_html: str) -> Markup:
    template = Markup('<article class="card"><h1>{}</h1><div>{}</div></article>')
    # Markup.format escapes plain args but leaves Markup args alone.
    return template.format(title, safe(body_html))


def autolink(text: str) -> Markup:
    # linkify alone does NOT sanitize, so clean first to avoid passing scripts through.
    return Markup(bleach.linkify(sanitize_strict(text)))
tests/test_sanitizer.py
import bleach
from markupsafe import Markup

from sanitizer import autolink, render_card, sanitize, sanitize_strict


def test_disallowed_tags_are_escaped_by_default():
    out = sanitize("<b>hi</b> <script>alert(1)</script>")
    assert "<b>hi</b>" in out
    assert "&lt;script&gt;alert(1)&lt;/script&gt;" in out
    assert "<script>" not in out


def test_strip_true_drops_tags_and_comments():
    assert sanitize_strict("a<!-- secret --><b>hi</b>") == "ahi"


def test_javascript_urls_and_event_handlers_are_dropped():
    out = sanitize('<a href="javascript:alert(1)" onclick="steal()">x</a>')
    assert out == "<a>x</a>"


def test_plain_bleach_output_is_double_escaped_by_markup_format():
    cleaned = sanitize("<b>hi</b>")
    assert isinstance(cleaned, str) and not isinstance(cleaned, Markup)
    template = Markup("<div>{}</div>")
    assert template.format(cleaned) == "<div>&lt;b&gt;hi&lt;/b&gt;</div>"
    assert template.format(Markup(cleaned)) == "<div><b>hi</b></div>"


def test_render_card_escapes_title_but_trusts_sanitized_body():
    out = render_card("<script>alert(1)</script>", "hello <em>world</em> <script>x</script>")
    assert isinstance(out, Markup)
    assert out.startswith('<article class="card"><h1>&lt;script&gt;alert(1)&lt;/script&gt;</h1>')
    assert "<em>world</em>" in out
    assert "<script>" not in out


def test_linkify_alone_does_not_sanitize_so_clean_first():
    assert "<script>" in bleach.linkify("<script>x</script> https://example.com")
    out = autolink("<script>x</script> https://example.com")
    assert "<script>" not in out
    assert '<a href="https://example.com" rel="nofollow">' in out
pytest.ini
[pytest]
pythonpath = .

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.