feat(provenance): nh3 HTML sanitizer for scraped post descriptions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
"""Backend HTML sanitization for scraped Post.description.
|
||||||
|
|
||||||
|
Provenance descriptions come from gallery-dl (untrusted third-party HTML).
|
||||||
|
We render them via v-html in the UI sub-slice, so they MUST be sanitized
|
||||||
|
server-side to a tight allowlist. nh3 (Rust/ammonia bindings) is used;
|
||||||
|
bleach is deprecated upstream and intentionally not used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import nh3
|
||||||
|
|
||||||
|
ALLOWED_TAGS = {
|
||||||
|
"p", "a", "br", "em", "strong", "b", "i", "ul", "ol", "li", "blockquote",
|
||||||
|
}
|
||||||
|
ALLOWED_ATTRIBUTES = {"a": {"href", "title"}}
|
||||||
|
ALLOWED_URL_SCHEMES = {"http", "https"}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_post_html(raw: str | None) -> str | None:
|
||||||
|
"""Return sanitized HTML, or None for null/empty/whitespace input.
|
||||||
|
|
||||||
|
- keeps only ALLOWED_TAGS / ALLOWED_ATTRIBUTES
|
||||||
|
- only http/https hrefs survive (javascript: etc. dropped)
|
||||||
|
- <script> content removed entirely
|
||||||
|
- all <a> get rel="noopener noreferrer" (nh3 default link_rel)
|
||||||
|
- never raises
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
return nh3.clean(
|
||||||
|
stripped,
|
||||||
|
tags=ALLOWED_TAGS,
|
||||||
|
attributes=ALLOWED_ATTRIBUTES,
|
||||||
|
url_schemes=ALLOWED_URL_SCHEMES,
|
||||||
|
link_rel="noopener noreferrer",
|
||||||
|
clean_content_tags={"script", "style"},
|
||||||
|
)
|
||||||
@@ -26,3 +26,6 @@ gallery-dl>=1.32,<1.33
|
|||||||
# Utilities
|
# Utilities
|
||||||
python-dotenv>=1.2,<2.0
|
python-dotenv>=1.2,<2.0
|
||||||
structlog>=25.5,<26.0
|
structlog>=25.5,<26.0
|
||||||
|
|
||||||
|
# HTML sanitization for scraped post descriptions (FC-2d provenance)
|
||||||
|
nh3>=0.2,<0.3
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from backend.app.utils.html_sanitize import sanitize_post_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_and_blank_return_none():
|
||||||
|
assert sanitize_post_html(None) is None
|
||||||
|
assert sanitize_post_html("") is None
|
||||||
|
assert sanitize_post_html(" \n ") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_tags_survive():
|
||||||
|
out = sanitize_post_html("<p>hi <strong>there</strong><br><em>x</em></p>")
|
||||||
|
assert "<p>" in out and "<strong>" in out and "<br>" in out and "<em>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_disallowed_tags_and_attrs_stripped_text_kept():
|
||||||
|
out = sanitize_post_html('<script>alert(1)</script><p onclick="x">body</p>')
|
||||||
|
assert "<script>" not in out
|
||||||
|
assert "alert(1)" not in out # clean_content removes script *content* too
|
||||||
|
assert "onclick" not in out
|
||||||
|
assert "body" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_div_stripped_but_text_preserved():
|
||||||
|
out = sanitize_post_html("<div><span>keep</span> me</div>")
|
||||||
|
assert "<div>" not in out and "<span>" not in out
|
||||||
|
assert "keep" in out and "me" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_links_get_rel_and_keep_http():
|
||||||
|
out = sanitize_post_html('<a href="https://example.com/p">link</a>')
|
||||||
|
assert 'href="https://example.com/p"' in out
|
||||||
|
assert 'rel="noopener noreferrer"' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_javascript_href_dropped():
|
||||||
|
out = sanitize_post_html('<a href="javascript:alert(1)">x</a>')
|
||||||
|
assert "javascript:" not in out
|
||||||
|
assert "x" in out # text preserved, dangerous href removed
|
||||||
Reference in New Issue
Block a user