@bygghammar/cms-platform 0.95.0 → 0.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,78 @@ matches `package.json` exactly. See
8
8
  Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
9
9
  from the published npm tarballs.
10
10
 
11
+ ## 0.96.0
12
+
13
+ Security release. No migration, and no export was added or removed.
14
+
15
+ - **Rich text is sanitized by a parser, not by patterns** (`./html/sanitize-html`).
16
+ The old sanitizer copied the text between the tags it recognised through as
17
+ written, so an unterminated tag at the end of a fragment (for example an image
18
+ tag with an event handler and no closing `>`) survived, and in server
19
+ rendering the markup after the fragment completed it into a live element.
20
+ Markup is now read the way a browser's HTML tokenizer reads it (every quoting
21
+ form, comments, DOCTYPE, CDATA, raw-text elements, a tag cut off by the end of
22
+ the input) and the output is written from scratch: allowlisted elements and
23
+ attributes only, attributes decoded once and escaped once, and every `<` and
24
+ `>` in text escaped. The editor's allowlist, URL rules and booking-link
25
+ `&amp;` handling are unchanged.
26
+ - Tightened: a `data:` image is accepted only in an image's `src`, never as a
27
+ link target. `svg`, `math`, `template`, `textarea`, `title`, `xmp`,
28
+ `noembed`, `noframes` and `plaintext` are dropped together with their
29
+ content.
30
+ - `htmlToText` now returns plain text, with entities decoded.
31
+ - **The builder and its preview only talk to each other**
32
+ (`./site/routes/preview-page`, the page builder). The public preview route
33
+ used to render blocks posted by any window that put a marker string in its
34
+ message, and both ends posted to `"*"`. Now:
35
+ - the preview accepts the builder's messages only from its parent frame,
36
+ from its own origin or an origin the host lists, in one builder session
37
+ with increasing sequence numbers, shape-checked and at most 4 MB;
38
+ - the builder accepts the preview's messages only from its own iframe, and
39
+ every message names its target origin.
40
+ - **Host change, only for a builder on another origin:** list that origin in
41
+ the preview page deps as `trustedEditorOrigins`. A builder on the site's own
42
+ origin (Voyara's) needs no configuration.
43
+ - **One outbound request policy** for every address staff or configuration
44
+ chooses: media import, form webhooks and popup opt-in webhooks. Each of these
45
+ was a server-side request forgery primitive. Every request now:
46
+ - allows only the schemes its policy names (webhooks: https only), with no
47
+ credentials in the URL, no local or single-label host names and no
48
+ privileged ports other than 80 and 443;
49
+ - looks the host up and goes ahead only if every address it resolves to is
50
+ public (IPv4 and IPv6 special-purpose ranges, including mapped, NAT64 and
51
+ 6to4 forms), then connects to the address it checked (pinned, so a DNS
52
+ answer that changes in between cannot redirect it), with TLS still verified
53
+ for the host name;
54
+ - follows redirects itself, checking and pinning every hop, up to a cap;
55
+ - runs under one deadline for the whole request, which also holds when a
56
+ lookup, connection or response body ignores cancellation (Node's DNS
57
+ lookup cannot be cancelled), and reads at most a fixed number of bytes,
58
+ streaming, whatever `Content-Length` says;
59
+ - reports failures by category, with messages that carry no address, query
60
+ string or response body.
61
+ - **What each path does with that policy:**
62
+ - **Media import:** a downloaded file is stored as what its first bytes say
63
+ it is (JPEG, PNG, GIF, WebP, AVIF, MP4, MOV, M4V, WebM, MP3, WAV, Ogg or
64
+ PDF), with that extension, never as the server's content type or the URL's
65
+ extension. SVG and HTML are refused. Public redirects to other sites are
66
+ still followed, carrying only plain request headers.
67
+ - **Form and popup webhooks never follow a redirect to another site.** A
68
+ signed body, its signature, timestamp, event id and idempotency key reach
69
+ only the configured origin (a redirect within that origin is followed;
70
+ popup webhooks report the refusal as `blocked-endpoint`). When a redirect
71
+ turns a request into a GET, entity and signature headers are dropped.
72
+ **Receivers that redirect to another host must be configured with their
73
+ final address.**
74
+ - **Form webhooks** are checked when the form is saved, report a failure by
75
+ status only (the receiver's reply is never copied into it), and are never
76
+ sent unsigned when signing fails.
77
+ - **Addresses in records keep the origin only.** Webhook delivery records,
78
+ errors and the form audit trail show a webhook as its origin plus a fixed
79
+ `/[redacted]` marker, never its path, query string or credentials, because
80
+ receivers put keys in paths as well as in query strings. Settings whose name
81
+ says they are a secret are recorded as changed, never with a value.
82
+
11
83
  ## 0.95.0
12
84
 
13
85
  - **Pages can be link-only, and unpublished from the page itself.** Listing is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.95.0",
3
+ "version": "0.96.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -56,6 +56,12 @@ import {
56
56
  type InsertRefusal,
57
57
  } from "../blocks/block-tree";
58
58
  import { subscribeSharedDrafts } from "../cms/shared-draft-store";
59
+ import {
60
+ acceptPreviewMessage,
61
+ EDITOR_SOURCE,
62
+ newPreviewSession,
63
+ previewOriginOf,
64
+ } from "../site/preview-messaging";
59
65
  import { SaveGlobalDialog, type SaveGlobalResult } from "./SaveGlobalDialog";
60
66
 
61
67
  function uid(prefix = "b") {
@@ -570,6 +576,27 @@ export function PageEditorView({
570
576
  );
571
577
  const iframeRef = useRef<HTMLIFrameElement>(null);
572
578
  const [previewReady, setPreviewReady] = useState(false);
579
+ // The canvas is spoken to only at its own origin, in one session, in order
580
+ // (see preview-messaging); its answers count only from its own window.
581
+ const previewSession = useMemo(() => newPreviewSession(), []);
582
+ const previewSeq = useRef(0);
583
+ const previewOrigin = useMemo(
584
+ () =>
585
+ typeof window === "undefined" ? null : previewOriginOf(previewUrl(pageKey), window.location.href),
586
+ [previewUrl, pageKey],
587
+ );
588
+ const postToPreview = useCallback(
589
+ (message: Record<string, unknown>) => {
590
+ const frame = iframeRef.current?.contentWindow;
591
+ if (!frame || !previewOrigin) return;
592
+ previewSeq.current += 1;
593
+ frame.postMessage(
594
+ { source: EDITOR_SOURCE, session: previewSession, seq: previewSeq.current, ...message },
595
+ previewOrigin,
596
+ );
597
+ },
598
+ [previewOrigin, previewSession],
599
+ );
573
600
 
574
601
  const currentBlocks = controlledBlocks ?? blocks;
575
602
 
@@ -581,19 +608,15 @@ export function PageEditorView({
581
608
  // Push draft to iframe whenever blocks/locale/selection change.
582
609
  useEffect(() => {
583
610
  if (!previewReady) return;
584
- iframeRef.current?.contentWindow?.postMessage(
585
- {
586
- source: "lovable-editor",
587
- type: "draft",
588
- blocks: currentBlocks,
589
- globals,
590
- selectedId,
591
- locale,
592
- sharedContent: sharedDrafts,
593
- },
594
- "*",
595
- );
596
- }, [currentBlocks, globals, selectedId, locale, previewReady, sharedDrafts]);
611
+ postToPreview({
612
+ type: "draft",
613
+ blocks: currentBlocks,
614
+ globals,
615
+ selectedId,
616
+ locale,
617
+ sharedContent: sharedDrafts,
618
+ });
619
+ }, [currentBlocks, globals, selectedId, locale, previewReady, sharedDrafts, postToPreview]);
597
620
 
598
621
  useEffect(() => {
599
622
  const t = setTimeout(() => {
@@ -604,27 +627,27 @@ export function PageEditorView({
604
627
  }, [pageKey, currentBlocks, globals, locale]);
605
628
 
606
629
  /** Select a block from the outline and scroll the preview to it. */
607
- const selectAndReveal = useCallback((id: string) => {
608
- setSelectedId(id);
609
- iframeRef.current?.contentWindow?.postMessage(
610
- { source: "lovable-editor", type: "scrollTo", blockId: id },
611
- "*",
612
- );
613
- }, []);
630
+ const selectAndReveal = useCallback(
631
+ (id: string) => {
632
+ setSelectedId(id);
633
+ postToPreview({ type: "scrollTo", blockId: id });
634
+ },
635
+ [postToPreview],
636
+ );
614
637
 
615
638
  // Listen for iframe messages.
616
639
  useEffect(() => {
617
640
  const onMsg = (ev: MessageEvent) => {
618
- const d = ev.data as { source?: string; type?: string; blockId?: string };
619
- if (d?.source !== "lovable-preview") return;
641
+ const d = acceptPreviewMessage(ev, { frame: iframeRef.current?.contentWindow, previewOrigin });
642
+ if (!d) return;
620
643
  if (d.type === "ready") setPreviewReady(true);
621
- if (d.type === "select" && d.blockId) {
644
+ if (d.type === "select") {
622
645
  if (findBlockDeep(currentBlocks, d.blockId)) setSelectedId(d.blockId);
623
646
  }
624
647
  };
625
648
  window.addEventListener("message", onMsg);
626
649
  return () => window.removeEventListener("message", onMsg);
627
- }, [currentBlocks]);
650
+ }, [currentBlocks, previewOrigin]);
628
651
 
629
652
  // Keep the outline highlight visible whenever the selection changes.
630
653
  useEffect(() => {
@@ -2,6 +2,8 @@
2
2
  // Every backoffice change to a form (settings + fields) is turned into a list
3
3
  // of short change descriptions so the Activity view answers who/what/when.
4
4
 
5
+ import { redactUrl } from "../util/redact-url";
6
+
5
7
  export type FormAuditChange = {
6
8
  /** Machine scope: "form" | "settings" | "field" */
7
9
  scope: "form" | "settings" | "field";
@@ -26,6 +28,24 @@ export type FormSnapshotInput = {
26
28
 
27
29
  const MAX_LEN = 160;
28
30
 
31
+ /** Delivery settings whose value is a credential: the audit says it changed, never what to. */
32
+ const SECRET_SETTING = /secret|token|password|apikey|api_key/i;
33
+
34
+ /** An address as the audit shows it: its origin only — keys travel in paths and query strings. */
35
+ function addressOnly(v: unknown): unknown {
36
+ if (typeof v !== "string" || v.trim() === "") return v;
37
+ const redacted = redactUrl(v.trim());
38
+ return redacted === "(invalid address)" ? "(not a valid address)" : redacted;
39
+ }
40
+
41
+ /** A delivery setting's value as the audit may record it. */
42
+ function auditable(group: string, key: string, v: unknown): unknown {
43
+ if (group !== "private_settings") return v;
44
+ if (SECRET_SETTING.test(key)) return v == null || v === "" ? v : "(hidden)";
45
+ if (/url$/i.test(key)) return addressOnly(v);
46
+ return v;
47
+ }
48
+
29
49
  function short(v: unknown): string {
30
50
  if (v == null) return "—";
31
51
  if (typeof v === "string") return v.length > MAX_LEN ? `${v.slice(0, MAX_LEN)}…` : v || "—";
@@ -143,8 +163,8 @@ export function diffForms(
143
163
  out.push({
144
164
  scope: "settings",
145
165
  label: `${label}: ${humanKey(k)}`,
146
- from: short(a[k]),
147
- to: short(b[k]),
166
+ from: short(auditable(group, k, a[k])),
167
+ to: short(auditable(group, k, b[k])),
148
168
  });
149
169
  }
150
170
  }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * An HTML tokenizer for the sanitizer, following the WHATWG tokenizer states
3
+ * that matter for a fragment of body content. No DOM is needed, so it runs the
4
+ * same on the server and in the browser.
5
+ *
6
+ * It splits markup into text, start tags and end tags, the way a browser
7
+ * would. Comments, DOCTYPEs, CDATA sections and processing instructions are
8
+ * consumed and produce nothing. A tag that runs into the end of the input is
9
+ * dropped, as a browser drops it. The content of raw-text and RCDATA elements
10
+ * (`<script>`, `<style>`, `<textarea>`, …) is returned as one text token, as
11
+ * the browser's tree builder makes the tokenizer read it.
12
+ *
13
+ * Values are returned exactly as written — text and attribute values are not
14
+ * decoded here. The sanitizer re-serializes everything it keeps, so nothing
15
+ * from the input reaches its output without being re-encoded.
16
+ */
17
+
18
+ export type HtmlToken =
19
+ | { kind: "text"; raw: string }
20
+ | {
21
+ kind: "start";
22
+ name: string;
23
+ /** In source order; a repeated name keeps its first value, as browsers do. */
24
+ attrs: Array<{ name: string; raw: string }>;
25
+ selfClosing: boolean;
26
+ }
27
+ | { kind: "end"; name: string };
28
+
29
+ /** Elements whose content the browser reads as text up to their own end tag. */
30
+ const RAW_TEXT = new Set([
31
+ "script",
32
+ "style",
33
+ "xmp",
34
+ "iframe",
35
+ "noembed",
36
+ "noframes",
37
+ "noscript",
38
+ "title",
39
+ "textarea",
40
+ ]);
41
+
42
+ const isWhitespace = (c: string) =>
43
+ c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
44
+ const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
45
+
46
+ /** Lower-cases ASCII only, as the HTML tokenizer does for names. */
47
+ function asciiLower(value: string): string {
48
+ return value.replace(/[A-Z]+/g, (m) => m.toLowerCase());
49
+ }
50
+
51
+ export function tokenizeHtml(input: string): HtmlToken[] {
52
+ const html = input.replace(/\r\n?/g, "\n").replace(/\u0000/g, "�");
53
+ const tokens: HtmlToken[] = [];
54
+ const n = html.length;
55
+ let lower: string | undefined;
56
+ let i = 0;
57
+ let textStart = 0;
58
+
59
+ const flushText = (end: number) => {
60
+ if (end > textStart) tokens.push({ kind: "text", raw: html.slice(textStart, end) });
61
+ };
62
+
63
+ /** Skips a bogus comment: everything up to and including the next `>`. */
64
+ const skipBogusComment = (from: number): number => {
65
+ const end = html.indexOf(">", from);
66
+ return end < 0 ? n : end + 1;
67
+ };
68
+
69
+ /** Skips a comment whose `<!--` ends at `from`. */
70
+ const skipComment = (from: number): number => {
71
+ if (html.startsWith(">", from)) return from + 1; // <!-->
72
+ if (html.startsWith("->", from)) return from + 2; // <!--->
73
+ let j = from;
74
+ while (j < n) {
75
+ if (html.startsWith("-->", j)) return j + 3;
76
+ if (html.startsWith("--!>", j)) return j + 4;
77
+ j++;
78
+ }
79
+ return n;
80
+ };
81
+
82
+ /**
83
+ * Reads a tag whose name starts at `from`. Returns the token and the index
84
+ * after the tag, or null when the input ends inside it.
85
+ */
86
+ const readTag = (
87
+ from: number,
88
+ closing: boolean,
89
+ ): { token: HtmlToken; next: number } | null => {
90
+ let j = from;
91
+ while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">") j++;
92
+ const name = asciiLower(html.slice(from, j));
93
+ const attrs: Array<{ name: string; raw: string }> = [];
94
+ const seen = new Set<string>();
95
+ let selfClosing = false;
96
+
97
+ for (;;) {
98
+ // Before attribute name.
99
+ while (j < n && (isWhitespace(html[j]!) || html[j] === "/")) {
100
+ if (html[j] === "/") {
101
+ // Self-closing start tag state.
102
+ if (html[j + 1] === ">") {
103
+ selfClosing = true;
104
+ j++;
105
+ break;
106
+ }
107
+ }
108
+ j++;
109
+ }
110
+ if (j >= n) return null;
111
+ if (html[j] === ">") {
112
+ j++;
113
+ break;
114
+ }
115
+ // Attribute name: a leading `=` is part of the name.
116
+ const nameStart = j;
117
+ j++;
118
+ while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">" && html[j] !== "=") {
119
+ j++;
120
+ }
121
+ const attrName = asciiLower(html.slice(nameStart, j));
122
+ // After attribute name.
123
+ while (j < n && isWhitespace(html[j]!)) j++;
124
+ let raw = "";
125
+ if (j < n && html[j] === "=") {
126
+ j++;
127
+ while (j < n && isWhitespace(html[j]!)) j++;
128
+ if (j >= n) return null;
129
+ const quote = html[j];
130
+ if (quote === '"' || quote === "'") {
131
+ const end = html.indexOf(quote, j + 1);
132
+ if (end < 0) return null;
133
+ raw = html.slice(j + 1, end);
134
+ j = end + 1;
135
+ } else if (html[j] !== ">") {
136
+ const start = j;
137
+ while (j < n && !isWhitespace(html[j]!) && html[j] !== ">") j++;
138
+ raw = html.slice(start, j);
139
+ }
140
+ }
141
+ if (j >= n) return null;
142
+ if (!seen.has(attrName)) {
143
+ seen.add(attrName);
144
+ attrs.push({ name: attrName, raw });
145
+ }
146
+ }
147
+
148
+ const token: HtmlToken = closing
149
+ ? { kind: "end", name }
150
+ : { kind: "start", name, attrs, selfClosing };
151
+ return { token, next: j };
152
+ };
153
+
154
+ while (i < n) {
155
+ const lt = html.indexOf("<", i);
156
+ if (lt < 0) break;
157
+ const c = html[lt + 1] ?? "";
158
+
159
+ if (isAlpha(c)) {
160
+ const tag = readTag(lt + 1, false);
161
+ flushText(lt);
162
+ if (!tag) {
163
+ // The input ends inside the tag: the browser drops it.
164
+ i = textStart = n;
165
+ break;
166
+ }
167
+ tokens.push(tag.token);
168
+ i = textStart = tag.next;
169
+ const name = tag.token.kind === "start" ? tag.token.name : "";
170
+ if (name === "plaintext") {
171
+ flushText(n);
172
+ textStart = n;
173
+ i = n;
174
+ break;
175
+ }
176
+ if (RAW_TEXT.has(name)) {
177
+ // Read the content as text, up to `</name` followed by a delimiter.
178
+ // ASCII-only lower-casing keeps every index the same as in `html`.
179
+ lower ??= asciiLower(html);
180
+ let j = i;
181
+ let end = -1;
182
+ while ((j = lower.indexOf(`</${name}`, j)) >= 0) {
183
+ const after = html[j + 2 + name.length] ?? "";
184
+ if (after === "" || isWhitespace(after) || after === "/" || after === ">") {
185
+ end = j;
186
+ break;
187
+ }
188
+ j += 2 + name.length;
189
+ }
190
+ if (end < 0) {
191
+ if (i < n) tokens.push({ kind: "text", raw: html.slice(i) });
192
+ i = textStart = n;
193
+ break;
194
+ }
195
+ if (end > i) tokens.push({ kind: "text", raw: html.slice(i, end) });
196
+ const close = readTag(end + 2, true);
197
+ tokens.push({ kind: "end", name });
198
+ i = textStart = close ? close.next : n;
199
+ }
200
+ continue;
201
+ }
202
+
203
+ if (c === "/") {
204
+ const d = html[lt + 2] ?? "";
205
+ if (isAlpha(d)) {
206
+ const tag = readTag(lt + 2, true);
207
+ flushText(lt);
208
+ if (!tag) {
209
+ i = textStart = n;
210
+ break;
211
+ }
212
+ tokens.push(tag.token);
213
+ i = textStart = tag.next;
214
+ continue;
215
+ }
216
+ if (d === ">") {
217
+ flushText(lt);
218
+ i = textStart = lt + 3;
219
+ continue;
220
+ }
221
+ if (d === "") {
222
+ // `</` at the end of the input is text.
223
+ i = n;
224
+ break;
225
+ }
226
+ flushText(lt);
227
+ i = textStart = skipBogusComment(lt + 2);
228
+ continue;
229
+ }
230
+
231
+ if (c === "!") {
232
+ flushText(lt);
233
+ if (html.startsWith("--", lt + 2)) {
234
+ i = textStart = skipComment(lt + 4);
235
+ } else {
236
+ // DOCTYPE, CDATA and anything else after `<!` end at the next `>`.
237
+ i = textStart = skipBogusComment(lt + 2);
238
+ }
239
+ continue;
240
+ }
241
+
242
+ if (c === "?") {
243
+ flushText(lt);
244
+ i = textStart = skipBogusComment(lt + 1);
245
+ continue;
246
+ }
247
+
248
+ // A `<` that starts nothing is text.
249
+ i = lt + 1;
250
+ }
251
+
252
+ flushText(n);
253
+ return tokens;
254
+ }
@@ -5,7 +5,16 @@
5
5
  * only staff can author it, the output is sanitized on every render so a
6
6
  * pasted `<script>`, inline event handler or `javascript:` URL can never end
7
7
  * up in a page.
8
+ *
9
+ * The input is tokenized the way a browser tokenizes it (`html-tokenizer`),
10
+ * and the output is written from scratch: allowed elements with allowed,
11
+ * re-encoded attributes, and text with every `<` and `>` escaped. Nothing from
12
+ * the input is ever copied through as written, so markup the sanitizer does
13
+ * not recognise — an unterminated tag, a stray `<` — reaches the page as
14
+ * visible text and can never be completed into an element by the markup that
15
+ * follows it.
8
16
  */
17
+ import { tokenizeHtml } from "./html-tokenizer";
9
18
 
10
19
  const ALLOWED_TAGS = new Set([
11
20
  "p", "br", "hr", "span", "div",
@@ -19,9 +28,13 @@ const ALLOWED_TAGS = new Set([
19
28
 
20
29
  /** Tags whose entire contents are dropped. */
21
30
  const DROP_WITH_CONTENT = new Set([
22
- "script", "style", "iframe", "object", "embed", "noscript", "template", "svg", "math",
31
+ "script", "style", "iframe", "object", "noscript", "template", "svg", "math",
32
+ "textarea", "title", "xmp", "noembed", "noframes", "plaintext",
23
33
  ]);
24
34
 
35
+ /** Dropped elements that can contain themselves, so their end tags are counted. */
36
+ const NESTING = new Set(["svg", "math", "object", "template"]);
37
+
25
38
  const VOID_TAGS = new Set(["br", "hr", "img", "col"]);
26
39
 
27
40
  const GLOBAL_ATTRS = new Set(["class", "style", "id", "title", "dir", "lang"]);
@@ -58,10 +71,13 @@ const ALLOWED_CSS = new Set([
58
71
 
59
72
  const SAFE_URL = /^(https?:\/\/|mailto:|tel:|\/|#|\.\/|\.\.\/)/i;
60
73
 
61
- function safeUrl(raw: string): string | null {
74
+ function safeUrl(raw: string, attribute: string): string | null {
62
75
  const value = raw.trim().replace(/[\u0000-\u001f]/g, "");
63
76
  if (!value) return null;
64
- if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,/i.test(value)) return value;
77
+ // An inline image is an image source, never a link target.
78
+ if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,/i.test(value)) {
79
+ return attribute === "src" ? value : null;
80
+ }
65
81
  if (!SAFE_URL.test(value)) return null;
66
82
  if (/^\s*javascript:/i.test(value)) return null;
67
83
  return value;
@@ -127,21 +143,17 @@ export function decodeAttr(value: string): string {
127
143
  );
128
144
  }
129
145
 
130
- const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
131
-
132
- function sanitizeAttrs(tag: string, raw: string): string {
133
- const allowed = TAG_ATTRS[tag];
146
+ function sanitizeAttrs(tag: string, attrs: ReadonlyArray<{ name: string; raw: string }>): string {
147
+ const allowed = Object.prototype.hasOwnProperty.call(TAG_ATTRS, tag) ? TAG_ATTRS[tag] : undefined;
134
148
  const parts: string[] = [];
135
- let m: RegExpExecArray | null;
136
- ATTR_RE.lastIndex = 0;
137
- while ((m = ATTR_RE.exec(raw))) {
138
- const name = m[1].toLowerCase();
139
- let value = decodeAttr(m[3] ?? m[4] ?? m[5] ?? "");
149
+ for (const attr of attrs) {
150
+ const name = attr.name;
151
+ let value = decodeAttr(attr.raw);
140
152
  if (name.startsWith("on")) continue;
141
153
  if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
142
154
  if (name === "href" || name === "src" || name === "srcset") {
143
155
  if (name === "srcset") continue;
144
- const url = safeUrl(value);
156
+ const url = safeUrl(value, name);
145
157
  if (!url) continue;
146
158
  value = url;
147
159
  }
@@ -164,30 +176,56 @@ function sanitizeAttrs(tag: string, raw: string): string {
164
176
  return parts.length ? " " + parts.join(" ") : "";
165
177
  }
166
178
 
179
+ /** A character reference as written in text: kept, since it can only decode to text. */
180
+ const CHAR_REF = /^&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{0,31});/;
181
+
182
+ /** Text as written, made safe to emit: no `<` or `>`, and every `&` a reference. */
183
+ function escapeText(raw: string): string {
184
+ let out = "";
185
+ for (let i = 0; i < raw.length; i++) {
186
+ const c = raw[i]!;
187
+ if (c === "<") out += "&lt;";
188
+ else if (c === ">") out += "&gt;";
189
+ else if (c === "&") out += CHAR_REF.test(raw.slice(i, i + 42)) ? "&" : "&amp;";
190
+ else out += c;
191
+ }
192
+ return out;
193
+ }
194
+
167
195
  /** Sanitize an HTML fragment down to the editor's allowlist. */
168
196
  export function sanitizeHtml(input: string | undefined | null): string {
169
197
  if (!input) return "";
170
- // Remove comments and any element whose content must go with it.
171
- let html = input.replace(/<!--[\s\S]*?-->/g, "");
172
- for (const tag of DROP_WITH_CONTENT) {
173
- html = html.replace(new RegExp(`<${tag}[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "");
174
- html = html.replace(new RegExp(`<${tag}\\b[^>]*\\/?>`, "gi"), "");
175
- }
176
-
177
198
  const open: string[] = [];
178
199
  let out = "";
179
- let last = 0;
180
- const tagRe = /<\s*(\/)?\s*([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
181
- let m: RegExpExecArray | null;
182
-
183
- while ((m = tagRe.exec(html))) {
184
- out += html.slice(last, m.index);
185
- last = m.index + m[0].length;
186
- const closing = Boolean(m[1]);
187
- const tag = m[2].toLowerCase();
200
+ /** The dropped element being skipped, and how deep inside it we are. */
201
+ let skipping: { tag: string; depth: number } | null = null;
202
+
203
+ for (const token of tokenizeHtml(input)) {
204
+ if (skipping) {
205
+ if (token.kind === "start" && token.name === skipping.tag && NESTING.has(token.name)) {
206
+ if (!token.selfClosing) skipping.depth++;
207
+ } else if (token.kind === "end" && token.name === skipping.tag) {
208
+ if (--skipping.depth === 0) skipping = null;
209
+ }
210
+ continue;
211
+ }
212
+
213
+ if (token.kind === "text") {
214
+ out += escapeText(token.raw);
215
+ continue;
216
+ }
217
+
218
+ const tag = token.name;
219
+ if (token.kind === "start" && DROP_WITH_CONTENT.has(tag)) {
220
+ // A self-closing <svg/> or <math/> is empty; every other one has content.
221
+ if (!(token.selfClosing && (tag === "svg" || tag === "math"))) {
222
+ skipping = { tag, depth: 1 };
223
+ }
224
+ continue;
225
+ }
188
226
  if (!ALLOWED_TAGS.has(tag)) continue;
189
227
 
190
- if (closing) {
228
+ if (token.kind === "end") {
191
229
  if (VOID_TAGS.has(tag)) continue;
192
230
  const at = open.lastIndexOf(tag);
193
231
  if (at < 0) continue;
@@ -197,22 +235,19 @@ export function sanitizeHtml(input: string | undefined | null): string {
197
235
  }
198
236
 
199
237
  if (VOID_TAGS.has(tag)) {
200
- out += `<${tag}${sanitizeAttrs(tag, m[3] ?? "")} />`;
238
+ out += `<${tag}${sanitizeAttrs(tag, token.attrs)} />`;
201
239
  continue;
202
240
  }
203
- out += `<${tag}${sanitizeAttrs(tag, m[3] ?? "")}>`;
241
+ out += `<${tag}${sanitizeAttrs(tag, token.attrs)}>`;
204
242
  open.push(tag);
205
243
  }
206
- out += html.slice(last);
207
244
  while (open.length) out += `</${open.pop()}>`;
208
245
  return out;
209
246
  }
210
247
 
211
248
  /** Plain-text preview of rich text (used for thumbnails and summaries). */
212
249
  export function htmlToText(input: string | undefined | null): string {
213
- return sanitizeHtml(input)
214
- .replace(/<[^>]+>/g, " ")
215
- .replace(/&nbsp;/g, " ")
250
+ return decodeAttr(sanitizeHtml(input).replace(/<[^>]+>/g, " "))
216
251
  .replace(/\s+/g, " ")
217
252
  .trim();
218
253
  }