File size: 5,750 Bytes
61ff785 60fc368 61ff785 60fc368 61ff785 a2416df 61ff785 60fc368 61ff785 a2416df 60fc368 61ff785 60fc368 a2416df 60fc368 61ff785 60fc368 61ff785 60fc368 61ff785 60fc368 61ff785 a2416df 61ff785 60fc368 61ff785 a2416df 61ff785 a2416df 61ff785 60fc368 61ff785 a2416df 61ff785 60fc368 61ff785 60fc368 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | """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'<a href="{escape(m.group(2), quote=True)}" target="_blank">{escape(m.group(1))}</a>',
value,
)
value = re.sub(r"\*\*([^*\n]+)\*\*", r"<strong>\1</strong>", value)
value = re.sub(r"(?m)^\s{0,3}#{1,6}\s+(.+)$", r"<strong>\1</strong><br>", 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)
|