nikeshn commited on
Commit
61ff785
·
verified ·
1 Parent(s): e7cd206

Upload html_sanitizer.py

Browse files
Files changed (1) hide show
  1. src/services/html_sanitizer.py +93 -53
src/services/html_sanitizer.py CHANGED
@@ -1,79 +1,119 @@
1
- """Small allow-list sanitizer for answer fragments returned to the browser."""
2
-
3
- from html import escape
4
  from html.parser import HTMLParser
 
 
5
  from urllib.parse import urlparse
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
 
8
- 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"}
9
- VOID_TAGS = {"br", "hr"}
10
- DROP_CONTENT_TAGS = {"button", "form", "iframe", "input", "math", "object", "script", "style", "svg"}
11
- GLOBAL_ATTRIBUTES = {"aria-controls", "aria-expanded", "aria-label", "class", "role"}
12
- TAG_ATTRIBUTES = {"a": {"href", "rel", "target", "title"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan", "scope"}}
13
 
14
 
15
- def _safe_href(value: str) -> str | None:
16
- candidate = value.strip()
17
  if not candidate:
18
  return None
19
  parsed = urlparse(candidate)
20
  if parsed.scheme.lower() not in {"https", "mailto", "tel"}:
21
  return None
 
 
22
  return candidate
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  class _Sanitizer(HTMLParser):
26
- def __init__(self) -> None:
27
  super().__init__(convert_charrefs=True)
28
  self.output: list[str] = []
29
  self.drop_depth = 0
30
-
31
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
32
- tag = tag.lower()
33
- if tag in DROP_CONTENT_TAGS:
34
- self.drop_depth += 1
35
- return
36
- if self.drop_depth or tag not in ALLOWED_TAGS:
37
- return
38
- rendered: list[str] = []
39
- for raw_name, raw_value in attrs:
40
- name = raw_name.lower()
41
- if name.startswith("on") or name == "style":
42
- continue
43
- if name not in GLOBAL_ATTRIBUTES and name not in TAG_ATTRIBUTES.get(tag, set()):
44
- continue
 
 
 
 
 
45
  value = raw_value or ""
46
  if tag == "a" and name == "href":
47
- value = _safe_href(value) or ""
48
- if not value:
49
- continue
50
- if tag == "a" and name == "target" and value != "_blank":
51
- continue
52
- rendered.append(f' {name}="{escape(value, quote=True)}"')
53
- if tag == "a":
54
- rendered = [item for item in rendered if not item.startswith(" rel=")]
55
- rendered.append(' rel="noopener noreferrer"')
56
- self.output.append(f"<{tag}{''.join(rendered)}>")
57
-
58
- def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
59
- self.handle_starttag(tag, attrs)
60
-
61
- def handle_endtag(self, tag: str) -> None:
62
- tag = tag.lower()
63
- if tag in DROP_CONTENT_TAGS:
64
- if self.drop_depth:
65
- self.drop_depth -= 1
66
- return
67
- if not self.drop_depth and tag in ALLOWED_TAGS and tag not in VOID_TAGS:
68
- self.output.append(f"</{tag}>")
69
-
70
  def handle_data(self, data: str) -> None:
71
  if not self.drop_depth:
 
 
 
 
 
72
  self.output.append(escape(data, quote=False))
73
-
74
-
75
- def sanitize_answer_html(value: str) -> str:
76
- parser = _Sanitizer()
77
  parser.feed(value or "")
78
  parser.close()
79
  return "".join(parser.output)
 
1
+ """Small allow-list sanitizer for answer fragments returned to the browser."""
2
+
3
+ from html import escape, unescape
4
  from html.parser import HTMLParser
5
+ import re
6
+ from typing import Collection
7
  from urllib.parse import urlparse
8
+
9
+
10
+ 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"}
11
+ VOID_TAGS = {"br", "hr"}
12
+ DROP_CONTENT_TAGS = {"button", "form", "iframe", "input", "math", "object", "script", "style", "svg"}
13
+ GLOBAL_ATTRIBUTES = {"aria-controls", "aria-expanded", "aria-label", "class", "role"}
14
+ TAG_ATTRIBUTES = {"a": {"href", "rel", "target", "title"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan", "scope"}}
15
+
16
+
17
+ _VISIBLE_URL_RE = re.compile(r"(?:https?://|mailto:|tel:)[^\s<>\"']+")
18
+ _MARKDOWN_LINK_RE = re.compile(
19
+ r"\[([^\]\n]+)\]\(\s*((?:https?://|mailto:|tel:)[^\s)]+)\s*\)"
20
+ )
21
 
22
 
23
+ def _normalise_url(value: str) -> str:
24
+ return unescape(value).strip().rstrip(".,;:!?)]}")
 
 
 
25
 
26
 
27
+ def _safe_href(value: str, allowed_urls: set[str] | None = None) -> str | None:
28
+ candidate = _normalise_url(value)
29
  if not candidate:
30
  return None
31
  parsed = urlparse(candidate)
32
  if parsed.scheme.lower() not in {"https", "mailto", "tel"}:
33
  return None
34
+ if allowed_urls is not None and candidate not in allowed_urls:
35
+ return None
36
  return candidate
37
 
38
 
39
+ def markdown_to_safe_html(value: str) -> str:
40
+ """Convert the small Markdown subset commonly emitted by an LLM.
41
+
42
+ LibBee's API contract is sanitized HTML. This prevents Markdown markers such
43
+ as ``**bold**`` and ``[label](url)`` from leaking into the browser as text.
44
+ """
45
+ value = value or ""
46
+ value = _MARKDOWN_LINK_RE.sub(
47
+ lambda m: f'<a href="{escape(m.group(2), quote=True)}" target="_blank">{escape(m.group(1))}</a>',
48
+ value,
49
+ )
50
+ value = re.sub(r"\*\*([^*\n]+)\*\*", r"<strong>\1</strong>", value)
51
+ value = re.sub(r"(?m)^\s{0,3}#{1,6}\s+(.+)$", r"<strong>\1</strong><br>", value)
52
+ return value
53
+
54
+
55
  class _Sanitizer(HTMLParser):
56
+ def __init__(self, allowed_urls: Collection[str] | None = None) -> None:
57
  super().__init__(convert_charrefs=True)
58
  self.output: list[str] = []
59
  self.drop_depth = 0
60
+ self.allowed_urls = (
61
+ {_normalise_url(url) for url in allowed_urls if _normalise_url(url)}
62
+ if allowed_urls is not None
63
+ else None
64
+ )
65
+
66
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
67
+ tag = tag.lower()
68
+ if tag in DROP_CONTENT_TAGS:
69
+ self.drop_depth += 1
70
+ return
71
+ if self.drop_depth or tag not in ALLOWED_TAGS:
72
+ return
73
+ rendered: list[str] = []
74
+ for raw_name, raw_value in attrs:
75
+ name = raw_name.lower()
76
+ if name.startswith("on") or name == "style":
77
+ continue
78
+ if name not in GLOBAL_ATTRIBUTES and name not in TAG_ATTRIBUTES.get(tag, set()):
79
+ continue
80
  value = raw_value or ""
81
  if tag == "a" and name == "href":
82
+ value = _safe_href(value, self.allowed_urls) or ""
83
+ if not value:
84
+ continue
85
+ if tag == "a" and name == "target" and value != "_blank":
86
+ continue
87
+ rendered.append(f' {name}="{escape(value, quote=True)}"')
88
+ if tag == "a":
89
+ rendered = [item for item in rendered if not item.startswith(" rel=")]
90
+ rendered.append(' rel="noopener noreferrer"')
91
+ self.output.append(f"<{tag}{''.join(rendered)}>")
92
+
93
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
94
+ self.handle_starttag(tag, attrs)
95
+
96
+ def handle_endtag(self, tag: str) -> None:
97
+ tag = tag.lower()
98
+ if tag in DROP_CONTENT_TAGS:
99
+ if self.drop_depth:
100
+ self.drop_depth -= 1
101
+ return
102
+ if not self.drop_depth and tag in ALLOWED_TAGS and tag not in VOID_TAGS:
103
+ self.output.append(f"</{tag}>")
104
+
105
  def handle_data(self, data: str) -> None:
106
  if not self.drop_depth:
107
+ if self.allowed_urls is not None:
108
+ data = _VISIBLE_URL_RE.sub(
109
+ lambda m: m.group(0) if _normalise_url(m.group(0)) in self.allowed_urls else "",
110
+ data,
111
+ )
112
  self.output.append(escape(data, quote=False))
113
+
114
+
115
+ def sanitize_answer_html(value: str, allowed_urls: Collection[str] | None = None) -> str:
116
+ parser = _Sanitizer(allowed_urls)
117
  parser.feed(value or "")
118
  parser.close()
119
  return "".join(parser.output)