max / goingson
8 files changed,
+161 insertions,
-173 deletions
| @@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| 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 | - | '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', | |
| 160 | - | })[c]); | |
| 161 | - | }; | |
| 162 | - | const escAttr = (s) => escHtml(s).replace(/"/g, '"'); | |
| 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, '"'); | |
| 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">×</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 | + | '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', | |
| 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, '&') | |
| 59 | + | .replace(/"/g, '"'); | |
| 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, '"')` 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*['"]"['"]\)/; | |
| 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, '&') | |
| 62 | - | .replace(/"/g, '"'); | |
| 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 `"`→`"` | |
| 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. |