diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index f0f0e1465..a26a2119a 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -183,4 +183,68 @@ describe("patchElementInHtml", () => { expect(result).not.toContain("srcdoc"); expect(result).not.toContain("formaction"); }); + + it("rejects on* event handlers regardless of casing", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "onClick", value: "alert(1)" }, + { type: "html-attribute", property: "ONERROR", value: "alert(2)" }, + { type: "html-attribute", property: "onmouseover", value: "alert(3)" }, + ]); + + expect(result).not.toContain("alert"); + }); + + it("rejects data:text/html URIs in src", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { + type: "html-attribute", + property: "src", + value: "data:text/html,", + }, + ]); + + expect(result).not.toContain("data:text/html"); + }); + + it("allows safe href values", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "href", value: "https://example.com" }, + ]); + + expect(result).toContain('href="https://example.com"'); + }); + + it("rejects javascript: in href", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "href", value: "javascript:alert(1)" }, + ]); + + expect(result).not.toContain("javascript:"); + }); + + it("allows legitimate form and media attributes", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "placeholder", value: "Enter text" }, + { type: "html-attribute", property: "target", value: "_blank" }, + { type: "html-attribute", property: "rel", value: "noopener" }, + { type: "html-attribute", property: "srcset", value: "img-2x.png 2x" }, + ]); + + expect(result).toContain('placeholder="Enter text"'); + expect(result).toContain('target="_blank"'); + expect(result).toContain('rel="noopener"'); + expect(result).toContain("srcset"); + }); + + it("rejects unknown/dangerous attributes", () => { + const result = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "html-attribute", property: "xmlns", value: "http://evil.com" }, + { type: "html-attribute", property: "background", value: "http://evil.com/bg.js" }, + { type: "html-attribute", property: "dynsrc", value: "http://evil.com/vid.avi" }, + ]); + + expect(result).not.toContain("xmlns"); + expect(result).not.toContain("background="); + expect(result).not.toContain("dynsrc"); + }); }); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index c266e6b17..97f1a3899 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -67,23 +67,36 @@ export interface PatchOperation { } const ALLOWED_HTML_ATTRS = new Set([ + // Identity & structure "id", "class", "style", "title", + "name", + "for", + "type", + // Internationalization "lang", "dir", + "translate", + // Interaction "hidden", "tabindex", - "role", - "slot", - "translate", "draggable", "contenteditable", - "width", - "height", + // Accessibility + "role", + "slot", + // Links & navigation + "href", + "target", + "rel", + // Media "src", + "srcset", + "sizes", "alt", + "poster", "loading", "decoding", "crossorigin", @@ -92,23 +105,53 @@ const ALLOWED_HTML_ATTRS = new Set([ "loop", "muted", "controls", - "poster", "playsinline", + // Layout + "width", + "height", + "colspan", + "rowspan", + "scope", + // Form + "placeholder", + "value", + "min", + "max", + "step", + "pattern", + "required", + "disabled", + "readonly", + "checked", + "selected", + "multiple", + "accept", + "maxlength", + "minlength", + "rows", + "cols", + "wrap", ]); +const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i; +const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i; + function isAllowedHtmlAttribute(name: string): boolean { const lower = name.toLowerCase(); + if (lower.startsWith("on")) return false; if (ALLOWED_HTML_ATTRS.has(lower)) return true; if (lower.startsWith("data-")) return true; if (lower.startsWith("aria-")) return true; return false; } +const URI_ATTRS = new Set(["src", "href", "action", "formaction", "poster", "srcset"]); + function isSafeAttributeValue(name: string, value: string): boolean { - const lower = name.toLowerCase(); - if (lower === "src" || lower === "href" || lower === "action" || lower === "formaction") { - const trimmed = value.trim().toLowerCase(); - if (trimmed.startsWith("javascript:") || trimmed.startsWith("vbscript:")) return false; + if (URI_ATTRS.has(name.toLowerCase())) { + const trimmed = value.trim(); + if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false; + if (DANGEROUS_DATA_URI.test(trimmed)) return false; } return true; }