"""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"{tag}>")
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)