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
↓ peach-browser-1db3573b.tar.gzatlas at this save
files changed3 files
src/popup/views/NoteEditor.toHtml.test.ts+109 −0src/popup/views/NoteEditor.tsx+32 −6src/popup/views/NotesEditor.tsx+3 −1src/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 & 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+});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, "&")7- .replace(/</g, "<")8- .replace(/>/g, ">")9- .replace(/\n/g, "<br>");26+ if (!/<\/?[a-z]/i.test(raw)) {27+ return raw28+ .replace(/&/g, "&")29+ .replace(/</g, "<")30+ .replace(/>/g, ">")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>;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(() => {