@runtypelabs/persona 2.2.0 → 2.3.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.
@@ -0,0 +1,114 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+ import { createDefaultSanitizer, resolveSanitizer } from "./sanitize";
4
+
5
+ describe("createDefaultSanitizer", () => {
6
+ const sanitize = createDefaultSanitizer();
7
+
8
+ it("strips <script> tags", () => {
9
+ expect(sanitize("<p>Hello</p><script>alert(1)</script>")).toBe("<p>Hello</p>");
10
+ });
11
+
12
+ it("strips onerror event handlers from img tags", () => {
13
+ const result = sanitize('<img src="x" onerror="alert(1)">');
14
+ expect(result).not.toContain("onerror");
15
+ expect(result).toContain("<img");
16
+ });
17
+
18
+ it("strips javascript: URIs from links", () => {
19
+ const result = sanitize('<a href="javascript:alert(1)">click</a>');
20
+ expect(result).not.toContain("javascript:");
21
+ });
22
+
23
+ it("strips onclick handlers", () => {
24
+ const result = sanitize('<div onclick="alert(1)">click</div>');
25
+ expect(result).not.toContain("onclick");
26
+ });
27
+
28
+ it("allows safe markdown output: headings", () => {
29
+ expect(sanitize("<h1>Title</h1>")).toBe("<h1>Title</h1>");
30
+ expect(sanitize("<h2>Subtitle</h2>")).toBe("<h2>Subtitle</h2>");
31
+ });
32
+
33
+ it("allows safe markdown output: paragraphs and formatting", () => {
34
+ expect(sanitize("<p><strong>bold</strong> and <em>italic</em></p>"))
35
+ .toBe("<p><strong>bold</strong> and <em>italic</em></p>");
36
+ });
37
+
38
+ it("allows safe markdown output: lists", () => {
39
+ const html = "<ul><li>one</li><li>two</li></ul>";
40
+ expect(sanitize(html)).toBe(html);
41
+ });
42
+
43
+ it("allows safe markdown output: code blocks", () => {
44
+ const html = '<pre><code class="language-js">const x = 1;</code></pre>';
45
+ expect(sanitize(html)).toBe(html);
46
+ });
47
+
48
+ it("allows safe markdown output: tables", () => {
49
+ const html = "<table><thead><tr><th>Col</th></tr></thead><tbody><tr><td>Val</td></tr></tbody></table>";
50
+ expect(sanitize(html)).toBe(html);
51
+ });
52
+
53
+ it("allows safe links with href", () => {
54
+ const html = '<a href="https://example.com" target="_blank">link</a>';
55
+ expect(sanitize(html)).toBe(html);
56
+ });
57
+
58
+ it("allows safe images with https src", () => {
59
+ const html = '<img src="https://example.com/img.png" alt="pic">';
60
+ expect(sanitize(html)).toBe(html);
61
+ });
62
+
63
+ it("allows data:image/ URIs (non-SVG)", () => {
64
+ const html = '<img src="data:image/png;base64,abc123" alt="pic">';
65
+ expect(sanitize(html)).toBe(html);
66
+ });
67
+
68
+ it("blocks data:image/svg+xml URIs", () => {
69
+ const result = sanitize('<img src="data:image/svg+xml,<svg onload=alert(1)>">');
70
+ expect(result).not.toContain("data:image/svg+xml");
71
+ });
72
+
73
+ it("blocks mixed-case data: URI scheme bypass", () => {
74
+ const result = sanitize('<img src="Data:image/svg+xml,<svg onload=alert(1)>">');
75
+ expect(result).not.toContain("Data:image/svg+xml");
76
+ const result2 = sanitize('<img src="DATA:image/svg+xml,<svg onload=alert(1)>">');
77
+ expect(result2).not.toContain("DATA:image/svg+xml");
78
+ });
79
+
80
+ it("preserves widget-specific data attributes", () => {
81
+ const html = '<div class="persona-form-directive" data-tv-form="init"></div>';
82
+ expect(sanitize(html)).toBe(html);
83
+ });
84
+
85
+ it("preserves data-persona-component-directive", () => {
86
+ const html = '<div data-persona-component-directive="card"></div>';
87
+ expect(sanitize(html)).toBe(html);
88
+ });
89
+ });
90
+
91
+ describe("resolveSanitizer", () => {
92
+ it("returns default sanitizer for undefined", () => {
93
+ const fn = resolveSanitizer(undefined);
94
+ expect(fn).toBeTypeOf("function");
95
+ expect(fn!("<script>bad</script>")).toBe("");
96
+ });
97
+
98
+ it("returns default sanitizer for true", () => {
99
+ const fn = resolveSanitizer(true);
100
+ expect(fn).toBeTypeOf("function");
101
+ expect(fn!("<script>bad</script>")).toBe("");
102
+ });
103
+
104
+ it("returns null for false (disabled)", () => {
105
+ expect(resolveSanitizer(false)).toBeNull();
106
+ });
107
+
108
+ it("returns the custom function as-is", () => {
109
+ const custom = (html: string) => html.toUpperCase();
110
+ const fn = resolveSanitizer(custom);
111
+ expect(fn).toBe(custom);
112
+ expect(fn!("hello")).toBe("HELLO");
113
+ });
114
+ });
@@ -0,0 +1,83 @@
1
+ import DOMPurify from "dompurify";
2
+
3
+ /**
4
+ * A function that sanitizes an HTML string, returning safe HTML.
5
+ */
6
+ export type SanitizeFunction = (html: string) => string;
7
+
8
+ const DEFAULT_PURIFY_CONFIG: DOMPurify.Config = {
9
+ // Tags safe for markdown-rendered content
10
+ ALLOWED_TAGS: [
11
+ // Headings & structure
12
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "div", "span",
13
+ // Lists
14
+ "ul", "ol", "li", "dl", "dt", "dd",
15
+ // Inline formatting
16
+ "strong", "em", "b", "i", "u", "s", "del", "ins", "mark", "small", "sub", "sup",
17
+ "abbr", "kbd", "var", "samp", "code",
18
+ // Links & media
19
+ "a", "img",
20
+ // Block elements
21
+ "blockquote", "pre", "details", "summary",
22
+ // Tables
23
+ "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
24
+ // Forms (used by widget directive system)
25
+ "input", "label", "select", "option", "textarea", "button",
26
+ ],
27
+ ALLOWED_ATTR: [
28
+ // Link/media attributes
29
+ "href", "src", "alt", "title", "target", "rel", "loading", "width", "height",
30
+ // Table attributes
31
+ "colspan", "rowspan", "scope",
32
+ // Styling & identity
33
+ "class", "id",
34
+ // Form attributes
35
+ "type", "name", "value", "placeholder", "disabled", "checked", "for",
36
+ // Accessibility
37
+ "aria-label", "aria-hidden", "aria-expanded", "role", "tabindex",
38
+ // Widget-internal data attributes
39
+ "data-tv-form", "data-message-id", "data-persona-component-directive",
40
+ "data-preserve-animation", "data-persona-instance",
41
+ ],
42
+ };
43
+
44
+ /** Raster image data URI pattern — blocks SVG and other non-image types. */
45
+ const SAFE_DATA_URI = /^data:image\/(?:png|jpe?g|gif|webp|bmp|x-icon|avif)/i;
46
+
47
+ /**
48
+ * Creates the default DOMPurify-based sanitizer.
49
+ * Uses the global window when available (browser).
50
+ */
51
+ export const createDefaultSanitizer = (): SanitizeFunction => {
52
+ // DOMPurify needs a DOM context. In the browser, pass `window`.
53
+ // The widget only runs in browsers, so `window` is always available at runtime.
54
+ const purify = DOMPurify(typeof window !== "undefined" ? window : (undefined as never));
55
+
56
+ // Hook: strip data:image/svg+xml and other unsafe data: URIs from src/href
57
+ purify.addHook("uponSanitizeAttribute", (_node, data) => {
58
+ if (data.attrName === "src" || data.attrName === "href") {
59
+ const val = data.attrValue;
60
+ if (val.toLowerCase().startsWith("data:") && !SAFE_DATA_URI.test(val)) {
61
+ data.attrValue = "";
62
+ data.keepAttr = false;
63
+ }
64
+ }
65
+ });
66
+
67
+ return (html: string): string => purify.sanitize(html, DEFAULT_PURIFY_CONFIG) as string;
68
+ };
69
+
70
+ /**
71
+ * Resolves a `sanitize` config value into a concrete function or null.
72
+ *
73
+ * - `undefined` / `true` → built-in DOMPurify sanitizer
74
+ * - `false` → `null` (no sanitization)
75
+ * - custom function → returned as-is
76
+ */
77
+ export const resolveSanitizer = (
78
+ option: boolean | SanitizeFunction | undefined,
79
+ ): SanitizeFunction | null => {
80
+ if (option === false) return null;
81
+ if (typeof option === "function") return option;
82
+ return createDefaultSanitizer();
83
+ };