"""Small allow-list sanitizer for answer fragments returned to the browser.""" from html import escape, unescape from html.parser import HTMLParser import re from typing import Collection from urllib.parse import urlparse ALLOWED_TAGS = {"a", "b", "blockquote", "br", "code", "details", "div", "em", "h3", "h4", "hr", "i", "li", "ol", "p", "pre", "span", "strong", "summary", "table", "tbody", "td", "th", "thead", "tr", "ul"} VOID_TAGS = {"br", "hr"} HTML_VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"} DROP_CONTENT_TAGS = {"button", "form", "iframe", "input", "math", "object", "script", "style", "svg"} GLOBAL_ATTRIBUTES = {"aria-controls", "aria-expanded", "aria-label", "class", "role"} TAG_ATTRIBUTES = {"a": {"href", "rel", "target", "title"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan", "scope"}} _VISIBLE_URL_RE = re.compile(r"(?:https?://|mailto:|tel:)[^\s<>\"']+") _MARKDOWN_LINK_RE = re.compile( r"\[([^\]\n]+)\]\(\s*((?:https?://|mailto:|tel:)[^\s)]+)\s*\)" ) def _normalise_url(value: str) -> str: # Punctuation can be meaningful in a URL, especially article titles and # signed query values. Only strip sentence punctuation when matching text. return unescape(value).strip() def _safe_href(value: str, allowed_urls: set[str] | None = None) -> str | None: candidate = _normalise_url(value) if not candidate: return None if any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in candidate): return None try: parsed = urlparse(candidate) if parsed.scheme.lower() == "https" and ( not parsed.hostname or parsed.username or parsed.password ): return None # Evaluating a malformed authority/port may itself raise ValueError. _ = parsed.port except ValueError: return None if parsed.scheme.lower() not in {"https", "mailto", "tel"}: return None if allowed_urls is not None and candidate not in allowed_urls: return None return candidate def markdown_to_safe_html(value: str) -> str: """Convert the small Markdown subset commonly emitted by an LLM. LibBee's API contract is sanitized HTML. This prevents Markdown markers such as ``**bold**`` and ``[label](url)`` from leaking into the browser as text. """ value = value or "" value = _MARKDOWN_LINK_RE.sub( lambda m: f'{escape(m.group(1))}', value, ) value = re.sub(r"\*\*([^*\n]+)\*\*", r"\1", value) value = re.sub(r"(?m)^\s{0,3}#{1,6}\s+(.+)$", r"\1
", value) return value class _Sanitizer(HTMLParser): def __init__(self, allowed_urls: Collection[str] | None = None) -> None: super().__init__(convert_charrefs=True) self.output: list[str] = [] self.drop_depth = 0 self.allowed_urls = ( {_normalise_url(url) for url in allowed_urls if _normalise_url(url)} if allowed_urls is not None else None ) def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: tag = tag.lower() if tag in DROP_CONTENT_TAGS: if tag not in HTML_VOID_TAGS: self.drop_depth += 1 return if self.drop_depth or tag not in ALLOWED_TAGS: return rendered: list[str] = [] for raw_name, raw_value in attrs: name = raw_name.lower() if name.startswith("on") or name == "style": continue if name not in GLOBAL_ATTRIBUTES and name not in TAG_ATTRIBUTES.get(tag, set()): continue value = raw_value or "" if tag == "a" and name == "href": value = _safe_href(value, self.allowed_urls) or "" if not value: continue if tag == "a" and name == "target" and value != "_blank": continue rendered.append(f' {name}="{escape(value, quote=True)}"') if tag == "a": rendered = [item for item in rendered if not item.startswith(" rel=")] rendered.append(' rel="noopener noreferrer"') self.output.append(f"<{tag}{''.join(rendered)}>") def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: self.handle_starttag(tag, attrs) if tag.lower() not in HTML_VOID_TAGS: self.handle_endtag(tag) def handle_endtag(self, tag: str) -> None: tag = tag.lower() if tag in DROP_CONTENT_TAGS and tag not in HTML_VOID_TAGS: if self.drop_depth: self.drop_depth -= 1 return if not self.drop_depth and tag in ALLOWED_TAGS and tag not in VOID_TAGS: self.output.append(f"") def handle_data(self, data: str) -> None: if not self.drop_depth: if self.allowed_urls is not None: data = _VISIBLE_URL_RE.sub( lambda m: m.group(0) if ( _normalise_url(m.group(0)) in self.allowed_urls or _normalise_url(m.group(0)).rstrip(".,;:!?)]}") in self.allowed_urls ) else "", data, ) self.output.append(escape(data, quote=False)) def sanitize_answer_html(value: str, allowed_urls: Collection[str] | None = None) -> str: parser = _Sanitizer(allowed_urls) parser.feed(value or "") parser.close() return "".join(parser.output)