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)