diff --git a/profiles.py b/profiles.py index 0b1bc45..8ffb704 100644 --- a/profiles.py +++ b/profiles.py @@ -3,10 +3,8 @@ import json import re import time from typing import Optional - import markdown - -from sanitizer import sanitize_html_fragment +from html.parser import HTMLParser from db import get_db DEFAULT_ROLE_NAME_COLORS = { @@ -14,6 +12,99 @@ DEFAULT_ROLE_NAME_COLORS = { "moderator": "#8ab4ff", "user": "#f5f5f7", } +_ALLOWED_TAGS = { + "b", + "strong", + "i", + "em", + "u", + "p", + "br", + "ul", + "ol", + "li", + "blockquote", + "code", + "pre", + "a", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", +} + +_ALLOWED_ATTRS = { + "a": {"href", "title", "target", "rel"}, +} + +_ALLOWED_SCHEMES = ("http://", "https://", "mailto:") + + +def _escape_attr(value: str) -> str: + return html.escape(value, quote=True) + + +def sanitize_html_fragment(raw: str) -> str: + class SafeHtmlParser(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + + def handle_starttag(self, tag, attrs): + tag = tag.lower() + if tag not in _ALLOWED_TAGS: + return + + allowed = _ALLOWED_ATTRS.get(tag, set()) + clean_attrs = [] + + for key, value in attrs: + key = (key or "").lower() + value = value or "" + + if key not in allowed: + continue + + if tag == "a" and key == "href": + lowered = value.strip().lower() + if not lowered.startswith(_ALLOWED_SCHEMES): + continue + + clean_attrs.append((key, value)) + + if tag == "a": + attr_keys = {key for key, _ in clean_attrs} + if "target" not in attr_keys: + clean_attrs.append(("target", "_blank")) + if "rel" not in attr_keys: + clean_attrs.append(("rel", "noopener noreferrer")) + + attr_text = "".join( + f' {key}="{_escape_attr(value)}"' + for key, value in clean_attrs + ) + self.parts.append(f"<{tag}{attr_text}>") + + def handle_endtag(self, tag): + tag = tag.lower() + if tag in _ALLOWED_TAGS and tag not in {"br"}: + self.parts.append(f"") + + def handle_data(self, data): + self.parts.append(html.escape(data)) + + def handle_entityref(self, name): + self.parts.append(f"&{name};") + + def handle_charref(self, name): + self.parts.append(f"&#{name};") + + parser = SafeHtmlParser() + parser.feed(raw or "") + parser.close() + return "".join(parser.parts) PROFILE_BIO_MODES = {"txt", "md", "html"} THEME_MODES = {"dark", "light"} diff --git a/routes/profiles.py b/routes/profiles.py index d402bc2..599f465 100644 --- a/routes/profiles.py +++ b/routes/profiles.py @@ -15,6 +15,7 @@ from .common import ( now_iso, csrf_valid, rate_limited, + current_user, require_user, require_writable_user, normalize_username, @@ -30,7 +31,6 @@ from .common import ( normalized_hex_color, ) - def register_profile_routes(app): @app.route("/api/profile") def api_get_my_profile(): diff --git a/sanitizer.py b/sanitizer.py deleted file mode 100644 index b6a704d..0000000 --- a/sanitizer.py +++ /dev/null @@ -1,97 +0,0 @@ -import html -from html.parser import HTMLParser - - -_ALLOWED_TAGS = { - "b", - "strong", - "i", - "em", - "u", - "p", - "br", - "ul", - "ol", - "li", - "blockquote", - "code", - "pre", - "a", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", -} - -_ALLOWED_ATTRS = { - "a": {"href", "title", "target", "rel"}, -} - -_ALLOWED_SCHEMES = ("http://", "https://", "mailto:") - - -def _escape_attr(value: str) -> str: - return html.escape(value, quote=True) - - -def sanitize_html_fragment(raw: str) -> str: - class SafeHtmlParser(HTMLParser): - def __init__(self): - super().__init__(convert_charrefs=True) - self.parts = [] - - def handle_starttag(self, tag, attrs): - tag = tag.lower() - if tag not in _ALLOWED_TAGS: - return - - allowed = _ALLOWED_ATTRS.get(tag, set()) - clean_attrs = [] - - for key, value in attrs: - key = (key or "").lower() - value = value or "" - - if key not in allowed: - continue - - if tag == "a" and key == "href": - lowered = value.strip().lower() - if not lowered.startswith(_ALLOWED_SCHEMES): - continue - - clean_attrs.append((key, value)) - - if tag == "a": - attr_keys = {key for key, _ in clean_attrs} - if "target" not in attr_keys: - clean_attrs.append(("target", "_blank")) - if "rel" not in attr_keys: - clean_attrs.append(("rel", "noopener noreferrer")) - - attr_text = "".join( - f' {key}="{_escape_attr(value)}"' - for key, value in clean_attrs - ) - self.parts.append(f"<{tag}{attr_text}>") - - def handle_endtag(self, tag): - tag = tag.lower() - if tag in _ALLOWED_TAGS and tag not in {"br"}: - self.parts.append(f"") - - def handle_data(self, data): - self.parts.append(html.escape(data)) - - def handle_entityref(self, name): - self.parts.append(f"&{name};") - - def handle_charref(self, name): - self.parts.append(f"&#{name};") - - parser = SafeHtmlParser() - parser.feed(raw or "") - parser.close() - return "".join(parser.parts)