@tokenoftrust/storefront-runner 4.15.0 → 5.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.15.0",
3
+ "version": "5.0.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -14,10 +14,10 @@
14
14
  *
15
15
  * - PINNED_ASSET_*: a tenant's locally-pinned bundle asset (/_a/<sha256>.<ext>,
16
16
  * e.g. a favicon) -- a hash-verified, already-reviewed file. Includes SVG.
17
- * - CAPTURABLE_RASTER_MEDIA_TYPES: what a *remote* capture (original-image-capture.ts)
18
- * may fetch and rehost. Deliberately EXCLUDES svg: an externally-sourced SVG can
19
- * embed <script> and is an XSS vector, so only local, already-reviewed SVGs may
20
- * ever reach the pinned-asset path above.
17
+ * - CAPTURABLE_RASTER_MEDIA_TYPES: the raster formats capture reads by their magic
18
+ * bytes. An SVG is never stored as fetched: capture and upload pass it through the
19
+ * one sanitizer (`svg-sanitize.ts`) and store only its output, which is what lets a
20
+ * sealed `remote` or `upload` entry carry `image/svg+xml` (`sealedMediaTypeAllowed`).
21
21
  */
22
22
 
23
23
  export const PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE: Readonly<Record<string, string>> = Object.freeze({
@@ -41,20 +41,20 @@ export const CAPTURABLE_RASTER_MEDIA_TYPES = Object.freeze([
41
41
 
42
42
  export type CapturableRasterMediaType = (typeof CAPTURABLE_RASTER_MEDIA_TYPES)[number];
43
43
 
44
- /** Comma-joined for an HTTP `Accept` header -- derived, never a second literal. */
45
- export const CAPTURABLE_RASTER_ACCEPT_HEADER = CAPTURABLE_RASTER_MEDIA_TYPES.join(",");
46
-
47
44
  /**
48
- * An author upload may also be an SVG: the upload path is the one ingest that sanitizes it
49
- * (`svg-sanitize.ts`) before it enters the pool, so a sealed `upload` entry may carry it while a
50
- * captured (`local`/`remote`) entry never does.
45
+ * An author upload or a remote capture may also be an SVG: both pass it through the sanitizer
46
+ * (`svg-sanitize.ts`) and store only its output, so a sealed `upload` or `remote` entry may carry
47
+ * it. A `local` entry is a repo file capture reads by raster magic, never an SVG.
51
48
  */
52
49
  export const UPLOADABLE_MEDIA_TYPES = Object.freeze([...CAPTURABLE_RASTER_MEDIA_TYPES, "image/svg+xml"] as const);
53
50
 
54
51
  export type UploadableMediaType = (typeof UPLOADABLE_MEDIA_TYPES)[number];
55
52
 
53
+ /** Comma-joined for a remote capture's HTTP `Accept` header -- derived, never a second literal. */
54
+ export const CAPTURE_ACCEPT_HEADER = UPLOADABLE_MEDIA_TYPES.join(",");
55
+
56
56
  /** May a sealed original of provenance `kind` carry `mediaType`? Every reader of a manifest asks this. */
57
57
  export function sealedMediaTypeAllowed(kind: unknown, mediaType: unknown): boolean {
58
- const types: readonly string[] = kind === "upload" ? UPLOADABLE_MEDIA_TYPES : CAPTURABLE_RASTER_MEDIA_TYPES;
58
+ const types: readonly string[] = kind === "upload" || kind === "remote" ? UPLOADABLE_MEDIA_TYPES : CAPTURABLE_RASTER_MEDIA_TYPES;
59
59
  return typeof mediaType === "string" && types.includes(mediaType);
60
60
  }
@@ -48,12 +48,26 @@ export function imageCensusByType(input: {
48
48
  }): ImageTypeCounts {
49
49
  if (!Array.isArray(input.tenantOrigins)) throw new TypeError("tenantOrigins is required");
50
50
  const out = {} as ImageTypeCounts;
51
- for (const type of IMAGE_TYPES) {
52
- const group = input.artifacts.filter((artifact) => imageTypeOf(artifact.path) === type);
53
- const skipped = (input.skipped ?? []).filter((path) => imageTypeOf(path) === type).length;
54
- const census = scanImageDependencies(group, { tenantId: input.tenantId, tenantOrigins: input.tenantOrigins }).census;
55
- out[type] = { files: group.length + skipped, references: census.references + skipped,
56
- uncaptured: census.captureRequired + census.securityErrors + census.deferred + skipped };
51
+ for (const type of IMAGE_TYPES) out[type] = { files: 0, references: 0, uncaptured: 0 };
52
+ for (const artifact of input.artifacts) out[imageTypeOf(artifact.path)].files += 1;
53
+ for (const path of input.skipped ?? []) {
54
+ const row = out[imageTypeOf(path)];
55
+ row.files += 1;
56
+ row.references += 1;
57
+ row.uncaptured += 1;
58
+ }
59
+ // One scan of the whole set. A custom property declared in one type and used in another is
60
+ // resolved only when both files are visible; scanning each type alone defers that use.
61
+ const scanned = scanImageDependencies(input.artifacts, { tenantId: input.tenantId, tenantOrigins: input.tenantOrigins });
62
+ const indexed = input.artifacts.filter((artifact) => !isDurableAssetsDeclarationPath(artifact.path));
63
+ for (const dependency of scanned.dependencies) {
64
+ const path = indexed[dependency.artifactIndex]?.path;
65
+ if (path == null) continue;
66
+ const row = out[imageTypeOf(path)];
67
+ row.references += 1;
68
+ if (dependency.disposition === "capture-required" || dependency.disposition === "security-error" || dependency.disposition === "deferred") {
69
+ row.uncaptured += 1;
70
+ }
57
71
  }
58
72
  return out;
59
73
  }
@@ -145,7 +145,8 @@ function supportedPoolEntry(entry: string, facts: ReleaseImageFacts, formats: Po
145
145
  if (!Object.hasOwn(formats.mediaTypeByExtension, ext)) return false;
146
146
  if (ext !== ".svg") return true;
147
147
  const bytes = facts.heldPoolBytes(entry);
148
- // A captured SVG is a sealed upload: it entered the pool only as sanitizer output (ADR 0038 §12).
148
+ // A captured SVG (a remote capture or an upload) entered the pool only as sanitizer output, which
149
+ // is a fixed point of the sanitizer (ADR 0038 §12).
149
150
  if (!bytes) return facts.captured(entry);
150
151
  const result = formats.sanitizeSvg(bytes);
151
152
  return result.ok && !result.changed;
@@ -15,11 +15,17 @@
15
15
  * `xml:space` and `xml:lang` are removed;
16
16
  * - references (`href`, `xlink:href`, every `url()`): only a same-document fragment (`#id`), a pool
17
17
  * object (`/_a/<sha256><ext>`) or an inline raster `data:` image. Any other reference is removed;
18
- * - `<style>` text with an escape, `@import`, a non-pool `url()` or a script-capable construct
19
- * refuses the image rather than being half-cleaned.
18
+ * - CSS: `<style>` text with an escape, a comment, `@import`, a non-pool `url()` or a script- or
19
+ * fetch-capable construct refuses the image rather than being half-cleaned. Every other kept
20
+ * attribute whose value could hold CSS (it contains `(` or `\`, as `fill`, `cursor` or `filter`
21
+ * can) passes the same CSS check or is removed. `<style>` text reads as the browser reads it:
22
+ * character data entity-decoded, a CDATA section raw.
20
23
  *
21
24
  * Output is the input with the removed spans cut out, byte for byte otherwise, so an already-clean
22
- * SVG keeps its bytes and therefore its address. Import-free.
25
+ * SVG keeps its bytes and therefore its address. Cutting a span out can join its neighbours into
26
+ * markup neither held (a `]]>`, a tag), so the output is read again and must come back unchanged:
27
+ * a sanitized SVG is a fixed point of this pass, which is what lets every later reader re-check it
28
+ * for free. Import-free.
23
29
  */
24
30
 
25
31
  export const SVG_SANITIZER_ID = "svg-allowlist-1";
@@ -122,17 +128,22 @@ function urlTargets(css: string): string[] | null {
122
128
  }
123
129
 
124
130
  const UNSAFE_CSS = ["@import", "@font-face", "@namespace", "expression(", "javascript:", "vbscript:", "-moz-binding",
125
- "behavior", "image-set(", "cross-fade(", "element(", "src("];
131
+ "behavior", "image-set(", "image(", "cross-fade(", "element(", "src("];
126
132
 
127
- /** Why `css` is unsafe, or null when it is safe to keep. */
133
+ /**
134
+ * Why `css` is unsafe, or null when it is safe to keep. A comment is refused outright rather than
135
+ * stripped: a CSS string can hold a `/*` the browser never reads as one, so stripping by pattern
136
+ * would hide the text between it and the next comment closer from this check while the browser
137
+ * applies it.
138
+ */
128
139
  function unsafeCss(css: string): string | null {
129
140
  if (css.includes("\\")) return "it uses a CSS escape sequence";
130
- const stripped = css.replace(/\/\*[\s\S]*?\*\//g, "");
131
- if (stripped.includes("/*")) return "it has an unterminated CSS comment";
132
- const lower = stripped.toLowerCase();
141
+ if (css.includes("/*")) return "it has a CSS comment";
142
+ const lower = css.toLowerCase();
133
143
  const construct = UNSAFE_CSS.find((item) => lower.includes(item));
134
144
  if (construct) return `it uses \`${construct}\``;
135
- const targets = urlTargets(stripped);
145
+ if (/url[ \t\r\n\f]+\(/.test(lower)) return "it has a url() that cannot be read exactly";
146
+ const targets = urlTargets(css);
136
147
  if (!targets) return "it has a url() that cannot be read exactly";
137
148
  if (targets.some((target) => !isPoolSafeReference(target))) return "it references a url() outside this image or the pool";
138
149
  return null;
@@ -158,14 +169,36 @@ export function sanitizeSvg(bytes: Uint8Array, limits: Partial<SvgSanitizeLimits
158
169
  return refuse("not-utf8", "it is not UTF-8 text");
159
170
  }
160
171
  try {
161
- return sanitizeText(source, bound);
172
+ const first = sanitizeText(source, bound);
173
+ if (!first.changed) return result(first, bytes);
174
+ const spliced = new Refusal("malformed", "removing its unsafe parts would form new markup");
175
+ let again: Pass;
176
+ try {
177
+ again = sanitizeText(first.output, bound);
178
+ } catch (error) {
179
+ if (error instanceof Refusal) throw spliced;
180
+ throw error;
181
+ }
182
+ if (again.changed) throw spliced;
183
+ return result(first, bytes);
162
184
  } catch (error) {
163
185
  if (error instanceof Refusal) return refuse(error.reason, error.detail);
164
186
  throw error;
165
187
  }
166
188
  }
167
189
 
168
- function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeResult {
190
+ interface Pass { output: string; changed: boolean; removedElements: number; removedAttributes: number }
191
+
192
+ /** An unchanged document is answered as the very bytes it arrived as, so its address stands. */
193
+ function result(pass: Pass, input: Uint8Array): SvgSanitizeResult {
194
+ return {
195
+ ok: true, bytes: pass.changed ? new TextEncoder().encode(pass.output) : input,
196
+ changed: pass.changed, removedElements: pass.removedElements, removedAttributes: pass.removedAttributes,
197
+ };
198
+ }
199
+
200
+ /** One allowlist pass over `source`; throws {@link Refusal}. */
201
+ function sanitizeText(source: string, limits: SvgSanitizeLimits): Pass {
169
202
  const out: string[] = [];
170
203
  const stack: Open[] = [];
171
204
  let i = 0;
@@ -200,6 +233,7 @@ function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeRes
200
233
  if (end === -1) throw malformed("a CDATA section is not closed");
201
234
  if (!dropped) {
202
235
  if (!open?.style) throw malformed("a CDATA section appears outside <style>");
236
+ // A CDATA section is raw CSS to the browser: never entity-decoded.
203
237
  open.style.css += source.slice(i + 9, end);
204
238
  out.push(source.slice(i, end + 3));
205
239
  }
@@ -223,9 +257,7 @@ function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeRes
223
257
  const top = stack.pop();
224
258
  if (!top || top.name !== match[1]) throw malformed(`</${match[1]}> does not close the open element`);
225
259
  if (top.style) {
226
- const decoded = decodeEntities(top.style.css);
227
- if (decoded === null) throw new Refusal("entity", "a <style> uses an undefined entity");
228
- const unsafe = unsafeCss(decoded);
260
+ const unsafe = unsafeCss(top.style.css);
229
261
  if (unsafe) throw new Refusal("unsafe-style", `a <style> element is unsafe: ${unsafe}`);
230
262
  }
231
263
  if (!top.dropped) out.push(match[0]);
@@ -297,9 +329,11 @@ function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeRes
297
329
  out.push(text);
298
330
  } else {
299
331
  if (text.includes("]]>")) throw malformed("text contains `]]>`");
300
- if (decodeEntities(text) === null) throw new Refusal("entity", "its text uses an undefined entity");
332
+ const decodedText = decodeEntities(text);
333
+ if (decodedText === null) throw new Refusal("entity", "its text uses an undefined entity");
301
334
  if (!dropped) {
302
- if (open?.style) open.style.css += text;
335
+ // Character data is entity-decoded before the browser reads it as CSS.
336
+ if (open?.style) open.style.css += decodedText;
303
337
  out.push(text);
304
338
  }
305
339
  }
@@ -308,10 +342,7 @@ function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeRes
308
342
  if (!rootSeen) throw new Refusal("not-svg", "it has no root element");
309
343
  if (stack.length > 0) throw malformed(`<${stack[stack.length - 1]!.name}> is not closed`);
310
344
  const output = out.join("");
311
- return {
312
- ok: true, bytes: new TextEncoder().encode(output), changed: output !== source,
313
- removedElements, removedAttributes,
314
- };
345
+ return { output, changed: output !== source, removedElements, removedAttributes };
315
346
  }
316
347
 
317
348
  /** Whether a kept element keeps this attribute (its decoded value). */
@@ -322,10 +353,8 @@ function keepAttribute(name: string, value: string, isRoot: boolean): boolean {
322
353
  if (name.includes(":") && !ALLOWED_PREFIXED_ATTRIBUTES.has(name)) return false;
323
354
  if (name === "href" || name === "xlink:href") return isPoolSafeReference(value);
324
355
  if (/javascript:|vbscript:/i.test(value.replace(/[\s\u0000-\u001f]+/g, ""))) return false;
325
- if (name === "style") return unsafeCss(value) === null;
326
- if (/url[ \t\r\n\f]*\(/i.test(value)) {
327
- const targets = urlTargets(value);
328
- return !!targets && targets.every(isPoolSafeReference) && !/url[ \t\r\n\f]+\(/i.test(value);
329
- }
356
+ // Presentation attributes (`fill`, `cursor`, `filter`, …) are CSS values too: one that could hold
357
+ // a function or an escape passes the same check as `style`.
358
+ if (name === "style" || value.includes("(") || value.includes("\\")) return unsafeCss(value) === null;
330
359
  return true;
331
360
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The hostile-SVG corpus every SVG intake is tested against: the sanitizer itself, remote capture
3
+ * and author upload. Each case is either `neutralized` (the stored bytes keep the drawing with the
4
+ * attack removed) or refused with the sanitizer reason named. `assertInert` is the property every
5
+ * stored SVG must hold.
6
+ */
7
+ import type { SvgRefusalReason } from "../../src/svg-sanitize";
8
+
9
+ const NS = 'xmlns="http://www.w3.org/2000/svg"';
10
+ const XLINK = 'xmlns:xlink="http://www.w3.org/1999/xlink"';
11
+
12
+ export interface MaliciousSvgCase {
13
+ label: string;
14
+ svg: string;
15
+ /** `neutralized`: accepted with the attack cut out; otherwise the refusal reason. */
16
+ expect: "neutralized" | SvgRefusalReason;
17
+ }
18
+
19
+ export const MALICIOUS_SVG_CORPUS: readonly MaliciousSvgCase[] = [
20
+ { label: "script element", svg: `<svg ${NS}><script>alert(1)</script><rect/></svg>`, expect: "neutralized" },
21
+ { label: "script with CDATA", svg: `<svg ${NS}><script><![CDATA[alert(1) && x < y]]></script></svg>`, expect: "neutralized" },
22
+ { label: "namespaced XHTML script", svg: `<svg ${NS}><h:script xmlns:h="http://www.w3.org/1999/xhtml">alert(1)</h:script></svg>`, expect: "neutralized" },
23
+ { label: "foreignObject with HTML", svg: `<svg ${NS}><foreignObject><iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe></foreignObject></svg>`, expect: "neutralized" },
24
+ { label: "onload handler", svg: `<svg ${NS} onload="alert(1)"><rect/></svg>`, expect: "neutralized" },
25
+ { label: "mixed-case handlers", svg: `<svg ${NS}><rect OnClick="alert(1)" onmouseover='x()'/></svg>`, expect: "neutralized" },
26
+ { label: "javascript: href", svg: `<svg ${NS}><a href="javascript:alert(1)"><text>x</text></a></svg>`, expect: "neutralized" },
27
+ { label: "entity-obfuscated javascript: xlink:href", svg: `<svg ${NS} ${XLINK}><a xlink:href="&#106;avascript:alert(1)"><rect/></a></svg>`, expect: "neutralized" },
28
+ { label: "vbscript: in an attribute", svg: `<svg ${NS}><rect filter="vbscript:msgbox(1)"/></svg>`, expect: "neutralized" },
29
+ { label: "data:text/html href", svg: `<svg ${NS}><a href="data:text/html,x"><rect/></a></svg>`, expect: "neutralized" },
30
+ { label: "nested SVG data URI", svg: `<svg ${NS}><image href="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="/></svg>`, expect: "neutralized" },
31
+ { label: "external href image", svg: `<svg ${NS}><image href="https://evil.example/track.png"/></svg>`, expect: "neutralized" },
32
+ { label: "external xlink:href image", svg: `<svg ${NS} ${XLINK}><image xlink:href="https://evil.example/track.png"/></svg>`, expect: "neutralized" },
33
+ { label: "external use sprite", svg: `<svg ${NS}><use href="https://evil.example/sprite.svg#a"/></svg>`, expect: "neutralized" },
34
+ { label: "set rewrites href", svg: `<svg ${NS}><a href="#x"><set attributeName="href" to="javascript:alert(1)"/><rect/></a></svg>`, expect: "neutralized" },
35
+ { label: "animate rewrites href", svg: `<svg ${NS}><a href="#x"><animate attributeName="href" values="javascript:alert(1)"/></a></svg>`, expect: "neutralized" },
36
+ { label: "external fill url", svg: `<svg ${NS}><rect fill="url(https://evil.example/p.svg#a)"/></svg>`, expect: "neutralized" },
37
+ { label: "external style attribute url", svg: `<svg ${NS}><rect style="fill:url('https://evil.example/x')"/></svg>`, expect: "neutralized" },
38
+ // Design-review CSS bypasses.
39
+ { label: "escaped url in a presentation attribute", svg: `<svg ${NS}><rect fill="u\\72l(https://evil.example/x)"/></svg>`, expect: "neutralized" },
40
+ { label: "-webkit-image-set in cursor", svg: `<svg ${NS}><rect cursor="-webkit-image-set(&quot;https://evil.example/c.png&quot; 1x), auto"/></svg>`, expect: "neutralized" },
41
+ { label: "image-set in a style attribute", svg: `<svg ${NS}><rect style="cursor:image-set('https://evil.example/c.png' 1x)"/></svg>`, expect: "neutralized" },
42
+ { label: "comment opener hidden in a CSS string", svg: `<svg ${NS}><style>.a{font-family:"/*"}.b{fill:url(https://evil.example/x)}.c{x:"*/"}</style><rect class="b"/></svg>`, expect: "unsafe-style" },
43
+ { label: "entity-encoded comment markers in CDATA", svg: `<svg ${NS}><style><![CDATA[&#47;&#42;]]>.b{fill:url(https://evil.example/x)}<![CDATA[&#42;&#47;]]></style><rect class="b"/></svg>`, expect: "unsafe-style" },
44
+ { label: "entity-encoded comment around a CDATA url", svg: `<svg ${NS}><style><![CDATA[&#47;&#42; .b{fill:url(https://evil.example/x)} &#42;&#47;]]></style></svg>`, expect: "unsafe-style" },
45
+ { label: "removed span splices ]]>", svg: `<svg ${NS}><text>]]<script/>></text></svg>`, expect: "malformed" },
46
+ { label: "style @import", svg: `<svg ${NS}><style>@import url(https://evil.example/x.css);</style></svg>`, expect: "unsafe-style" },
47
+ { label: "style CSS escape", svg: `<svg ${NS}><style>.a{background:\\75rl(https://evil.example/x)}</style></svg>`, expect: "unsafe-style" },
48
+ { label: "DOCTYPE internal subset (billion laughs)", svg: `<!DOCTYPE svg [<!ENTITY a "aaaa"><!ENTITY b "&a;&a;&a;&a;">]><svg ${NS}><text>&b;</text></svg>`, expect: "entity" },
49
+ { label: "external entity", svg: `<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg ${NS}><text>&x;</text></svg>`, expect: "entity" },
50
+ { label: "undefined entity", svg: `<svg ${NS}><text>&nbsp;</text></svg>`, expect: "entity" },
51
+ { label: "processing instruction stylesheet", svg: `<svg ${NS}><?xml-stylesheet href="https://evil.example/x.css"?></svg>`, expect: "malformed" },
52
+ { label: "root is HTML", svg: `<html xmlns="http://www.w3.org/1999/xhtml"><svg ${NS}/></html>`, expect: "not-svg" },
53
+ { label: "element-count cap", svg: `<svg ${NS}>${"<rect/>".repeat(100_001)}</svg>`, expect: "too-complex" },
54
+ { label: "depth cap", svg: `<svg ${NS}>${"<g>".repeat(300)}${"</g>".repeat(300)}</svg>`, expect: "too-complex" },
55
+ ];
56
+
57
+ /** A clean logo as design tools export it: must round-trip byte-identical. */
58
+ export const BENIGN_LOGO =
59
+ `<?xml version="1.0" encoding="UTF-8"?>\n<svg ${NS} ${XLINK} viewBox="0 0 120 40" width="120" height="40">\n` +
60
+ ` <title>Logo</title>\n <defs>\n <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
61
+ `<stop offset="0" stop-color="#ff6a00"/><stop offset="1" stop-color="#ee0979"/></linearGradient>\n` +
62
+ ` <clipPath id="c"><rect width="120" height="40" rx="6"/></clipPath>\n` +
63
+ ` <style>.wordmark{fill:url(#g);font-family:Helvetica,Arial,sans-serif;font-weight:700}</style>\n </defs>\n` +
64
+ ` <g clip-path="url(#c)" transform="translate(2 2) rotate(0)">\n` +
65
+ ` <path d="M0 0h36v36H0z" fill="url(#g)" stroke="#000" stroke-width="0.5"/>\n` +
66
+ ` <use xlink:href="#c"/>\n <text x="44" y="26" class="wordmark">Vape &amp; Co &#169;</text>\n </g>\n</svg>\n`;
67
+
68
+ /** Nothing in stored bytes can run script or reach outside the image and the pool. */
69
+ export function inertViolations(output: string): string[] {
70
+ const checks: Array<[string, RegExp]> = [
71
+ ["script-capable element", /<script|<foreignObject|<iframe|<animate|<set[\s>/]|<handler|<listener|<h:script/i],
72
+ ["event handler", /\son[a-z]+\s*=/i],
73
+ ["script url", /javascript:|vbscript:|&#106;avascript/i],
74
+ ["active data url", /data:text\/html|data:image\/svg/i],
75
+ ["external reference", /https?:\/\/(?!www\.w3\.org\/(?:2000\/svg|1999\/xlink))|\/\/evil/i],
76
+ ["css escape or image-set", /\\|image-set/i],
77
+ ];
78
+ return checks.filter(([, pattern]) => pattern.test(output)).map(([name]) => name);
79
+ }