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("