···11import { ExternalLinkWarningModal } from "/js/modals/externalLinkWarning.modal.js";
22-import { assertSafeInlineStyleValue } from "/js/plugins/pluginStylesLoader.js";
22+import {
33+ assertSafeInlineStyleValue,
44+ assertSafeSvgValue,
55+} from "/js/plugins/pluginStylesLoader.js";
36import "/js/components/toggle-switch.js";
47import "/js/components/plugin-profiles-list.js";
58import "/js/components/plugin-posts-feed.js";
···49525053const ALLOWED_EVENTS = ["click", "change", "input"];
51545555+const HTML_NS = "http://www.w3.org/1999/xhtml";
5656+const SVG_NS = "http://www.w3.org/2000/svg";
5757+5858+// Case-sensitive
5959+const SVG_ALLOWED_TAGS = new Set([
6060+ "svg",
6161+ "g",
6262+ "path",
6363+ "rect",
6464+ "circle",
6565+ "ellipse",
6666+ "line",
6767+ "polyline",
6868+ "polygon",
6969+ "title",
7070+ "desc",
7171+]);
7272+7373+const SVG_ALLOWED_ATTRS = new Set([
7474+ "viewBox",
7575+ "preserveAspectRatio",
7676+ "width",
7777+ "height",
7878+ "x",
7979+ "y",
8080+ "x1",
8181+ "y1",
8282+ "x2",
8383+ "y2",
8484+ "cx",
8585+ "cy",
8686+ "r",
8787+ "rx",
8888+ "ry",
8989+ "d",
9090+ "points",
9191+ "transform",
9292+ "class",
9393+ "role",
9494+ "fill",
9595+ "fill-rule",
9696+ "fill-opacity",
9797+ "stroke",
9898+ "stroke-width",
9999+ "stroke-linecap",
100100+ "stroke-linejoin",
101101+ "stroke-dasharray",
102102+ "stroke-opacity",
103103+ "opacity",
104104+ "color",
105105+]);
106106+107107+const SVG_MAX_PATH_LENGTH = 32 * 1024;
108108+52109function isAllowedTag(tag) {
53110 return ALLOWED_TAGS.includes(tag);
111111+}
112112+113113+function isAllowedSvgAttr(name) {
114114+ if (SVG_ALLOWED_ATTRS.has(name)) return true;
115115+ if (name.startsWith("data-") || name.startsWith("aria-")) return true;
116116+ return false;
54117}
5511856119const ALLOWED_ATTRS = [
···288351 return tag;
289352}
290353354354+function resolveSvgTag(node, pluginId) {
355355+ const tag = typeof node.tag === "string" ? node.tag : "";
356356+ if (SVG_ALLOWED_TAGS.has(tag)) return tag;
357357+ if (pluginId !== undefined) {
358358+ console.warn(
359359+ `[plugins] "${pluginId}" tried to render disallowed SVG tag <${tag}>`,
360360+ );
361361+ }
362362+ return "g";
363363+}
364364+365365+// Namespace is HTML by default, SVG once we enter an <svg>
366366+function resolveChildNamespace(parentNs, node) {
367367+ if (parentNs === SVG_NS) return SVG_NS;
368368+ if (typeof node.tag === "string" && node.tag.toLowerCase() === "svg") {
369369+ return SVG_NS;
370370+ }
371371+ return HTML_NS;
372372+}
373373+291374// Render a serialized VirtualNode (text or element) into a DOM node.
292375export class PluginRenderer {
293376 constructor(pluginBridge, pluginId, renderContext) {
···303386 el: null,
304387 render(rawNode) {
305388 const node = renderer._normalize(rawNode);
306306- if (this.el && renderer._sameKind(this.tree, node)) {
307307- renderer._patch(this.el, this.tree, node);
389389+ if (this.el && renderer._sameKind(this.tree, node, HTML_NS)) {
390390+ renderer._patch(this.el, this.tree, node, HTML_NS);
308391 } else {
309309- this.el = renderer._create(node);
392392+ this.el = renderer._create(node, HTML_NS);
310393 }
311394 this.tree = node;
312395 return this.el;
···340423 console.warn(`[plugins] "${this.pluginId}" had ${summary}`);
341424 }
342425343343- _sameKind(oldNode, newNode) {
426426+ _sameKind(oldNode, newNode, parentNs = HTML_NS) {
344427 if (!oldNode || !newNode) return false;
345428 if (oldNode.type === "text" && newNode.type === "text") return true;
346429 if (oldNode.type === "element" && newNode.type === "element") {
430430+ const oldNs = resolveChildNamespace(parentNs, oldNode);
431431+ const newNs = resolveChildNamespace(parentNs, newNode);
432432+ if (oldNs !== newNs) return false;
433433+ if (oldNs === SVG_NS) {
434434+ return resolveSvgTag(oldNode) === resolveSvgTag(newNode);
435435+ }
347436 return resolveTag(oldNode) === resolveTag(newNode);
348437 }
349438 return false;
350439 }
351440352352- _create(node) {
441441+ _create(node, parentNs = HTML_NS) {
353442 if (node.type === "text") {
354443 return document.createTextNode(node.value);
355444 }
356356- const pluginId = this.pluginId;
357357- const tag = resolveTag(node, pluginId);
445445+ const ns = resolveChildNamespace(parentNs, node);
446446+ if (ns === SVG_NS) return this._createSvg(node);
447447+ const tag = resolveTag(node, this.pluginId);
358448 const element = document.createElement(tag);
359449 if (tag === "a") {
360450 element.setAttribute("target", "_blank");
···393483 for (const [name, value] of Object.entries(node.attrs)) {
394484 if (!isAllowedAttr(name, tag)) {
395485 console.warn(
396396- `[plugins] "${pluginId}" tried to set disallowed attribute "${name}" on <${tag}>`,
486486+ `[plugins] "${this.pluginId}" tried to set disallowed attribute "${name}" on <${tag}>`,
397487 );
398488 continue;
399489 }
400490 if (name === "href" && !isSafeHref(value)) {
401491 console.warn(
402402- `[plugins] "${pluginId}" tried to set unsafe href "${value}"`,
492492+ `[plugins] "${this.pluginId}" tried to set unsafe href "${value}"`,
403493 );
404494 continue;
405495 }
···413503 }
414504 this._patchEvents(element, null, node.events);
415505 for (const child of node.children) {
416416- element.appendChild(this._create(child));
506506+ element.appendChild(this._create(child, HTML_NS));
417507 }
418508 return element;
419509 }
420510421421- _patch(node, oldNode, newNode) {
511511+ _createSvg(node) {
512512+ const tag = resolveSvgTag(node, this.pluginId);
513513+ const element = document.createElementNS(SVG_NS, tag);
514514+ if (node.attrs) {
515515+ for (const [name, value] of Object.entries(node.attrs)) {
516516+ this._setSvgAttr(element, tag, name, value);
517517+ }
518518+ }
519519+ if (node.styles) {
520520+ for (const [name, value] of Object.entries(node.styles)) {
521521+ applyStyle(element, name, value);
522522+ }
523523+ }
524524+ for (const child of node.children) {
525525+ if (child.type === "text") {
526526+ element.appendChild(document.createTextNode(child.value));
527527+ continue;
528528+ }
529529+ element.appendChild(this._create(child, SVG_NS));
530530+ }
531531+ return element;
532532+ }
533533+534534+ _setSvgAttr(element, tag, name, value) {
535535+ const pluginId = this.pluginId;
536536+ if (!isAllowedSvgAttr(name)) {
537537+ if (pluginId !== undefined) {
538538+ console.warn(
539539+ `[plugins] "${pluginId}" tried to set disallowed SVG attribute "${name}" on <${tag}>`,
540540+ );
541541+ }
542542+ return;
543543+ }
544544+ const stringValue = String(value);
545545+ if (
546546+ (name === "d" || name === "points") &&
547547+ stringValue.length > SVG_MAX_PATH_LENGTH
548548+ ) {
549549+ if (pluginId !== undefined) {
550550+ console.warn(
551551+ `[plugins] "${pluginId}" SVG "${name}" attribute exceeds max length; dropped`,
552552+ );
553553+ }
554554+ return;
555555+ }
556556+ try {
557557+ assertSafeSvgValue(name, stringValue);
558558+ } catch {
559559+ if (pluginId !== undefined) {
560560+ console.warn(
561561+ `[plugins] "${pluginId}" tried to set unsafe SVG "${name}" value`,
562562+ );
563563+ }
564564+ return;
565565+ }
566566+ element.setAttribute(name, stringValue);
567567+ }
568568+569569+ _patch(node, oldNode, newNode, parentNs = HTML_NS) {
422570 if (newNode.type === "text") {
423571 if (node.nodeValue !== newNode.value) node.nodeValue = newNode.value;
424572 return;
425573 }
426426- const pluginId = this.pluginId;
574574+ const ns = resolveChildNamespace(parentNs, newNode);
575575+ if (ns === SVG_NS) {
576576+ this._patchSvg(node, oldNode, newNode);
577577+ return;
578578+ }
427579 const element = node;
428580 const oldAttrs = oldNode.attrs ?? {};
429581 const newAttrs = newNode.attrs ?? {};
···431583 const tag = element.localName;
432584433585 for (const name of Object.keys(oldAttrs)) {
434434- if (!(name in newAttrs) && isAllowedAttr(name, tag)) {
435435- element.removeAttribute(name);
436436- }
586586+ if (!(name in newAttrs)) element.removeAttribute(name);
437587 }
438588 for (const [name, value] of Object.entries(newAttrs)) {
439589 if (!isAllowedAttr(name, tag)) {
440590 console.warn(
441441- `[plugins] "${pluginId}" tried to set disallowed attribute "${name}"`,
591591+ `[plugins] "${this.pluginId}" tried to set disallowed attribute "${name}"`,
442592 );
443593 continue;
444594 }
···446596 if (isFocused && (name === "value" || name === "checked")) continue;
447597 if (name === "href" && !isSafeHref(value)) {
448598 console.warn(
449449- `[plugins] "${pluginId}" tried to set unsafe href "${value}"`,
599599+ `[plugins] "${this.pluginId}" tried to set unsafe href "${value}"`,
450600 );
451601 element.removeAttribute("href");
452602 continue;
···472622 element.refresh();
473623 }
474624475475- const oldChildren = oldNode.children ?? [];
476476- const newChildren = newNode.children ?? [];
625625+ this._patchChildren(
626626+ element,
627627+ oldNode.children ?? [],
628628+ newNode.children ?? [],
629629+ HTML_NS,
630630+ );
631631+ }
632632+633633+ _patchChildren(element, oldChildren, newChildren, parentNs) {
477634 const domChildren = Array.from(element.childNodes);
478635 const max = Math.max(oldChildren.length, newChildren.length);
479636 for (let index = 0; index < max; index++) {
···481638 const newChild = newChildren[index];
482639 const domChild = domChildren[index];
483640 if (!oldChild && newChild) {
484484- element.appendChild(this._create(newChild));
641641+ element.appendChild(this._create(newChild, parentNs));
485642 } else if (oldChild && !newChild) {
486643 if (domChild) element.removeChild(domChild);
487487- } else if (this._sameKind(oldChild, newChild)) {
488488- this._patch(domChild, oldChild, newChild);
644644+ } else if (this._sameKind(oldChild, newChild, parentNs)) {
645645+ this._patch(domChild, oldChild, newChild, parentNs);
489646 } else {
490490- element.replaceChild(this._create(newChild), domChild);
647647+ element.replaceChild(this._create(newChild, parentNs), domChild);
491648 }
492649 }
650650+ }
651651+652652+ _patchSvg(element, oldNode, newNode) {
653653+ const tag = element.localName;
654654+ const oldAttrs = oldNode.attrs ?? {};
655655+ const newAttrs = newNode.attrs ?? {};
656656+ for (const name of Object.keys(oldAttrs)) {
657657+ if (!(name in newAttrs)) element.removeAttribute(name);
658658+ }
659659+ for (const [name, value] of Object.entries(newAttrs)) {
660660+ if (oldAttrs[name] === value) continue;
661661+ this._setSvgAttr(element, tag, name, value);
662662+ }
663663+ const oldStyles = oldNode.styles ?? {};
664664+ const newStyles = newNode.styles ?? {};
665665+ for (const name of Object.keys(oldStyles)) {
666666+ if (!(name in newStyles)) element.style.removeProperty(name);
667667+ }
668668+ for (const [name, value] of Object.entries(newStyles)) {
669669+ if (oldStyles[name] !== value) applyStyle(element, name, value);
670670+ }
671671+ this._patchChildren(
672672+ element,
673673+ oldNode.children ?? [],
674674+ newNode.children ?? [],
675675+ SVG_NS,
676676+ );
493677 }
494678495679 _patchEvents(element, oldEvents, newEvents) {
+11
src/js/plugins/pluginStylesLoader.js
···77 }
88}
991010+// Rejects all url()/image-set()/etc, matching the stylesheet rule
1111+export function assertSafeSvgValue(attribute, value) {
1212+ if (typeof value !== "string") return;
1313+ if (URL_FUNC_RE.test(value)) {
1414+ throw new Error(`disallowed url() in ${attribute}`);
1515+ }
1616+ if (value.includes("\\")) {
1717+ throw new Error(`disallowed escape in ${attribute}`);
1818+ }
1919+}
2020+1021export function assertSafeInlineStyleValue(property, value) {
1122 // Reject any backslash so a CSS-escape-encoded identifier (e.g. `\75rl(...)`)
1223 // can't slip past URL_FUNC_RE