diff --git a/src/web/app.js b/src/web/app.js index 111ab22..f216da8 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -183,7 +183,7 @@ function renderCombinedCard(pair) {
${t("badge-memory")} - ${memory.memoryType ? `${memory.memoryType}` : ""} + ${memory.memoryType ? `${escapeHtml(memory.memoryType)}` : ""} ${similarityHtml} ${isPinned ? `${t("badge-pinned")}` : ""} ${escapeHtml(memory.displayName || memory.id)} @@ -281,7 +281,7 @@ function renderMemoryCard(memory) {
- ${memory.memoryType ? `${memory.memoryType}` : ""} + ${memory.memoryType ? `${escapeHtml(memory.memoryType)}` : ""} ${isLinked ? ` ${t("badge-linked")}` : ""} ${similarityHtml} ${isPinned ? `${t("badge-pinned")}` : ""} diff --git a/tests/web-memorytype-xss.test.ts b/tests/web-memorytype-xss.test.ts new file mode 100644 index 0000000..863a1ea --- /dev/null +++ b/tests/web-memorytype-xss.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Script, createContext } from "node:vm"; + +type RenderMemoryCard = (memory: MemoryFixture) => string; +type RenderCombinedCard = (pair: CombinedCardFixture) => string; + +type MemoryFixture = { + id: string; + content: string; + memoryType: string; + tags: readonly string[]; + createdAt: string; + displayName: string; + isPinned: boolean; +}; + +type PromptFixture = { + id: string; + content: string; + createdAt: string; +}; + +type CombinedCardFixture = { + memory: MemoryFixture; + prompt: PromptFixture; +}; + +type RenderHarness = { + renderMemoryCard: RenderMemoryCard; + renderCombinedCard: RenderCombinedCard; +}; + +const MALICIOUS_MEMORY_TYPE = ''; + +function loadRenderHarness(): RenderHarness { + const source = readFileSync(new URL("../src/web/app.js", import.meta.url), "utf-8"); + const context = createContext({ + console, + DOMPurify: { sanitize: (html: string) => html }, + getLanguage: () => "en", + lucide: { createIcons: () => undefined }, + marked: { parse: (markdown: string) => markdown, setOptions: () => undefined }, + t: (key: string) => key, + document: { + addEventListener: () => undefined, + createElement: () => { + let text = ""; + return { + set textContent(value: string) { + text = value; + }, + get innerHTML() { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); + }, + }; + }, + }, + }); + + new Script( + `${source}\n;globalThis.__renderMemoryCard = renderMemoryCard;\n;globalThis.__renderCombinedCard = renderCombinedCard;` + ).runInContext(context); + + return { + renderMemoryCard: context.__renderMemoryCard as RenderMemoryCard, + renderCombinedCard: context.__renderCombinedCard as RenderCombinedCard, + }; +} + +function createMemory(): MemoryFixture { + return { + id: "mem_xss_repro", + content: "benign content", + memoryType: MALICIOUS_MEMORY_TYPE, + tags: [], + createdAt: "2026-06-22T20:21:11.419Z", + displayName: "XSS repro memory", + isPinned: false, + }; +} + +function expectEscapedMemoryType(html: string): void { + expect(html).not.toContain(" { + it("escapes memoryType in standalone memory cards", () => { + // Given: a stored memory with an HTML event-handler payload in memoryType. + const { renderMemoryCard } = loadRenderHarness(); + const memory = createMemory(); + + // When: the Web UI renders the standalone memory card. + const html = renderMemoryCard(memory); + + // Then: the payload is displayed as badge text, not parsed as executable HTML. + expectEscapedMemoryType(html); + }); + + it("escapes memoryType in combined prompt-memory cards", () => { + // Given: a stored memory linked to a prompt with an HTML payload in memoryType. + const { renderCombinedCard } = loadRenderHarness(); + const memory = createMemory(); + + // When: the Web UI renders the combined prompt-memory card. + const html = renderCombinedCard({ + memory, + prompt: { + id: "prompt_xss_repro", + content: "remember this", + createdAt: "2026-06-22T20:20:00.000Z", + }, + }); + + // Then: the payload is displayed as badge text, not parsed as executable HTML. + expectEscapedMemoryType(html); + }); +});