1db3573bd284

M15/THREAT-006: sanitize note HTML with allow-list — strip iframe/base/meta/form/script

time
2026-05-16 04:12 UTC · 5 months ago
machine
koh2
parent
24ef36cf45b3
3 files src/popup/views/NoteEditor.toHtml.test.ts+109 −0src/popup/views/NoteEditor.tsx+32 −6src/popup/views/NotesEditor.tsx+3 −1
src/popup/views/NoteEditor.toHtml.test.ts+109 −0
@@ -0,0 +1,109 @@1+// @vitest-environment jsdom2+3+import { describe, expect, it } from "vitest";4+import { toHtml } from "./NoteEditor";5+6+describe("toHtml sanitization (M15/THREAT-006)", () => {7+  // Plain text path — escape special chars when no tag-like content present.8+  it("escapes plain text ampersand", () => {9+    expect(toHtml("a & b")).toBe("a &amp; b");10+  });11+12+  it("converts newlines to <br> in plain text", () => {13+    expect(toHtml("line1\nline2")).toBe("line1<br>line2");14+  });15+16+  // Strips disallowed tags by replacing with their textContent.17+  it("strips iframe", () => {18+    expect(toHtml('<iframe src="x">payload</iframe>')).not.toContain("iframe");19+  });20+21+  it("strips base", () => {22+    expect(toHtml('<base href="//evil">')).not.toContain("base");23+  });24+25+  it("strips meta refresh", () => {26+    expect(toHtml('<meta http-equiv="refresh" content="0;url=//evil">')).not.toContain("meta");27+  });28+29+  it("strips form", () => {30+    expect(toHtml('<form action="x"><input name="y"></form>')).not.toContain("form");31+  });32+33+  it("strips script", () => {34+    expect(toHtml('<script>alert(1)</script>')).not.toContain("script");35+  });36+37+  it("strips style", () => {38+    expect(toHtml('<style>body{display:none}</style>')).not.toContain("style");39+  });40+41+  it("strips object/embed", () => {42+    expect(toHtml('<object data="x"></object>')).not.toContain("object");43+    expect(toHtml('<embed src="x">')).not.toContain("embed");44+  });45+46+  it("strips all attributes from allowed tags", () => {47+    const result = toHtml('<b onclick="alert(1)" id="x" style="color:red">bold</b>');48+    expect(result).toContain("<b>bold</b>");49+    expect(result).not.toContain("onclick");50+    expect(result).not.toContain("style");51+    expect(result).not.toContain("id=");52+  });53+54+  it("strips href from a tags (a is not in allow-list)", () => {55+    const result = toHtml('<a href="//evil.com">click</a>');56+    expect(result).not.toContain("href");57+    expect(result).not.toContain("<a");58+  });59+60+  // Preserve allow-listed tags.61+  it("preserves bold", () => {62+    expect(toHtml("<b>hi</b>")).toContain("<b>hi</b>");63+  });64+65+  it("preserves italic, underline, strikethrough", () => {66+    expect(toHtml("<i>x</i><u>y</u><s>z</s>")).toContain("<i>x</i><u>y</u><s>z</s>");67+  });68+69+  it("preserves strong and em", () => {70+    expect(toHtml("<strong>x</strong><em>y</em>")).toContain("<strong>x</strong><em>y</em>");71+  });72+73+  it("preserves headings h2 and h3", () => {74+    expect(toHtml("<h2>x</h2><h3>y</h3>")).toContain("<h2>x</h2><h3>y</h3>");75+  });76+77+  it("preserves lists", () => {78+    const result = toHtml("<ul><li>a</li><li>b</li></ul>");79+    expect(result).toContain("<ul>");80+    expect(result).toContain("<li>a</li>");81+  });82+83+  it("preserves br, hr, p", () => {84+    expect(toHtml("<p>x<br>y</p><hr>")).toContain("<p>");85+    expect(toHtml("<p>x<br>y</p><hr>")).toContain("<br>");86+    expect(toHtml("<p>x<br>y</p><hr>")).toContain("<hr>");87+  });88+89+  // Nested adversarial cases.90+  it("strips script nested inside allowed tag", () => {91+    expect(toHtml("<b><script>alert(1)</script></b>")).not.toContain("script");92+  });93+94+  it("strips javascript: URL by removing the href attribute from a-tag (a not allowed)", () => {95+    const result = toHtml('<a href="javascript:alert(1)">x</a>');96+    expect(result).not.toContain("javascript:");97+    expect(result).not.toContain("href");98+  });99+100+  it("strips img with onerror", () => {101+    const result = toHtml('<img src="x" onerror="alert(1)">');102+    expect(result).not.toContain("img");103+    expect(result).not.toContain("onerror");104+  });105+106+  it("strips svg", () => {107+    expect(toHtml('<svg><script>alert(1)</script></svg>')).not.toContain("svg");108+  });109+});

