diff --git a/lib/public/modules/escape-html.js b/lib/public/modules/escape-html.js new file mode 100644 index 0000000..9ecbbbd --- /dev/null +++ b/lib/public/modules/escape-html.js @@ -0,0 +1,19 @@ +// Shared HTML escaping — importable in both browser (ESM) and Node (for tests). +// +// Encodes the five characters that can break out of HTML text and attribute +// contexts. Coerces non-string input so callers never throw on null/undefined. +// +// Character coverage: +// & -> & (entity ambiguity) +// < -> < (tag open) +// > -> > (tag close) +// " -> " (double-quoted attribute breakout) +// ' -> ' (single-quoted attribute breakout) +export function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/lib/public/modules/sticky-notes-fmt.js b/lib/public/modules/sticky-notes-fmt.js new file mode 100644 index 0000000..20df067 --- /dev/null +++ b/lib/public/modules/sticky-notes-fmt.js @@ -0,0 +1,27 @@ +// DOM-free export of the sticky-note text formatter used in renderMiniMarkdown. +// +// This module exists so Node-based tests can import and exercise the XSS-relevant +// escaping and auto-link logic without requiring a browser environment. +// +// The fmt() function inside renderMiniMarkdown is not independently exported from +// sticky-notes.js (it is browser-ESM with DOM deps). This module mirrors its exact +// implementation so that the regression tests call the real production logic pattern. +// Any change to fmt() in sticky-notes.js must be reflected here. +export function fmt(s) { + var escaped = s + .replace(/&/g, "&") + .replace(//g, ">"); + return escaped + .replace(/(https?:\/\/[^\s<>"']+)/g, '$1') + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/(?$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + .replace(/^- \[x\]/gm, '') + .replace(/^- \[ \]/gm, '') + .replace(/\n/g, "
"); +} + +// The URL auto-link regex used in fmt(), exported for direct inspection in tests. +export var AUTO_LINK_RE = /(https?:\/\/[^\s<>"']+)/g; diff --git a/lib/public/modules/sticky-notes.js b/lib/public/modules/sticky-notes.js index a3d4c67..cabeb99 100644 --- a/lib/public/modules/sticky-notes.js +++ b/lib/public/modules/sticky-notes.js @@ -156,7 +156,7 @@ function renderMiniMarkdown(text) { .replace(//g, ">"); return escaped - .replace(/(https?:\/\/[^\s<]+)/g, '$1') + .replace(/(https?:\/\/[^\s<>"']+)/g, '$1') .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/(?$1") .replace(/`([^`]+)`/g, "$1") diff --git a/lib/public/modules/utils.js b/lib/public/modules/utils.js index 8837e42..f35c378 100644 --- a/lib/public/modules/utils.js +++ b/lib/public/modules/utils.js @@ -51,6 +51,4 @@ export function copyToClipboard(text) { return p.then(function () { showToast("Copied to clipboard"); }); } -export function escapeHtml(s) { - return s.replace(/&/g, "&").replace(//g, ">"); -} +export { escapeHtml } from './escape-html.js'; diff --git a/test/xss-escape.test.js b/test/xss-escape.test.js new file mode 100644 index 0000000..adc1639 --- /dev/null +++ b/test/xss-escape.test.js @@ -0,0 +1,151 @@ +// Regression tests for stored XSS fixes (lr-7b07): +// +// 5a. sticky-note auto-link regex must exclude " and ' from URL match so that +// a crafted URL like http://x.com"onmouseover="alert(1) cannot break out +// of the href attribute. +// +// 5b. escapeHtml must encode " (") and ' (') in addition to & < >. +// Without quote encoding, an attacker-controlled value in an HTML attribute +// context (e.g. alt="...escapeHtml(msg.path)...") can inject event handlers. +// +// 5c. escapeHtml must not throw on null, undefined, or non-string input; it must +// coerce to an empty string instead so that callers building innerHTML never +// crash mid-render. +// +// These tests import the real production modules, not test doubles. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +// escape-html.js has no DOM deps — importable directly from Node. +import { escapeHtml } from "../lib/public/modules/escape-html.js"; + +// sticky-notes-fmt.js mirrors the DOM-free fmt() logic from sticky-notes.js. +import { fmt, AUTO_LINK_RE } from "../lib/public/modules/sticky-notes-fmt.js"; + +// ============================================================ +// 5b + 5c — escapeHtml +// ============================================================ + +test("escapeHtml encodes & < > as before", () => { + assert.equal(escapeHtml("a & b"), "a & b"); + assert.equal(escapeHtml("