Skip to main content

max / goingson

Security: single-source escaping into js/escape.js, cover the compose window; bump ammonia - Extract the CHRONIC-XSS escaping primitives (escapeHtml, escapeAttrValue, escapeHandlerArg, safeUrl) into js/escape.js as the single source of truth. utils.js now delegates to it, so the main-app call sites and the enforcement gate are unchanged; compose-form.js and address-highlight.js drop their local escaper copies and use GoingsOn.escape. compose.html and index.html both load escape.js first, so the standalone compose window (the highest-value XSS surface) is now covered by the same gate. - New gate test forbids any hand-rolled `.replace(/"/g,'&quot;')` outside escape.js, so the divergent-copy pattern can't return. 55 JS tests pass. - Deps: ammonia 4.1.2 -> 4.1.3 (RUSTSEC-2026-0193 mXSS on the HTML render path), tauri-plugin-updater 2.10.0 -> 2.10.1. Suite: 785 Rust + 55 JS pass; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 21:53 UTC
Commit: 21b5072af12ed72cd2df859ce0d597fa7301e890
Parent: 9873b32
8 files changed, +161 insertions, -173 deletions
M Cargo.lock +16 -64
@@ -50,14 +50,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
50 50
51 51 [[package]]
52 52 name = "ammonia"
53 - version = "4.1.2"
53 + version = "4.1.3"
54 54 source = "registry+https://github.com/rust-lang/crates.io-index"
55 - checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
55 + checksum = "68b9d3370580a12f4b7a10fdcc18b28942c083ba570e3d954fe59d10951b85a2"
56 56 dependencies = [
57 - "cssparser 0.35.0",
58 - "html5ever 0.35.0",
57 + "cssparser 0.37.0",
58 + "html5ever 0.39.0",
59 59 "maplit",
60 - "tendril 0.4.3",
61 60 "url",
62 61 ]
63 62
@@ -833,27 +832,25 @@ dependencies = [
833 832
834 833 [[package]]
835 834 name = "cssparser"
836 - version = "0.35.0"
835 + version = "0.36.0"
837 836 source = "registry+https://github.com/rust-lang/crates.io-index"
838 - checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
837 + checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
839 838 dependencies = [
840 839 "cssparser-macros",
841 840 "dtoa-short",
842 841 "itoa",
843 - "phf 0.11.3",
842 + "phf 0.13.1",
844 843 "smallvec",
845 844 ]
846 845
847 846 [[package]]
848 847 name = "cssparser"
849 - version = "0.36.0"
848 + version = "0.37.0"
850 849 source = "registry+https://github.com/rust-lang/crates.io-index"
851 - checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
850 + checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
852 851 dependencies = [
853 - "cssparser-macros",
854 852 "dtoa-short",
855 853 "itoa",
856 - "phf 0.13.1",
857 854 "smallvec",
858 855 ]
859 856
@@ -2084,18 +2081,7 @@ dependencies = [
2084 2081 "log",
2085 2082 "mac",
2086 2083 "markup5ever 0.14.1",
2087 - "match_token 0.1.0",
2088 - ]
2089 -
2090 - [[package]]
2091 - name = "html5ever"
2092 - version = "0.35.0"
2093 - source = "registry+https://github.com/rust-lang/crates.io-index"
2094 - checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
2095 - dependencies = [
2096 - "log",
2097 - "markup5ever 0.35.0",
2098 - "match_token 0.35.0",
2084 + "match_token",
2099 2085 ]
2100 2086
2101 2087 [[package]]
@@ -2902,24 +2888,13 @@ dependencies = [
2902 2888
2903 2889 [[package]]
2904 2890 name = "markup5ever"
2905 - version = "0.35.0"
2906 - source = "registry+https://github.com/rust-lang/crates.io-index"
2907 - checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
2908 - dependencies = [
2909 - "log",
2910 - "tendril 0.4.3",
2911 - "web_atoms 0.1.3",
2912 - ]
2913 -
2914 - [[package]]
2915 - name = "markup5ever"
2916 2891 version = "0.38.0"
2917 2892 source = "registry+https://github.com/rust-lang/crates.io-index"
2918 2893 checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
2919 2894 dependencies = [
2920 2895 "log",
2921 2896 "tendril 0.5.0",
2922 - "web_atoms 0.2.3",
2897 + "web_atoms",
2923 2898 ]
2924 2899
2925 2900 [[package]]
@@ -2930,7 +2905,7 @@ checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
2930 2905 dependencies = [
2931 2906 "log",
2932 2907 "tendril 0.5.0",
2933 - "web_atoms 0.2.3",
2908 + "web_atoms",
2934 2909 ]
2935 2910
2936 2911 [[package]]
@@ -2945,17 +2920,6 @@ dependencies = [
2945 2920 ]
2946 2921
2947 2922 [[package]]
2948 - name = "match_token"
2949 - version = "0.35.0"
2950 - source = "registry+https://github.com/rust-lang/crates.io-index"
2951 - checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
2952 - dependencies = [
2953 - "proc-macro2",
2954 - "quote",
2955 - "syn 2.0.117",
2956 - ]
2957 -
2958 - [[package]]
2959 2923 name = "matchers"
2960 2924 version = "0.2.0"
2961 2925 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3528,7 +3492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3528 3492 checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
3529 3493 dependencies = [
3530 3494 "libc",
3531 - "windows-sys 0.61.2",
3495 + "windows-sys 0.48.0",
3532 3496 ]
3533 3497
3534 3498 [[package]]
@@ -5885,9 +5849,9 @@ dependencies = [
5885 5849
5886 5850 [[package]]
5887 5851 name = "tauri-plugin-updater"
5888 - version = "2.10.0"
5852 + version = "2.10.1"
5889 5853 source = "registry+https://github.com/rust-lang/crates.io-index"
5890 - checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
5854 + checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
5891 5855 dependencies = [
5892 5856 "base64 0.22.1",
5893 5857 "dirs",
@@ -6913,18 +6877,6 @@ dependencies = [
6913 6877
6914 6878 [[package]]
6915 6879 name = "web_atoms"
6916 - version = "0.1.3"
6917 - source = "registry+https://github.com/rust-lang/crates.io-index"
6918 - checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
6919 - dependencies = [
6920 - "phf 0.11.3",
6921 - "phf_codegen 0.11.3",
6922 - "string_cache 0.8.9",
6923 - "string_cache_codegen 0.5.4",
6924 - ]
6925 -
6926 - [[package]]
6927 - name = "web_atoms"
6928 6880 version = "0.2.3"
6929 6881 source = "registry+https://github.com/rust-lang/crates.io-index"
6930 6882 checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
@@ -7056,7 +7008,7 @@ version = "0.1.11"
7056 7008 source = "registry+https://github.com/rust-lang/crates.io-index"
7057 7009 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
7058 7010 dependencies = [
7059 - "windows-sys 0.61.2",
7011 + "windows-sys 0.48.0",
7060 7012 ]
7061 7013
7062 7014 [[package]]
@@ -25,6 +25,7 @@
25 25 <div class="compose-attachments hidden" id="attachments-bar"></div>
26 26 <div class="status-bar" id="status-bar">Ready</div>
27 27
28 + <script src="js/escape.js"></script>
28 29 <script src="js/address-highlight.js"></script>
29 30 <script src="js/compose-form.js"></script>
30 31 <script>
@@ -643,6 +643,7 @@
643 643 <!-- Core Layer -->
644 644 <script src="js/api.js"></script>
645 645 <script src="js/state.js"></script>
646 + <script src="js/escape.js"></script>
646 647 <script src="js/utils.js"></script>
647 648 <script src="js/components-modal.js"></script>
648 649 <script src="js/components.js"></script>
@@ -264,9 +264,9 @@
264 264 statusCache.clear();
265 265 }
266 266
267 - function escapeHtml(str) {
268 - return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
269 - }
267 + // Text-context HTML escaping via the shared single-source primitive
268 + // (js/escape.js), loaded before this file in both compose.html and index.html.
269 + const escapeHtml = GoingsOn.escape.escapeHtml;
270 270
271 271 // Export to GoingsOn namespace or window (for standalone compose.html)
272 272 if (typeof GoingsOn !== 'undefined') {
@@ -14,8 +14,9 @@
14 14 * markup and behaviors; for now, just the data contract.
15 15 *
16 16 * The module is safe to load in either context (main app webview *or*
17 - * the standalone compose webview) since it touches neither GoingsOn
18 - * state nor the DOM.
17 + * the standalone compose webview). Its only dependency is `GoingsOn.escape`
18 + * (js/escape.js), which both compose.html and index.html load before this
19 + * file — so escaping is single-source and gate-covered in both surfaces.
19 20 */
20 21 (function() {
21 22 'use strict';
@@ -29,6 +30,13 @@
29 30 const ATTACHMENT_HARD_CAP_BYTES = 25 * 1024 * 1024;
30 31 const ATTACHMENT_WARN_CAP_BYTES = 20 * 1024 * 1024;
31 32
33 + // Escaping goes through the shared single-source primitives (js/escape.js),
34 + // loaded before this file in both compose.html and the main app, so the
35 + // compose window is covered by the same CHRONIC-XSS enforcement gate.
36 + // `esc` = text/innerHTML context; `escAttr` = double-quoted attribute value.
37 + const esc = GoingsOn.escape.escapeHtml;
38 + const escAttr = GoingsOn.escape.escapeAttrValue;
39 +
32 40 function totalAttachmentBytes(files) {
33 41 return (files || []).reduce((sum, f) => sum + (f && f.size ? f.size : 0), 0);
34 42 }
@@ -152,15 +160,6 @@
152 160 const selectedId = o.selectedAccountId || null;
153 161 const bodyRows = o.bodyRows || 0;
154 162
155 - const escHtml = (s) => {
156 - const d = (typeof document !== 'undefined') ? document.createElement('div') : null;
157 - if (d) { d.textContent = s == null ? '' : String(s); return d.innerHTML; }
158 - return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
159 - '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
160 - })[c]);
161 - };
162 - const escAttr = (s) => escHtml(s).replace(/"/g, '&quot;');
163 -
164 163 let accountOptionsHtml;
165 164 if (o.accountsLoading) {
166 165 accountOptionsHtml = '<option value="">Loading accounts…</option>';
@@ -173,7 +172,7 @@
173 172 const addr = a.email_address || a.email || '';
174 173 const label = name ? `${name} <${addr}>` : addr;
175 174 const sel = id === selectedId ? ' selected' : '';
176 - return `<option value="${escAttr(id)}"${sel}>${escHtml(label)}</option>`;
175 + return `<option value="${escAttr(id)}"${sel}>${esc(label)}</option>`;
177 176 }).join('');
178 177 }
179 178
@@ -219,7 +218,7 @@
219 218 <input type="text" class="form-input form-input--ghost flex-1" id="subject" name="subject" placeholder="Subject" value="${escAttr(prefill.subject || '')}">
220 219 </div>
221 220 <div class="body-container">
222 - <textarea class="body-textarea" id="body" name="body"${bodyRowsAttr} placeholder="Write your message...">${escHtml(prefill.body || '')}</textarea>
221 + <textarea class="body-textarea" id="body" name="body"${bodyRowsAttr} placeholder="Write your message...">${esc(prefill.body || '')}</textarea>
223 222 </div>
224 223 ${attachmentsBlock}
225 224 `;
@@ -266,13 +265,6 @@
266 265 let attachedFiles = [];
267 266 let currentSignature = o.initialSignature || '';
268 267
269 - const escHtmlLocal = (s) => {
270 - const d = document.createElement('div');
271 - d.textContent = s == null ? '' : String(s);
272 - return d.innerHTML;
273 - };
274 - const escAttrLocal = (s) => escHtmlLocal(s).replace(/"/g, '&quot;');
275 -
276 268 // ---------- Autocomplete (self-contained — compose.html doesn't load js/autocomplete.js) ----------
277 269 function getLastToken(input) {
278 270 const val = input.value;
@@ -315,7 +307,7 @@
315 307 filtered.forEach((m) => {
316 308 const item = document.createElement('div');
317 309 item.className = 'autocomplete-item';
318 - item.innerHTML = `<span class="autocomplete-name">${escHtmlLocal(m.name)}</span> <span class="autocomplete-email">${escHtmlLocal(m.email)}</span>`;
310 + item.innerHTML = `<span class="autocomplete-name">${esc(m.name)}</span> <span class="autocomplete-email">${esc(m.email)}</span>`;
319 311 item.addEventListener('mousedown', (e) => { e.preventDefault(); selectMatch(m.email); });
320 312 dropdown.appendChild(item);
321 313 });
@@ -414,7 +406,7 @@
414 406 }
415 407 const items = attachedFiles.map((f, i) => `
416 408 <div class="compose-attachment-item row-flex row-flex-2">
417 - <span class="compose-attachment-name" title="${escAttrLocal(f.path)}">${escHtmlLocal(f.name)}</span>
409 + <span class="compose-attachment-name" title="${escAttr(f.path)}">${esc(f.name)}</span>
418 410 <span class="compose-attachment-size">${formatBytes(f.size || 0)}</span>
419 411 <button type="button" class="compose-attachment-remove" data-compose-remove-attachment="${i}" title="Remove">&times;</button>
420 412 </div>
@@ -0,0 +1,95 @@
1 + /**
2 + * GoingsOn - Escaping primitives (single source of truth)
3 + *
4 + * The CHRONIC-XSS seal lives here. Both the main app (via utils.js, which
5 + * re-exports these onto GoingsOn.utils) and the standalone compose window (which
6 + * loads this file directly) use these exact functions, so there is ONE
7 + * implementation of each escaper and the enforcement gate in js/tests/run.js
8 + * covers every caller. Load this before utils.js (main app) and before
9 + * compose-form.js (compose window).
10 + */
11 + (function() {
12 + 'use strict';
13 +
14 + /**
15 + * Escape HTML special characters for a text/innerHTML context.
16 + * Note: textContent serialization does NOT encode `"`, so this is NOT safe for
17 + * a double-quoted attribute value — use escapeAttrValue there.
18 + * @param {string} text
19 + * @returns {string}
20 + */
21 + function escapeHtml(text) {
22 + if (typeof document !== 'undefined') {
23 + const div = document.createElement('div');
24 + div.textContent = text == null ? '' : String(text);
25 + return div.innerHTML;
26 + }
27 + // Non-DOM fallback (test harness): entity-encode the HTML metacharacters.
28 + return String(text == null ? '' : text).replace(/[&<>"']/g, (c) => ({
29 + '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
30 + })[c]);
31 + }
32 +
33 + /**
34 + * Escape a string for a raw JavaScript string-literal context (NOT an HTML
35 + * attribute). Kept private: only sound as a building block of escapeHandlerArg.
36 + * @param {string} str
37 + * @returns {string}
38 + */
39 + function escapeJsString(str) {
40 + if (str == null) return '';
41 + return String(str)
42 + .replace(/\\/g, '\\\\')
43 + .replace(/'/g, "\\'")
44 + .replace(/"/g, '\\"')
45 + .replace(/\n/g, '\\n')
46 + .replace(/\r/g, '\\r');
47 + }
48 +
49 + /**
50 + * Escape a string for placement inside a double-quoted HTML attribute value.
51 + * Encodes `&` and `"` so the value cannot terminate the attribute. This is the
52 + * ONLY escaper for HTML attributes. (ultra-fuzz Run #28 C1, CHRONIC-XSS.)
53 + * @param {string} str
54 + * @returns {string}
55 + */
56 + function escapeAttrValue(str) {
57 + return String(str ?? '')
58 + .replace(/&/g, '&amp;')
59 + .replace(/"/g, '&quot;');
60 + }
61 +
62 + /**
63 + * Escape a value placed inside a quoted JS string *within* a double-quoted
64 + * inline handler, e.g. `onclick="fn('${escapeHandlerArg(x)}')"`. Survives both
65 + * the JS-string and HTML-attribute layers.
66 + * @param {string} str
67 + * @returns {string}
68 + */
69 + function escapeHandlerArg(str) {
70 + return escapeAttrValue(escapeJsString(str));
71 + }
72 +
73 + /**
74 + * Return a URL only if it uses a safe scheme (http/https/mailto), else "#".
75 + * Blocks `javascript:`, `data:`, and custom-handler schemes. Still pass the
76 + * result through escapeAttrValue for the attribute itself.
77 + * @param {string} url
78 + * @returns {string}
79 + */
80 + function safeUrl(url) {
81 + const s = String(url ?? '').trim();
82 + return /^(https?:|mailto:)/i.test(s) ? s : '#';
83 + }
84 +
85 + const api = { escapeHtml, escapeAttrValue, escapeHandlerArg, safeUrl };
86 +
87 + // Attach to the same GoingsOn the rest of the app references by bare identifier.
88 + // The standalone compose window loads this file with no goingson.js, so create
89 + // the namespace if it doesn't exist yet.
90 + if (typeof GoingsOn === 'undefined') {
91 + (typeof window !== 'undefined' ? window : globalThis).GoingsOn = {};
92 + }
93 + GoingsOn.escape = api;
94 +
95 + })();
@@ -80,8 +80,9 @@ globalThis.GoingsOn = window.GoingsOn;
80 80 // ============================================================
81 81
82 82 require('../goingson'); // Initializes GoingsOn namespace
83 + require('../escape'); // GoingsOn.escape (escaping primitives, single source)
83 84 require('../state'); // GoingsOn.state (AppStateManager)
84 - require('../utils'); // GoingsOn.utils (escapeHtml, escapeAttr, etc.)
85 + require('../utils'); // GoingsOn.utils (re-exports GoingsOn.escape.*)
85 86 require('../pagination-manager'); // GoingsOn.PaginationManager
86 87 require('../selection-manager'); // GoingsOn.SelectionManager
87 88 require('../whats-new'); // GoingsOn.whatsNew (changelog parser/renderer)
@@ -598,6 +599,23 @@ describe('escaping enforcement gate (CHRONIC-XSS)', () => {
598 599 assert(offenders.length === 0,
599 600 `esc() used in an attribute (use escapeAttrValue):\n ${offenders.join('\n ')}`);
600 601 });
602 +
603 + // The hand-rolled attribute escaper `.replace(/"/g, '&quot;')` is the exact
604 + // shape the compose window used to duplicate outside this gate's reach.
605 + // escape.js is the single source of truth; no other file may re-implement it.
606 + const HANDROLLED_ATTR = /replace\(\/"\/g,\s*['"]&quot;['"]\)/;
607 + test('no file re-implements an attribute escaper (single source: escape.js)', () => {
608 + const offenders = [];
609 + for (const f of files) {
610 + if (f === 'escape.js') continue;
611 + const src = fs.readFileSync(path.join(srcDir, f), 'utf8');
612 + src.split('\n').forEach((line, i) => {
613 + if (HANDROLLED_ATTR.test(line)) offenders.push(`${f}:${i + 1} ${line.trim()}`);
614 + });
615 + }
616 + assert(offenders.length === 0,
617 + `hand-rolled attribute escaper found (use GoingsOn.escape / GoingsOn.utils.escapeAttrValue):\n ${offenders.join('\n ')}`);
618 + });
601 619 });
602 620
603 621 // ============================================================
@@ -7,89 +7,18 @@
7 7 'use strict';
8 8
9 9 // ============ HTML Escaping ============
10 -
11 - /**
12 - * Escape HTML special characters to prevent XSS
13 - * @param {string} text - Text to escape
14 - * @returns {string} - Escaped text safe for innerHTML
15 - */
16 - function escapeHtml(text) {
17 - const div = document.createElement('div');
18 - div.textContent = text || '';
19 - return div.innerHTML;
20 - }
21 -
22 - /**
23 - * Escape a string for a raw JavaScript string-literal context (NOT an HTML
24 - * attribute). Backslash-escapes quotes/newlines so the value can't terminate a
25 - * JS string. This is the wrong primitive for `value="..."`/`onclick="..."`
26 - * attributes — the HTML parser ignores backslashes, so a `"` still closes the
27 - * attribute. For attribute contexts use `escapeAttrValue` (plain attribute) or
28 - * `escapeHandlerArg` (a value inside a quoted JS string within an inline
29 - * handler). This function is kept private to the module on purpose: it is only
30 - * sound as a building block of `escapeHandlerArg`. (ultra-fuzz CHRONIC-XSS.)
31 - * @param {string} str - String to escape
32 - * @returns {string} - Escaped string safe for a JS string literal
33 - */
34 - function escapeJsString(str) {
35 - if (str == null) return '';
36 - return String(str)
37 - .replace(/\\/g, '\\\\')
38 - .replace(/'/g, "\\'")
39 - .replace(/"/g, '\\"')
40 - .replace(/\n/g, '\\n')
41 - .replace(/\r/g, '\\r');
42 - }
43 -
44 - /**
45 - * Escape a string for placement inside a double-quoted HTML attribute value.
46 - *
47 - * Unlike `escapeJsString` (which backslash-escapes for a JS-string context) and
48 - * `escapeHtml` (which, via textContent serialization, does NOT encode `"`), this
49 - * encodes `&` and `"` to HTML entities so the value cannot terminate the
50 - * attribute. Use it for any attacker-influenced content going into `value="..."`,
51 - * `title="..."`, `placeholder="..."`, a `data-*` attribute, or a whole inline
52 - * handler expression — the HTML parser decodes the entities before the value (or
53 - * handler JS) is used, so the original text is recovered intact. This is the
54 - * ONLY escaper for HTML attributes; the JS-string escaper is private.
55 - * (ultra-fuzz Run #28 C1, CHRONIC-XSS.)
56 - * @param {string} str
57 - * @returns {string}
58 - */
59 - function escapeAttrValue(str) {
60 - return String(str ?? '')
61 - .replace(/&/g, '&amp;')
62 - .replace(/"/g, '&quot;');
63 - }
64 -
65 - /**
66 - * Escape a value that is placed inside a quoted JS string *within* a
67 - * double-quoted inline handler, e.g. `onclick="fn('${escapeHandlerArg(x)}')"`.
68 - * Such a value must survive two layers: the JS-string layer (a `'` must not
69 - * close the string) and the HTML-attribute layer (a `"` must not close the
70 - * attribute). JS-escape first (so `'`→`\'`), then entity-encode (so `"`→`&quot;`
71 - * and the backslash-escaped quote round-trips: the HTML parser decodes `&#...`
72 - * back to the JS-escaped form before the JS engine sees it). IDs/UUIDs pass
73 - * through unchanged; filenames and other free text are made safe on both layers.
74 - * @param {string} str
75 - * @returns {string}
76 - */
77 - function escapeHandlerArg(str) {
78 - return escapeAttrValue(escapeJsString(str));
79 - }
80 -
81 - /**
82 - * Return a URL only if it uses a safe scheme (http/https/mailto), else "#".
83 - * Blocks `javascript:`, `data:`, and custom-handler schemes in user/sync-supplied
84 - * links (e.g. contact social URLs) before they reach an href (ultra-fuzz Run #28).
85 - * Still pass the result through escapeAttrValue for the attribute itself.
86 - * @param {string} url
87 - * @returns {string}
88 - */
89 - function safeUrl(url) {
90 - const s = String(url ?? '').trim();
91 - return /^(https?:|mailto:)/i.test(s) ? s : '#';
92 - }
10 + //
11 + // The escaping primitives (the CHRONIC-XSS seal) live in js/escape.js as the
12 + // single source of truth, so the standalone compose window shares the exact same
13 + // implementation. utils.js re-exports them onto GoingsOn.utils; every existing
14 + // caller (GoingsOn.utils.escapeHtml / escapeAttrValue / escapeHandlerArg /
15 + // safeUrl) is unchanged. escape.js must load before utils.js.
16 + //
17 + // escapeHtml - text/innerHTML context (does NOT encode ")
18 + // escapeAttrValue - the ONLY escaper for a double-quoted attribute value
19 + // escapeHandlerArg - a value inside a quoted JS string within an inline handler
20 + // safeUrl - allow only http/https/mailto schemes in an href
21 + const { escapeHtml, escapeAttrValue, escapeHandlerArg, safeUrl } = GoingsOn.escape;
93 22
94 23 /**
95 24 * Human-friendly prefixes for machine-readable API error codes.