interactive-markdown 0.1.1 → 0.2.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.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Sanitize SVG markup before injecting into the DOM.
3
3
  * Strips script, foreignObject, event handlers, and external hrefs.
4
- * Safe shapes (rect, circle, line, path, text, g, defs, animate, …) are kept.
4
+ * Keeps shapes, SMIL animation, filters, internal <use>, and CSS in <style>
5
+ * (keyframes, dash motion) after a CSS pass that drops imports and remote urls.
5
6
  */
6
7
 
7
8
  /** Tags removed entirely (case-insensitive localName). */
@@ -14,11 +15,11 @@ const FORBIDDEN_TAGS = new Set([
14
15
  "link",
15
16
  "meta",
16
17
  "base",
17
- "use", // may reference external resources
18
18
  "handler", // legacy
19
- "style", // avoid CSS injection vectors in v1; presentation attrs remain
20
19
  ]);
21
20
 
21
+ const MOTION_TAGS = new Set(["animate", "set", "animatetransform", "animatemotion"]);
22
+
22
23
  /**
23
24
  * Optional allowlist. If an element is not forbidden and not in this set,
24
25
  * it is still kept (unknown SVG elements are generally harmless once scripts/hrefs are gone).
@@ -44,6 +45,7 @@ export const SAFE_SVG_TAGS = Object.freeze([
44
45
  "animate",
45
46
  "animatetransform",
46
47
  "animatemotion",
48
+ "mpath",
47
49
  "set",
48
50
  "lineargradient",
49
51
  "radialgradient",
@@ -52,9 +54,36 @@ export const SAFE_SVG_TAGS = Object.freeze([
52
54
  "mask",
53
55
  "pattern",
54
56
  "marker",
57
+ "filter",
58
+ "fegaussianblur",
59
+ "femerge",
60
+ "femergenode",
61
+ "feoffset",
62
+ "feflood",
63
+ "fecomposite",
64
+ "feblend",
65
+ "fecolormatrix",
66
+ "fedropshadow",
67
+ "fecomponenttransfer",
68
+ "fefuncr",
69
+ "fefuncg",
70
+ "fefuncb",
71
+ "fefunca",
72
+ "femorphology",
73
+ "feturbulence",
74
+ "fedisplacementmap",
75
+ "feimage",
76
+ "fetile",
77
+ "fediffuselighting",
78
+ "fespecularlighting",
79
+ "fedistantlight",
80
+ "fepointlight",
81
+ "fespotlight",
55
82
  "image", // only with safe href (data:image or #)
56
83
  "switch",
57
84
  "a", // href must be safe (# only in practice)
85
+ "style", // sanitized CSS only; renderer scopes it to the figure
86
+ "use", // fragment hrefs only
58
87
  ]);
59
88
 
60
89
  /**
@@ -65,11 +94,150 @@ export function isSafeHref(href) {
65
94
  if (href == null || href === "") return true;
66
95
  const v = String(href).trim().toLowerCase();
67
96
  if (v.startsWith("#")) return true;
68
- if (v.startsWith("data:image/")) return true;
69
- // Block javascript:, http(s):, file:, and any other scheme or relative URL
97
+ if (v.startsWith("data:image/") && !v.startsWith("data:image/svg")) return true;
98
+ // Block javascript:, http(s):, file:, svg data urls, and any other scheme or relative URL
99
+ return false;
100
+ }
101
+
102
+ /**
103
+ * Drop CSS that can load remote resources or break out of a style element.
104
+ * Keeps @keyframes, animation, and url(#fragment) / raster data images.
105
+ * @param {string} css
106
+ * @returns {string}
107
+ */
108
+ export function sanitizeCss(css) {
109
+ let s = String(css ?? "");
110
+ for (let pass = 0; pass < 2; pass++) {
111
+ s = stripCssComments(s);
112
+ s = decodeCssEscapes(s);
113
+ }
114
+ s = stripCssComments(s);
115
+ s = s.replace(/<!--/g, "").replace(/-->/g, "");
116
+ // Prevent </style> breakout when the markup is later parsed as HTML.
117
+ s = s.replace(/</g, "");
118
+ s = stripAtBlocks(s, ["font-face", "document"]);
119
+ s = s.replace(/@import\b[^;{]*;?/gi, "");
120
+ s = s.replace(/@(charset|namespace)\b[^;{]*;?/gi, "");
121
+ s = s.replace(/expression\s*\(/gi, "(");
122
+ s = s.replace(/javascript\s*:/gi, "");
123
+ s = s.replace(/-moz-binding\s*:[^;}]*/gi, "");
124
+ s = s.replace(/behavior\s*:[^;}]*/gi, "");
125
+ s = s.replace(/position\s*:\s*fixed/gi, "position:static");
126
+ s = replaceUnsafeUrls(s);
127
+ return s.trim();
128
+ }
129
+
130
+ function stripCssComments(css) {
131
+ return String(css).replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\*[\s\S]*$/g, "");
132
+ }
133
+
134
+ /** Decode CSS hex and single-character escapes so obfuscated url()/javascript: cannot slip through. */
135
+ function decodeCssEscapes(input) {
136
+ let out = "";
137
+ for (let i = 0; i < input.length; i++) {
138
+ if (input[i] !== "\\") {
139
+ out += input[i];
140
+ continue;
141
+ }
142
+ const hex = /^[0-9a-fA-F]{1,6}/.exec(input.slice(i + 1));
143
+ if (hex) {
144
+ const cp = parseInt(hex[0], 16);
145
+ i += hex[0].length;
146
+ const next = input[i + 1];
147
+ if (next === " " || next === "\n" || next === "\r" || next === "\t" || next === "\f") i += 1;
148
+ if (cp > 0 && cp <= 0x10ffff) out += String.fromCodePoint(cp);
149
+ continue;
150
+ }
151
+ if (i + 1 < input.length) {
152
+ out += input[i + 1];
153
+ i += 1;
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * @param {string} css
161
+ * @param {string[]} names at-rule names without @
162
+ */
163
+ function stripAtBlocks(css, names) {
164
+ const re = new RegExp(`@(${names.join("|")})\\b`, "gi");
165
+ let out = "";
166
+ let last = 0;
167
+ let m;
168
+ while ((m = re.exec(css))) {
169
+ out += css.slice(last, m.index);
170
+ let i = re.lastIndex;
171
+ while (i < css.length && css[i] !== "{" && css[i] !== ";") i++;
172
+ if (css[i] === ";") {
173
+ i++;
174
+ } else if (css[i] === "{") {
175
+ let depth = 0;
176
+ for (; i < css.length; i++) {
177
+ if (css[i] === "{") depth++;
178
+ else if (css[i] === "}") {
179
+ depth--;
180
+ if (depth === 0) {
181
+ i++;
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ }
187
+ last = i;
188
+ re.lastIndex = i;
189
+ }
190
+ out += css.slice(last);
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * @param {string} body
196
+ * @returns {boolean}
197
+ */
198
+ function isSafeCssUrl(body) {
199
+ let v = String(body || "").trim();
200
+ if (
201
+ (v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
202
+ (v.startsWith("'") && v.endsWith("'") && v.length >= 2)
203
+ ) {
204
+ v = v.slice(1, -1).trim();
205
+ }
206
+ if (!v) return false;
207
+ const low = v.toLowerCase();
208
+ if (v.startsWith("#")) return !/[\s"'()]/.test(v);
209
+ if (low.startsWith("data:image/") && !low.startsWith("data:image/svg")) return true;
70
210
  return false;
71
211
  }
72
212
 
213
+ function replaceUnsafeUrls(css) {
214
+ let out = "";
215
+ let i = 0;
216
+ const lower = css.toLowerCase();
217
+ while (i < css.length) {
218
+ const idx = lower.indexOf("url(", i);
219
+ if (idx === -1) {
220
+ out += css.slice(i);
221
+ break;
222
+ }
223
+ out += css.slice(i, idx);
224
+ let j = idx + 4;
225
+ let depth = 1;
226
+ while (j < css.length && depth > 0) {
227
+ const ch = css[j];
228
+ if (ch === "(") depth++;
229
+ else if (ch === ")") depth--;
230
+ if (depth > 0) j++;
231
+ else break;
232
+ }
233
+ if (depth !== 0) break;
234
+ const body = css.slice(idx + 4, j);
235
+ out += isSafeCssUrl(body) ? `url(${body.trim()})` : "none";
236
+ i = j + 1;
237
+ }
238
+ return out;
239
+ }
240
+
73
241
  function getDOMParser() {
74
242
  const P = globalThis.DOMParser;
75
243
  if (!P) throw new Error("DOMParser is not available");
@@ -105,6 +273,45 @@ function removeAttr(el, name) {
105
273
  }
106
274
  }
107
275
 
276
+ function hrefOf(el) {
277
+ return (
278
+ el.getAttribute("href") ||
279
+ el.getAttribute("xlink:href") ||
280
+ ""
281
+ );
282
+ }
283
+
284
+ /**
285
+ * SMIL can animate href or event-handler attributes. Drop those animations.
286
+ * @param {Element} el
287
+ * @param {string} tag
288
+ * @returns {boolean} true if the element was removed
289
+ */
290
+ function dropDangerousAnimation(el, tag) {
291
+ if (!MOTION_TAGS.has(tag)) return false;
292
+ const attrName = String(el.getAttribute("attributeName") || "")
293
+ .trim()
294
+ .toLowerCase()
295
+ .replace(/^.*:/, "");
296
+ if (
297
+ attrName.startsWith("on") ||
298
+ attrName === "href" ||
299
+ attrName === "style"
300
+ ) {
301
+ el.parentNode?.removeChild(el);
302
+ return true;
303
+ }
304
+ for (const key of ["from", "to", "by", "values"]) {
305
+ if (!el.hasAttribute(key)) continue;
306
+ const raw = el.getAttribute(key) ?? "";
307
+ if (!/url\s*\(|javascript\s*:|expression\s*\(/i.test(raw)) continue;
308
+ const next = sanitizeCss(raw);
309
+ if (!next) el.removeAttribute(key);
310
+ else el.setAttribute(key, next);
311
+ }
312
+ return false;
313
+ }
314
+
108
315
  /**
109
316
  * Sanitize an SVG string. Returns safe SVG markup (wraps fragments in <svg>).
110
317
  * @param {string} svgText
@@ -155,6 +362,17 @@ function walk(el) {
155
362
  return;
156
363
  }
157
364
 
365
+ // <use href="sprite.svg#id"> can pull in external documents. Fragments stay.
366
+ if (tag === "use") {
367
+ const href = String(hrefOf(el)).trim();
368
+ if (!href.startsWith("#") || !isSafeHref(href)) {
369
+ el.parentNode?.removeChild(el);
370
+ return;
371
+ }
372
+ }
373
+
374
+ if (dropDangerousAnimation(el, tag)) return;
375
+
158
376
  const attrs = Array.from(el.attributes || []);
159
377
  for (const attr of attrs) {
160
378
  const name = String(attr.name || "").toLowerCase();
@@ -180,13 +398,26 @@ function walk(el) {
180
398
  continue;
181
399
  }
182
400
 
183
- // xlink:actuate etc. left alone; block style urls with external schemes
184
- if (name === "style" && /url\s*\(\s*['"]?\s*(https?:|javascript:|data:text)/i.test(value)) {
185
- removeAttr(el, attr.name);
186
- continue;
401
+ // Presentation values and style="" — keep url(#id), drop remote loads.
402
+ if (name === "style" || /url\s*\(/i.test(value)) {
403
+ const next = sanitizeCss(value);
404
+ if (!next) removeAttr(el, attr.name);
405
+ else if (next !== value) el.setAttribute(attr.name, next);
187
406
  }
188
407
  }
189
408
 
409
+ if (tag === "style") {
410
+ const kids = Array.from(el.children || []).filter((n) => n.nodeType === 1);
411
+ for (const child of kids) child.parentNode?.removeChild(child);
412
+ const css = sanitizeCss(el.textContent || "");
413
+ if (!css) {
414
+ el.parentNode?.removeChild(el);
415
+ return;
416
+ }
417
+ el.textContent = css;
418
+ return;
419
+ }
420
+
190
421
  const children = Array.from(el.children || el.childNodes || []).filter(
191
422
  (n) => n.nodeType === 1
192
423
  );