view file

src/popup/views/NoteEditor.tsx+32 −6
@@ -1,12 +1,38 @@11 import { createEffect, on, onMount } from "solid-js";22 3+// M15/THREAT-006: allow-list sanitizer for note bodies. The editor writes4+// arbitrary HTML directly to innerHTML; without this any note body could5+// inject iframe/base/meta/form/script. Allow-list is intentional — adding6+// to it requires reviewing the attack surface of the new tag.7+const ALLOWED_NOTE_TAGS = new Set([8+  "b", "i", "u", "s", "strong", "em",9+  "h2", "h3", "ul", "ol", "li", "br", "hr", "p",10+]);11+12+function sanitizeNoteElement(el: Element): void {13+  for (const child of Array.from(el.children)) {14+    if (!ALLOWED_NOTE_TAGS.has(child.tagName.toLowerCase())) {15+      child.replaceWith(document.createTextNode(child.textContent ?? ""));16+    } else {17+      while (child.attributes.length > 0) {18+        child.removeAttribute(child.attributes[0]!.name);19+      }20+      sanitizeNoteElement(child);21+    }22+  }23+}24+325 export function toHtml(raw: string): string {4-  if (/<\/?[a-z]/i.test(raw)) return raw;5-  return raw6-    .replace(/&/g, "&amp;")7-    .replace(/</g, "&lt;")8-    .replace(/>/g, "&gt;")9-    .replace(/\n/g, "<br>");26+  if (!/<\/?[a-z]/i.test(raw)) {27+    return raw28+      .replace(/&/g, "&amp;")29+      .replace(/</g, "&lt;")30+      .replace(/>/g, "&gt;")31+      .replace(/\n/g, "<br>");32+  }33+  const doc = new DOMParser().parseFromString(raw, "text/html");34+  sanitizeNoteElement(doc.body);35+  return doc.body.innerHTML;1036 }1137 1238 const BoldIcon = () => <span style="font-family:Inter,system-ui,sans-serif;font-weight:700;font-size:11px;">B</span>;

view file

src/popup/views/NotesEditor.tsx+3 −1
@@ -3,6 +3,7 @@33 import { sendMessage } from "../../shared/bridge";44 import { isNativePlatform } from "../../shared/native";55 import { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar";6+import { toHtml } from "./NoteEditor";67 78 const HEADER_HEIGHT = 52;89 const AUTOSAVE_MS = 800;@@ -97,7 +98,8 @@9798   onMount(() => {9899     // Populate editor content99100     if (titleRef) titleRef.textContent = props.note?.title ?? "";100-    if (bodyRef) bodyRef.innerHTML = props.note?.notes ?? "";101+    // M15/THREAT-006: sanitize stored note body before assigning to innerHTML.102+    if (bodyRef) bodyRef.innerHTML = toHtml(props.note?.notes ?? "");101103 102104     if (props.autoFocusTitle && titleRef) {103105       setTimeout(() => {

view file