Skip to content
Open
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
16 changes: 9 additions & 7 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ initTheme();

function truncatedNoticeHtml(key: string, total: number | undefined): string {
if (total === undefined || total <= UI_TRUNCATION_MAX) return "";
return `<div class="truncated-notice">Showing last ${UI_TRUNCATION_MAX} of ${total} ${key}</div>`;
return `<div class="truncated-notice">Showing last ${UI_TRUNCATION_MAX} of ${total} ${escapeHtml(key)}</div>`;
}

function truncatedNoticeText(key: string, total: number | undefined): string {
Expand Down Expand Up @@ -1075,14 +1075,16 @@ document.addEventListener("DOMContentLoaded", async () => {
function createTranscriptEntryHTML(entry: TranscriptEntry): string {
const timeStr = escapeHtml(entry.timestampLabel || formatDuration(entry.timestamp || 0));
const speaker = escapeHtml(entry.speaker || "Unknown");
const initials = (entry.speaker || "Unknown")
const initials = speaker
.split(" ")
.filter(Boolean)
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
const isAudio = (entry.speaker || "") === "Audio";
// speaker is already escapeHtml'd above, and "Audio" is pure ASCII
// so the comparison is safe (escapeHtml is a no-op for plain ASCII).
const isAudio = speaker === "Audio";
const text = escapeHtml(entry.text || "");
const chunkId = entry.id ? `transcript-${escapeHtml(entry.id)}` : "";

Expand All @@ -1095,9 +1097,9 @@ document.addEventListener("DOMContentLoaded", async () => {
<div class="transcript-text">${text}</div>
</div>
<button type="button" class="copy-transcript-btn"
data-speaker="${speaker}"
data-time="${timeStr}"
data-message="${text}"
data-speaker="${sanitizeDataAttr(speaker)}"
data-time="${sanitizeDataAttr(timeStr)}"
data-message="${sanitizeDataAttr(text)}"
title="Copy message to clipboard"
Comment thread
Shan7Usmani marked this conversation as resolved.
aria-label="Copy message to clipboard">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
Expand Down Expand Up @@ -2113,7 +2115,7 @@ function getEmptyStateHTML(message: string, isList: boolean = false): string {
<line x1="12" x2="12" y1="19" y2="22"></line>
</svg>
</div>
<div class="empty-state-title">${message}</div>
<div class="empty-state-title">${escapeHtml(message)}</div>
</${tag}>
`;
}
103 changes: 103 additions & 0 deletions src/utils/domHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import test from "node:test";
import { escapeHtml, formatDuration, sanitizeTopicStatus } from "./domHelpers";

test("escapeHtml prevents XSS", () => {
assert.equal(escapeHtml(null), "");
assert.equal(escapeHtml(undefined), "");

assert.equal(escapeHtml(""), "");
assert.equal(escapeHtml("Alice"), "Alice");
assert.equal(escapeHtml("O'Brien"), "O&#039;Brien");
assert.equal(escapeHtml('Say "hello"'), "Say &quot;hello&quot;");
assert.equal(escapeHtml("a < b"), "a &lt; b");
assert.equal(escapeHtml("a > b"), "a &gt; b");
assert.equal(escapeHtml("M&Ms"), "M&amp;Ms");

const xss = '<script>alert("xss")</script>';
assert.equal(escapeHtml(xss), "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");

const imgXss = '<img src=x onerror="alert(1)">';
assert.equal(escapeHtml(imgXss), "&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");

const safe = escapeHtml(xss);
assert.ok(!safe.includes("<script>"));
assert.ok(!safe.includes("</script>"));
assert.ok(!safe.includes('"'));
});

test("escapeHtml handles edge cases", () => {
assert.equal(escapeHtml(" "), " ");
assert.equal(escapeHtml("a".repeat(1000)), "a".repeat(1000));

assert.equal(escapeHtml("Jack &amp; Jill"), "Jack &amp;amp; Jill");

assert.equal(escapeHtml("\n\t"), "\n\t");
});

test("escapeHtml in attribute context prevents injection", () => {
const name = 'John "Drop Table" Doe';
const escaped = escapeHtml(name);
const attr = `data-name="${escaped}"`;
assert.equal(attr, 'data-name="John &quot;Drop Table&quot; Doe"');

const singleQuote = "O'Brien";
const escaped2 = escapeHtml(singleQuote);
const attr2 = `data-author="${escaped2}"`;
assert.equal(attr2, 'data-author="O&#039;Brien"');
});

test("regression: malicious speaker name in transcript entry pattern", () => {
const malicious = '<script>alert("xss")</script>';
const speaker = escapeHtml(malicious);

const initials = speaker
.split(" ")
.filter(Boolean)
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);

assert.equal(initials, "&");
assert.ok(!initials.includes("<"));
assert.ok(!initials.includes(">"));
assert.ok(!initials.includes('"'));

const html = `<div class="transcript-speaker">${speaker}</div>`;
assert.ok(!html.includes("<script>"));
assert.ok(html.includes("&lt;script&gt;"));
});

test("escapeHtml output is safe when embedded in innerHTML", () => {
const payloads = [
'<script>alert("xss")</script>',
'<img src=x onerror="alert(1)">',
'"><script>alert(1)</script>',
"javascript:alert(1)",
"<<script>script>alert(1)</script>",
];

for (const payload of payloads) {
const escaped = escapeHtml(payload);
assert.ok(!escaped.includes("<script>"), `payload still contains <script>: ${payload}`);
assert.ok(!escaped.includes("</script>"), `payload still contains </script>: ${payload}`);
assert.ok(!escaped.includes('"'), `payload still contains double quote: ${payload}`);
}
});

test("formatDuration", () => {
assert.equal(formatDuration(0), "00:00:00");
assert.equal(formatDuration(90), "00:01:30");
assert.equal(formatDuration(3661), "01:01:01");
assert.equal(formatDuration(86399), "23:59:59");
assert.equal(formatDuration(360000), "100:00:00");
});

test("sanitizeTopicStatus", () => {
assert.equal(sanitizeTopicStatus("completed"), "completed");
assert.equal(sanitizeTopicStatus("unresolved"), "unresolved");
assert.equal(sanitizeTopicStatus("active"), "active");
assert.equal(sanitizeTopicStatus("pending"), "active");
assert.equal(sanitizeTopicStatus(""), "active");
});
49 changes: 9 additions & 40 deletions src/utils/sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,23 @@
import assert from "node:assert/strict";
import test from "node:test";
import { escapeHtml, sanitizeClassName, sanitizeDataAttr } from "./sanitize";
import { escapeHtml } from "./domHelpers";
import { sanitizeClassName, sanitizeDataAttr } from "./sanitize";

// Mock document for testing escapeHtml in Node.js environment
const mockDiv = {
_textContent: "",
set textContent(val: string) {
this._textContent = val;
this.innerHTML = val.replace(/[&<>"']/g, (char) => {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return map[char] || char;
});
},
get textContent() {
return this._textContent;
},
innerHTML: "",
};

(globalThis as any).document = {
createElement(tag: string) {
if (tag === "div") {
return mockDiv;
}
throw new Error(`Unsupported tag in mock: ${tag}`);
},
};

test("escapeHtml behavior", () => {
// Null/undefined inputs
test("escapeHtml prevents XSS in text content", () => {
assert.equal(escapeHtml(null), "");
assert.equal(escapeHtml(undefined), "");

// Standard string inputs
assert.equal(escapeHtml(""), "");
assert.equal(escapeHtml("hello"), "hello");
assert.equal(escapeHtml("Alice & Bob"), "Alice &amp; Bob");
assert.equal(
escapeHtml("<script>alert('xss')</script>"),
"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;",
"&lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;",
);

// Non-string inputs
assert.equal(escapeHtml(42), "42");
assert.equal(escapeHtml(true), "true");
assert.equal(escapeHtml({ a: 1 }), "{&quot;a&quot;:1}");
const output = escapeHtml('<img src=x onerror="alert(1)">');
assert.equal(output, "&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
assert.ok(!output.includes('"'));
});

test("sanitizeClassName behavior", () => {
Expand Down
42 changes: 7 additions & 35 deletions src/utils/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,45 +9,17 @@
*/

/**
* Escapes HTML special characters in an arbitrary value to prevent XSS attacks.
* Escapes HTML special characters in a string to prevent XSS attacks.
*
* Accepts any type and converts it to a safe HTML string:
* - `null` / `undefined` → `""`
* - `string` → escaped as-is
* - `number`, `boolean`, `symbol`, `bigint` → converted via `String()`
* - objects / arrays → serialized via `JSON.stringify()`
*
* Internally delegates to a temporary `<div>` element so that the browser's
* own HTML serialiser handles all edge cases (including supplementary Unicode
* code points).
* @deprecated Use `escapeHtml` from `./domHelpers` instead — it has the same
* contract for string values, is used by all rendering code, and works in
* plain Node.js without a DOM mock. This re-export exists only to avoid
* breaking existing imports during the migration.
*
* @param value - The raw, potentially untrusted value to escape.
* @returns An HTML-safe string where `&`, `<`, `>`, `"`, and `'` are replaced
* with their corresponding HTML entities.
*
* @example
* element.innerHTML = escapeHtml(userInput);
* // '<script>alert(1)</script>' → '&lt;script&gt;alert(1)&lt;/script&gt;'
* @returns An HTML-safe string.
*/
export function escapeHtml(value: unknown): string {
if (value === null || value === undefined) return "";
let str: string;
if (typeof value === "string") {
str = value;
} else if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "symbol" ||
typeof value === "bigint"
) {
str = String(value);
} else {
str = JSON.stringify(value);
}
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
export { escapeHtml } from "./domHelpers";

/**
* Validates a class name string against an explicit allowlist to prevent
Expand Down