Skip to main content

max / makenotwork

frontend: fix CSP-dead modal close buttons, consolidate escapeHtml (audit Run 20 Phase 6) - The shortcuts-help and promo-redemptions modal close buttons were built via innerHTML with an inline onclick. The app CSP has no script-src 'unsafe-inline', so those handlers never fired and the buttons were dead. Wire them via .onclick in JS instead; the buttons work again and CSP stays unsafe-inline-free. - escapeHtml was copy-pasted into 8 feature scripts in three different strengths (element-only textContent, 4-char, 5-char). Consolidate into one global in mnw.js using the strongest 5-char impl (safe for element and attribute contexts) and delete the 8 copies — removing the weaker variants and the single-missed-copy XSS-drift risk. mnw.js loads before every feature script. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-03 07:45 UTC
Commit: 727a2b36f2dc24e9403b1967e264083c8f4df147
Parent: 9da0537
9 files changed, +23 insertions, -33 deletions
@@ -208,7 +208,4 @@
208 208 return result;
209 209 }
210 210
211 - function escapeHtml(s) {
212 - return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
213 - }
214 211 })();
@@ -407,11 +407,6 @@
407 407
408 408 // ── Shared ──
409 409
410 - function escapeHtml(s) {
411 - var d = document.createElement('div');
412 - d.textContent = s;
413 - return d.innerHTML;
414 - }
415 410
416 411 // Run on initial load
417 412 init();
@@ -202,7 +202,6 @@
202 202 });
203 203 }
204 204
205 - function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
206 205 function escapeAttr(s) { return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
207 206
208 207 // Upload all button
@@ -10,6 +10,18 @@ function csrfHeaders() {
10 10 return token ? { 'X-CSRF-Token': token } : {};
11 11 }
12 12
13 + /* Shared HTML escaper — the single definition (Run 20). Feature JS files use
14 + this global rather than each shipping their own; consolidating also removes
15 + the weaker per-file variants that escaped fewer characters. Escapes all five
16 + HTML-special characters, so it is safe for both element-content and attribute
17 + interpolation. mnw.js loads before every feature script, so the global is
18 + always available. */
19 + function escapeHtml(s) {
20 + return String(s)
21 + .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
22 + .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
23 + }
24 +
13 25 document.addEventListener('DOMContentLoaded', function() {
14 26 // Read the csrf-token meta LIVE on every HTMX request rather than
15 27 // snapshotting it at load. The token rotates mid-session (e.g. the 2FA
@@ -530,7 +542,7 @@ function toggleShortcutsHelp() {
530 542 '<div class="modal-content" style="max-width: 420px; padding: 2rem;">'
531 543 + '<div class="modal-header" style="margin-bottom: 1rem;">'
532 544 + '<h2>Keyboard Shortcuts</h2>'
533 - + '<button type="button" class="modal-close" onclick="document.getElementById(\'shortcuts-help\').remove()">&times;</button>'
545 + + '<button type="button" class="modal-close" aria-label="Close">&times;</button>'
534 546 + '</div>'
535 547 + '<table style="width: 100%; font-size: 0.9rem;">'
536 548 + '<tr><td style="padding: 0.3rem 0;"><kbd>Cmd+K</kbd></td><td>Search</td></tr>'
@@ -540,6 +552,11 @@ function toggleShortcutsHelp() {
540 552 + '</table>'
541 553 + '</div>';
542 554
555 + // Wire the close button via JS, not an inline onclick — the app CSP has no
556 + // script-src 'unsafe-inline', so a generated inline handler is dead (Run 20).
557 + var closeBtn = overlay.querySelector('.modal-close');
558 + if (closeBtn) closeBtn.onclick = function() { overlay.remove(); };
559 +
543 560 document.body.appendChild(overlay);
544 561 }
545 562
@@ -106,7 +106,6 @@
106 106 items.forEach(function(el, i) { el.classList.toggle('highlighted', i === selectedIdx); });
107 107 }
108 108
109 - function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
110 109
111 110 document.addEventListener('click', function(e) {
112 111 if (!box.contains(e.target) && e.target !== input) { box.style.display = 'none'; }
@@ -36,11 +36,6 @@ function renderMarkdownPreview() {
36 36 preview.innerHTML = html || '<p class="text-editor-placeholder">Nothing to preview...</p>';
37 37 }
38 38
39 - function escapeHtml(text) {
40 - const div = document.createElement('div');
41 - div.textContent = text;
42 - return div.innerHTML;
43 - }
44 39
45 40 function updateWordCount() {
46 41 const body = document.getElementById('text-body').value;
@@ -7,11 +7,6 @@
7 7 // reads the csrf-token meta live on each call, which matters because the
8 8 // token rotates mid-session. Don't shadow it with a local copy.
9 9
10 - function escapeHtml(s) {
11 - var d = document.createElement('div');
12 - d.textContent = s;
13 - return d.innerHTML;
14 - }
15 10
16 11 function showToast(msg) {
17 12 if (window.showToast) { window.showToast(msg); return; }
@@ -40,10 +40,13 @@ function showPromoRedemptions(codeId, codeLabel) {
40 40 content.innerHTML =
41 41 '<div class="modal-header" style="margin-bottom: 1rem;">'
42 42 + '<h2>Redemptions: <code>' + escapeHtml(codeLabel) + '</code></h2>'
43 - + '<button type="button" class="modal-close" aria-label="Dismiss"'
44 - + ' onclick="document.getElementById(\'promo-redemptions-modal\').remove()">&times;</button>'
43 + + '<button type="button" class="modal-close" aria-label="Dismiss">&times;</button>'
45 44 + '</div>'
46 45 + '<div id="promo-redemptions-body" class="muted">Loading…</div>';
46 + // Wire the close button via JS, not an inline onclick — the app CSP has no
47 + // script-src 'unsafe-inline', so a generated inline handler is dead (Run 20).
48 + var closeBtn = content.querySelector('.modal-close');
49 + if (closeBtn) closeBtn.onclick = function () { overlay.remove(); };
47 50 overlay.appendChild(content);
48 51 document.body.appendChild(overlay);
49 52
@@ -81,11 +84,6 @@ function showPromoRedemptions(codeId, codeLabel) {
81 84 });
82 85 }
83 86
84 - function escapeHtml(s) {
85 - return String(s)
86 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
87 - .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
88 - }
89 87
90 88 /// Switch between preset trial lengths and a custom number input. The
91 89 /// hidden #pc-trial-days carries the actual submitted value so we can keep
@@ -42,11 +42,6 @@
42 42 queueEl.classList.remove('hidden');
43 43 }
44 44
45 - function escapeHtml(s) {
46 - var d = document.createElement('div');
47 - d.textContent = s;
48 - return d.innerHTML;
49 - }
50 45
51 46 function escapeAttr(s) {
52 47 return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');