"""Parses a contenteditable div's serialized innerHTML (widget_dialog_ text.js's POSTed content_html) into a plain, storage-safe run structure -- list of paragraphs, each a list of {"text", "bold", "italic", "underline", "color", "bg"} runs -- for the text widget (see models.TextWidgetConfig, app/widgets/text.py). This is the sanitization boundary the checklist's stored-XSS note (CLAUDE.md, another linked user could have set this) is about: raw HTML never round-trips back into any browser DOM. Only text content and a small fixed set of style flags survive parsing; every tag, attribute, and CSS property not explicitly recognized below is simply discarded -- there's no allowlist-of-tags-to-keep-as-HTML step where something could slip through unescaped, because nothing is ever re-emitted as HTML at all. The dialog reconstructs its editor from this same run structure via safe DOM calls (createElement/textContent), never innerHTML.""" from __future__ import annotations import re from html.parser import HTMLParser # Generous ceilings, not exact UX limits -- just stop a direct API call # (bypassing the dialog's own textarea-ish size) from storing something # pathologically large. MAX_INPUT_CHARS bounds parse work; MAX_TOTAL_CHARS # bounds what's actually kept (a widget's on-panel region is a few # hundred pixels -- there is no legible use for more than a few thousand # characters of body text there). MAX_INPUT_CHARS = 200_000 MAX_TOTAL_CHARS = 4_000 _BASE_STYLE = {"bold": False, "italic": False, "underline": False, "color": None, "bg": None} _BLOCK_TAGS = {"div", "p", "li"} _VOID_TAGS = {"br"} _HEX6 = re.compile(r"^#([0-9a-fA-F]{6})$") _HEX3 = re.compile(r"^#([0-9a-fA-F]{3})$") _RGB = re.compile(r"^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$") _STYLE_PROP = re.compile(r"([a-zA-Z-]+)\s*:\s*([^;]+)") _BOLD_WEIGHTS = {"bold", "bolder", "600", "700", "800", "900"} def _normalize_color(value: str) -> str | None: """"#1a2b3c" / "#abc" / "rgb(26, 43, 60)" -> "#1a2b3c". Anything else (a CSS named color, "transparent", garbage) -> None, i.e. dropped -- this is the one place an arbitrary style-attribute string could try to smuggle something through, so it's a strict allowlist match, not a best-effort parse.""" value = value.strip() m = _HEX6.match(value) if m: return "#" + m.group(1).lower() m = _HEX3.match(value) if m: return "#" + "".join(c * 2 for c in m.group(1)).lower() m = _RGB.match(value) if m: r, g, b = (max(0, min(255, int(x))) for x in m.groups()) return f"#{r:02x}{g:02x}{b:02x}" return None class _RichTextParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.paragraphs: list[list[dict]] = [[]] self._style_stack: list[dict] = [_BASE_STYLE] self._at_line_start = True def _break(self, tag: str) -> None: # Coalesces contenteditable's per-line block wrapping (Chrome # wraps every line in its own
even without a deliberate # blank line) down to one paragraph break per actual line gap, # while still letting an explicit
when already at a fresh # line start (Chrome's "

" idiom for a blank line, # or a genuine double Shift+Enter) add a real blank paragraph. if not self._at_line_start: self.paragraphs.append([]) self._at_line_start = True elif tag == "br": self.paragraphs.append([]) def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag in _BLOCK_TAGS or tag in _VOID_TAGS: self._break(tag) if tag in _VOID_TAGS: return style = dict(self._style_stack[-1]) attrs_dict = {k: v for k, v in attrs if v is not None} if tag in ("b", "strong"): style["bold"] = True elif tag in ("i", "em"): style["italic"] = True elif tag == "u": style["underline"] = True elif tag == "font": color = _normalize_color(attrs_dict.get("color", "")) if color: style["color"] = color elif tag == "span": for prop, val in _STYLE_PROP.findall(attrs_dict.get("style", "")): prop = prop.strip().lower() val = val.strip() if prop == "color": color = _normalize_color(val) if color: style["color"] = color elif prop == "background-color": color = _normalize_color(val) if color: style["bg"] = color elif prop == "font-weight" and val.lower() in _BOLD_WEIGHTS: style["bold"] = True elif prop == "font-style" and val.lower() == "italic": style["italic"] = True elif prop == "text-decoration" and "underline" in val.lower(): style["underline"] = True # Pushed for every non-void tag, including ones with no # recognized style effect (script/a/img/...) -- keeps push/pop # balanced against handle_endtag regardless of tag, without # needing to track which tags actually pushed something. self._style_stack.append(style) def handle_endtag(self, tag: str) -> None: if tag in _VOID_TAGS: return if len(self._style_stack) > 1: self._style_stack.pop() def handle_data(self, data: str) -> None: if not data: return style = self._style_stack[-1] self.paragraphs[-1].append({"text": data, **style}) if data.strip(): self._at_line_start = False def parse_rich_text(html: str) -> list[list[dict]]: """The sanitization entry point -- see module docstring. Always returns a valid (possibly all-empty) paragraphs structure, never raises for malformed markup (html.parser tolerates unclosed/ mismatched tags; handle_endtag's length guard tolerates an over-popped stack).""" parser = _RichTextParser() parser.feed(html[:MAX_INPUT_CHARS]) parser.close() paragraphs = parser.paragraphs total = 0 truncated: list[list[dict]] = [] for para in paragraphs: new_para: list[dict] = [] for run in para: remaining = MAX_TOTAL_CHARS - total if remaining <= 0: break text = run["text"][:remaining] total += len(text) new_para.append({**run, "text": text}) truncated.append(new_para) if total >= MAX_TOTAL_CHARS: break return truncated def has_text(paragraphs: list[list[dict]] | None) -> bool: if not paragraphs: return False return any(run["text"].strip() for para in paragraphs for run in para)