Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ function renderCombinedCard(pair) {
<div class="meta">
<input type="checkbox" class="memory-checkbox" data-id="${memory.id}" ${isSelected ? "checked" : ""} />
<span class="badge badge-memory">${t("badge-memory")}</span>
${memory.memoryType ? `<span class="badge badge-type">${memory.memoryType}</span>` : ""}
${memory.memoryType ? `<span class="badge badge-type">${escapeHtml(memory.memoryType)}</span>` : ""}
${similarityHtml}
${isPinned ? `<span class="badge badge-pinned">${t("badge-pinned")}</span>` : ""}
<span class="memory-display-name">${escapeHtml(memory.displayName || memory.id)}</span>
Expand Down Expand Up @@ -281,7 +281,7 @@ function renderMemoryCard(memory) {
<div class="memory-header">
<div class="meta">
<input type="checkbox" class="memory-checkbox" data-id="${memory.id}" ${isSelected ? "checked" : ""} />
${memory.memoryType ? `<span class="badge badge-type">${memory.memoryType}</span>` : ""}
${memory.memoryType ? `<span class="badge badge-type">${escapeHtml(memory.memoryType)}</span>` : ""}
${isLinked ? `<span class="badge badge-linked"><i data-lucide="link" class="icon-sm"></i> ${t("badge-linked")}</span>` : ""}
${similarityHtml}
${isPinned ? `<span class="badge badge-pinned">${t("badge-pinned")}</span>` : ""}
Expand Down
125 changes: 125 additions & 0 deletions tests/web-memorytype-xss.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '</span><img src=x onerror="window.__xssFired=true"><span>';

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
},
};
},
},
});

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("<img src=x");
expect(html).not.toContain('onerror="');
expect(html).toContain("&lt;/span&gt;&lt;img src=x");
}

describe("web memoryType rendering", () => {
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);
});
});