@instructure/platform-sanitize 0.4.1 → 0.5.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/dist/index.js CHANGED
@@ -1,5 +1,63 @@
1
1
  import f from "dompurify";
2
- const w = /* @__PURE__ */ new Set([
2
+ const m = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]), u = "http://platform-sanitize.invalid/", p = /^\s*\/\//, b = (
3
+ // oxlint-disable-next-line no-control-regex -- intentional security guard
4
+ /^[\u0000-\u0020\u007F-\u00A0\u2000-\u200F\u2028\u2029\u202F\u205F\u2060\u3000\uFEFF]*(?:javascript|data|vbscript|file):/i
5
+ ), h = {
6
+ colon: ":",
7
+ // javascript:alert(1) → javascript:alert(1)
8
+ sol: "/",
9
+ // //evil.com → //evil.com (protocol-relative)
10
+ bsol: "\\",
11
+ // \\evil.com → \\evil.com
12
+ Tab: " ",
13
+ // java	script: — WHATWG strips mid-scheme tabs
14
+ NewLine: `
15
+ `
16
+ // java
script:
17
+ };
18
+ function w(s) {
19
+ return s.replace(/&#(\d+);/g, (t, e) => {
20
+ const r = Number(e);
21
+ if (!Number.isFinite(r) || r < 0 || r > 1114111) return "";
22
+ try {
23
+ return String.fromCodePoint(r);
24
+ } catch {
25
+ return "";
26
+ }
27
+ }).replace(/&#x([\da-f]+);/gi, (t, e) => {
28
+ const r = parseInt(e, 16);
29
+ if (!Number.isFinite(r) || r < 0 || r > 1114111) return "";
30
+ try {
31
+ return String.fromCodePoint(r);
32
+ } catch {
33
+ return "";
34
+ }
35
+ }).replace(/&([A-Za-z][A-Za-z0-9]*);/g, (t, e) => h[e] ?? t);
36
+ }
37
+ function A(s) {
38
+ if (!s || !s.trim()) return "about:blank";
39
+ const t = s.replace(/\\/g, "/");
40
+ if (p.test(t) || b.test(t)) return "about:blank";
41
+ if (/&[#A-Za-z]/.test(t)) {
42
+ const e = w(t);
43
+ if (p.test(e) || b.test(e))
44
+ return "about:blank";
45
+ try {
46
+ const r = new URL(e, u);
47
+ if (!r.href.startsWith(u) && !m.has(r.protocol))
48
+ return "about:blank";
49
+ } catch {
50
+ return "about:blank";
51
+ }
52
+ }
53
+ try {
54
+ const e = new URL(t, u);
55
+ return !m.has(e.protocol) || (e.protocol === "http:" || e.protocol === "https:") && (e.username || e.password) ? "about:blank" : e.href.startsWith(u) ? s : t.replace(/[\x00-\x1F\u2028\u2029]/g, "").replace(/%250[9ad]/gi, "").replace(/%0[9ad]/gi, "");
56
+ } catch {
57
+ return "about:blank";
58
+ }
59
+ }
60
+ const y = /* @__PURE__ */ new Set([
3
61
  // layout
4
62
  "display",
5
63
  "float",
@@ -241,7 +299,7 @@ const w = /* @__PURE__ */ new Set([
241
299
  "caret-color",
242
300
  "accent-color",
243
301
  "appearance"
244
- ]), h = /* @__PURE__ */ new Set([
302
+ ]), S = /* @__PURE__ */ new Set([
245
303
  "src",
246
304
  "href",
247
305
  "action",
@@ -252,7 +310,7 @@ const w = /* @__PURE__ */ new Set([
252
310
  "cite",
253
311
  "longdesc",
254
312
  "xlink:href"
255
- ]), y = ["content"], A = /url\s*\(\s*['"]?(?:[a-z][a-z0-9+\-.]*:|\/\/)/i, k = /* @__PURE__ */ new Set([
313
+ ]), k = ["content"], E = /url\s*\(\s*['"]?(?:[a-z][a-z0-9+\-.]*:|\/\/)/i, v = /* @__PURE__ */ new Set([
256
314
  "allow-downloads",
257
315
  "allow-forms",
258
316
  "allow-modals",
@@ -265,7 +323,14 @@ const w = /* @__PURE__ */ new Set([
265
323
  "allow-scripts",
266
324
  "allow-storage-access-by-user-activation",
267
325
  "allow-top-navigation-by-user-activation"
268
- ]), p = {
326
+ ]), x = {
327
+ "data-url": "data-custom-url",
328
+ "data-method": "data-custom-method",
329
+ "data-remote": "data-custom-remote",
330
+ "data-remove": "data-custom-remove",
331
+ "data-confirm": "data-custom-confirm",
332
+ "data-disable-with": "data-custom-disable-with"
333
+ }, _ = /* @__PURE__ */ new Set(["data-item-href"]), g = {
269
334
  // semantics and annotation are re-allowed because DOMPurify blocks all of
270
335
  // mathMlDisallowed by default. semantics is a safe structural wrapper;
271
336
  // annotation carries LaTeX source text (e.g. \frac{1}{2}); Canvas emits
@@ -280,7 +345,26 @@ const w = /* @__PURE__ */ new Set([
280
345
  // application/x-tex, …) are not integration points and are preserved, so this
281
346
  // layer no longer strips the benign annotation-xml the backend and TinyMCE
282
347
  // already allow.
283
- ADD_TAGS: ["iframe", "semantics", "annotation", "annotation-xml"],
348
+ // object/embed/param re-enable PDF/SVG/media embedding in course content
349
+ // (e.g. <object data="file.pdf" type="application/pdf">). Their Flash/ActiveX
350
+ // attributes (classid, codebase, pluginspage, wmode, allowscriptaccess) are
351
+ // intentionally NOT added — dead tech, and ADD_ATTR is global in DOMPurify so
352
+ // re-allowing them would also reopen that surface on iframe. DOMPurify's URI
353
+ // filter blocks javascript:/data: on data/src, matching Canvas's
354
+ // http/https/relative protocol rule for these tags.
355
+ //
356
+ // SECURITY ASSUMPTION (same one iframe already relies on): object/embed load
357
+ // their resource as a *document*, not an image — a same-origin SVG/HTML file
358
+ // (e.g. <object data="/files/123/download" type="image/svg+xml">) executes any
359
+ // <script> it contains in the embedding page's origin. The javascript:/data:
360
+ // filter does not help here because the URL is a benign same-origin path; the
361
+ // payload lives in the fetched file. This is NOT new attack surface: <iframe>
362
+ // is already allowed and has the identical capability (its srcdoc is stripped —
363
+ // see test). The defense for all three is that Canvas serves user-uploaded
364
+ // files from a separate sandboxed origin. object/embed also have no usable
365
+ // sandbox attribute, so the sandbox-token hook cannot constrain them — again,
366
+ // parity with an unsandboxed iframe, which is already permitted.
367
+ ADD_TAGS: ["iframe", "semantics", "annotation", "annotation-xml", "object", "embed", "param"],
284
368
  ADD_ATTR: [
285
369
  "allowfullscreen",
286
370
  "allow",
@@ -288,6 +372,12 @@ const w = /* @__PURE__ */ new Set([
288
372
  "sandbox",
289
373
  "data-media-id",
290
374
  "data-media-type",
375
+ // <object>'s resource URL attribute — not in DOMPurify's defaults. src/type/
376
+ // name/value/width/height (used by object/embed/param) already are. Added
377
+ // globally (DOMPurify has no per-tag attr scoping), which is harmless: `data`
378
+ // is in URL_ATTRS so it is protocol-filtered, and it is not a DOM-clobbering
379
+ // or URI-safe attribute name.
380
+ "data",
291
381
  // RCE produces target="_blank" for external links. Modern browsers treat
292
382
  // target="_blank" as implicit rel="noopener", and the backend allowlist
293
383
  // already includes target.
@@ -305,10 +395,6 @@ const w = /* @__PURE__ */ new Set([
305
395
  "intent",
306
396
  "arg"
307
397
  ],
308
- // Rails UJS turns data-method/data-remote/etc. on clickable elements into
309
- // state-changing requests carrying the victim's CSRF token. Strip them so
310
- // user-authored HTML cannot become a CSRF gadget when UJS is on the page.
311
- FORBID_ATTR: ["data-method", "data-remote", "data-url", "data-confirm", "data-disable-with"],
312
398
  // In browsers that support the Trusted Types API (Chromium, Firefox),
313
399
  // DOMPurify returns a TrustedHTML object rather than a plain string. The
314
400
  // exported sanitizeHtml signature declares string — a deliberate lie that
@@ -324,18 +410,18 @@ const w = /* @__PURE__ */ new Set([
324
410
  // confusion attacks where fragments like <svg> could influence parse context.
325
411
  FORCE_BODY: !0
326
412
  };
327
- let s = null;
328
- function E() {
329
- if (s) return s;
330
- s = typeof f == "function" ? f(window) : f, s.addHook("afterSanitizeAttributes", (t) => {
413
+ let a = null;
414
+ function R() {
415
+ if (a) return a;
416
+ a = typeof f == "function" ? f(window) : f, a.addHook("afterSanitizeAttributes", (t) => {
331
417
  if (!(t instanceof Element) || !t.hasAttribute("style")) return;
332
418
  const e = t.style, r = [];
333
419
  for (let i = 0; i < e.length; i++) {
334
420
  const l = e.item(i);
335
- w.has(l) || r.push(l);
421
+ y.has(l) || r.push(l);
336
422
  }
337
423
  for (const i of r) e.removeProperty(i);
338
- const a = /* @__PURE__ */ new Set([
424
+ const n = /* @__PURE__ */ new Set([
339
425
  "static",
340
426
  "relative",
341
427
  "absolute",
@@ -345,116 +431,71 @@ function E() {
345
431
  "revert",
346
432
  "revert-layer"
347
433
  ]), c = e.getPropertyValue("position").trim().toLowerCase();
348
- c && !a.has(c) && e.removeProperty("position");
434
+ c && !n.has(c) && e.removeProperty("position");
349
435
  const d = /* @__PURE__ */ new Set(["initial", "inherit", "unset", "revert", "revert-layer"]), o = e.getPropertyValue("opacity").trim().toLowerCase();
350
436
  o && !d.has(o) && (o.endsWith("%") ? parseFloat(o) / 100 : parseFloat(o)) < 0.05 && e.removeProperty("opacity");
351
- for (const i of y) {
437
+ for (const i of k) {
352
438
  const l = e.getPropertyValue(i);
353
- l && A.test(l) && e.removeProperty(i);
439
+ l && E.test(l) && e.removeProperty(i);
354
440
  }
355
441
  e.length === 0 && t.removeAttribute("style");
356
- }), s.addHook("uponSanitizeAttribute", (t, e) => {
357
- if (!h.has(e.attrName)) return;
442
+ }), a.addHook("uponSanitizeAttribute", (t, e) => {
443
+ if (!S.has(e.attrName)) return;
358
444
  const r = e.attrValue;
359
445
  /^\s*\/\//.test(r) ? (e.attrValue = r.trimStart().replace(/^\/\//, "https://"), e.keepAttr = !0) : /^\s*\\/.test(r) && (e.keepAttr = !1);
360
- }), s.addHook("afterSanitizeAttributes", (t) => {
446
+ }), a.addHook("uponSanitizeAttribute", (t, e) => {
447
+ if (!_.has(e.attrName)) return;
448
+ const r = e.attrValue;
449
+ if (!r.trim()) return;
450
+ const n = /^\s*\/\//.test(r) ? r.trimStart().replace(/^\/\//, "https://") : r;
451
+ e.attrValue = A(n);
452
+ }), a.addHook("uponSanitizeAttribute", (t, e) => {
453
+ const r = x[e.attrName];
454
+ !r || !(t instanceof Element) || (t.hasAttribute(r) || t.setAttribute(r, e.attrValue), e.keepAttr = !1);
455
+ }), a.addHook("afterSanitizeAttributes", (t) => {
361
456
  if (!(t instanceof Element) || !t.hasAttribute("srcset")) return;
362
- const r = (t.getAttribute("srcset") ?? "").split(","), a = (o) => o.trim().split(/\s+/)[0];
363
- if (r.some((o) => /^\s*\\/.test(a(o)))) {
457
+ const r = (t.getAttribute("srcset") ?? "").split(","), n = (o) => o.trim().split(/\s+/)[0];
458
+ if (r.some((o) => /^\s*\\/.test(n(o)))) {
364
459
  t.removeAttribute("srcset");
365
460
  return;
366
461
  }
367
462
  let c = !1;
368
463
  const d = r.map((o) => {
369
- const i = a(o);
464
+ const i = n(o);
370
465
  if (!i.startsWith("//")) return o;
371
466
  c = !0;
372
467
  const l = o.indexOf(i);
373
468
  return o.slice(0, l) + "https://" + i.slice(2) + o.slice(l + i.length);
374
469
  });
375
470
  c && t.setAttribute("srcset", d.join(","));
376
- }), s.addHook("afterSanitizeAttributes", (t) => {
377
- var a;
378
- if (!(t instanceof Element) || t.tagName !== "A" && t.tagName !== "AREA" || ((a = t.getAttribute("target")) == null ? void 0 : a.toLowerCase()) !== "_blank") return;
471
+ }), a.addHook("afterSanitizeAttributes", (t) => {
472
+ var n;
473
+ if (!(t instanceof Element) || t.tagName !== "A" && t.tagName !== "AREA" || ((n = t.getAttribute("target")) == null ? void 0 : n.toLowerCase()) !== "_blank") return;
379
474
  const e = t.getAttribute("rel") ?? "", r = new Set(e.split(/\s+/).filter(Boolean));
380
475
  r.add("noopener"), t.setAttribute("rel", [...r].join(" "));
381
- }), s.addHook("afterSanitizeAttributes", (t) => {
476
+ }), a.addHook("afterSanitizeAttributes", (t) => {
382
477
  if (!(t instanceof Element) || t.tagName !== "IFRAME" || !t.hasAttribute("sandbox")) return;
383
- const r = (t.getAttribute("sandbox") ?? "").toLowerCase().split(/\s+/).filter(Boolean), a = r.filter((c) => k.has(c));
384
- a.length !== r.length && t.setAttribute("sandbox", a.join(" "));
478
+ const r = (t.getAttribute("sandbox") ?? "").toLowerCase().split(/\s+/).filter(Boolean), n = r.filter((c) => v.has(c));
479
+ n.length !== r.length && t.setAttribute("sandbox", n.join(" "));
480
+ }), a.addHook("afterSanitizeAttributes", (t) => {
481
+ if (!(t instanceof Element) || t.tagName !== "PARAM") return;
482
+ const e = t.getAttribute("value");
483
+ e && /^\s*(?:javascript|vbscript):/i.test(e) && t.removeAttribute("value");
385
484
  });
386
- const n = /* @__PURE__ */ new Set(["text/html", "application/xhtml+xml"]);
387
- return s.addHook("afterSanitizeElements", (t) => {
485
+ const s = /* @__PURE__ */ new Set(["text/html", "application/xhtml+xml"]);
486
+ return a.addHook("afterSanitizeElements", (t) => {
388
487
  if (!(t instanceof Element) || t.tagName.toLowerCase() !== "annotation-xml") return;
389
488
  const e = (t.getAttribute("encoding") ?? "").toLowerCase().trim();
390
- n.has(e) && t.remove();
391
- }), s;
489
+ s.has(e) && t.remove();
490
+ }), a;
392
491
  }
393
- function _(n, t) {
492
+ function N(s, t) {
394
493
  if (typeof window > "u")
395
494
  throw new Error("sanitizeHtml requires a DOM environment (window is not defined)");
396
- const e = t != null && t.allowFormAttributeNames ? { ...p, SANITIZE_DOM: !1 } : p;
397
- return E().sanitize(n ?? "", e);
398
- }
399
- const m = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]), u = "http://platform-sanitize.invalid/", g = /^\s*\/\//, b = (
400
- // oxlint-disable-next-line no-control-regex -- intentional security guard
401
- /^[\u0000-\u0020\u007F-\u00A0\u2000-\u200F\u2028\u2029\u202F\u205F\u2060\u3000\uFEFF]*(?:javascript|data|vbscript|file):/i
402
- ), S = {
403
- colon: ":",
404
- // javascript&colon;alert(1) → javascript:alert(1)
405
- sol: "/",
406
- // &sol;&sol;evil.com → //evil.com (protocol-relative)
407
- bsol: "\\",
408
- // &bsol;&bsol;evil.com → \\evil.com
409
- Tab: " ",
410
- // java&Tab;script: — WHATWG strips mid-scheme tabs
411
- NewLine: `
412
- `
413
- // java&NewLine;script:
414
- };
415
- function x(n) {
416
- return n.replace(/&#(\d+);/g, (t, e) => {
417
- const r = Number(e);
418
- if (!Number.isFinite(r) || r < 0 || r > 1114111) return "";
419
- try {
420
- return String.fromCodePoint(r);
421
- } catch {
422
- return "";
423
- }
424
- }).replace(/&#x([\da-f]+);/gi, (t, e) => {
425
- const r = parseInt(e, 16);
426
- if (!Number.isFinite(r) || r < 0 || r > 1114111) return "";
427
- try {
428
- return String.fromCodePoint(r);
429
- } catch {
430
- return "";
431
- }
432
- }).replace(/&([A-Za-z][A-Za-z0-9]*);/g, (t, e) => S[e] ?? t);
433
- }
434
- function R(n) {
435
- if (!n || !n.trim()) return "about:blank";
436
- const t = n.replace(/\\/g, "/");
437
- if (g.test(t) || b.test(t)) return "about:blank";
438
- if (/&[#A-Za-z]/.test(t)) {
439
- const e = x(t);
440
- if (g.test(e) || b.test(e))
441
- return "about:blank";
442
- try {
443
- const r = new URL(e, u);
444
- if (!r.href.startsWith(u) && !m.has(r.protocol))
445
- return "about:blank";
446
- } catch {
447
- return "about:blank";
448
- }
449
- }
450
- try {
451
- const e = new URL(t, u);
452
- return !m.has(e.protocol) || (e.protocol === "http:" || e.protocol === "https:") && (e.username || e.password) ? "about:blank" : e.href.startsWith(u) ? n : t.replace(/[\x00-\x1F\u2028\u2029]/g, "").replace(/%250[9ad]/gi, "").replace(/%0[9ad]/gi, "");
453
- } catch {
454
- return "about:blank";
455
- }
495
+ const e = t != null && t.allowFormAttributeNames ? { ...g, SANITIZE_DOM: !1 } : g;
496
+ return R().sanitize(s ?? "", e);
456
497
  }
457
498
  export {
458
- _ as sanitizeHtml,
459
- R as sanitizeUrl
499
+ N as sanitizeHtml,
500
+ A as sanitizeUrl
460
501
  };
@@ -1 +1 @@
1
- {"version":3,"file":"sanitizeHtml.d.ts","sourceRoot":"","sources":["../src/sanitizeHtml.ts"],"names":[],"mappings":"AA8fA,wBAAgB,YAAY,CAC1B,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC/B,OAAO,CAAC,EAAE;IAAE,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAE,GAC9C,MAAM,CASR"}
1
+ {"version":3,"file":"sanitizeHtml.d.ts","sourceRoot":"","sources":["../src/sanitizeHtml.ts"],"names":[],"mappings":"AA+kBA,wBAAgB,YAAY,CAC1B,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC/B,OAAO,CAAC,EAAE;IAAE,uBAAuB,CAAC,EAAE,OAAO,CAAA;CAAE,GAC9C,MAAM,CASR"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instructure/platform-sanitize",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",