Skip to main content

max / goingson

Frontend XSS: seal the unsafe attribute escaper (CHRONIC-XSS) The JS-string escaper escapeAttr backslash-escaped quotes, which does not prevent HTML-attribute breakout: a `"` in attacker data still closes the attribute. It had recurred as a stored-XSS vector across multiple ultra-fuzz runs because the correct helper (escapeAttrValue) was opt-in per call site. Structural fix so the bad pattern is no longer writable: - Rename escapeAttr -> escapeJsString and stop exporting it; the namespace now exposes only escapeAttrValue (entity-encode, for plain attributes) and escapeHandlerArg (JS-escape then entity-encode, for a value inside a quoted JS string within an inline handler). - Repoint all 32 escAttr aliases and 5 inline calls to escapeAttrValue. - Use escapeHandlerArg for the quote-bearing handler args (attachment/backup filenames and paths) that must survive both the JS-string and attribute layers. - Fix the genuinely exploitable esc()-in-attribute sites: task title in day-planning title/aria-label, project name in events value. - Add a test-suite enforcement gate that fails the build if utils.escapeAttr or esc() in a quoted attribute reappears. Also fixes a pre-existing latent bug: time-tracking.js called escAttr unbound (strict-mode ReferenceError) when the timer-view buttons rendered. 54/54 JS tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 01:39 UTC
Commit: 278da601e4851c8dc0098aeb4aee3a5300537413
Parent: c3024b7
36 files changed, +158 insertions, -68 deletions
@@ -6,8 +6,9 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10 const escAttrVal = GoingsOn.utils.escapeAttrValue;
11 + const escArg = GoingsOn.utils.escapeHandlerArg;
11 12
12 13 // MIME type to icon mapping
13 14 const MIME_ICONS = {
@@ -67,7 +68,7 @@
67 68 <div class="attachment-actions">
68 69 ${a.hasLocalBlob ? `
69 70 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.attachments.open('${escAttr(a.id)}')" title="Open">Open</button>
70 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.attachments.saveAs('${escAttr(a.id)}', '${escAttr(a.filename)}')" title="Save As">Save</button>
71 + <button class="btn btn-sm btn-secondary" onclick="GoingsOn.attachments.saveAs('${escAttr(a.id)}', '${escArg(a.filename)}')" title="Save As">Save</button>
71 72 ` : `
72 73 <span class="attachment-sync-warning">Sync needed</span>
73 74 `}
@@ -6,7 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10
11 11 /**
12 12 * Show or hide the bulk actions bars based on current selection state.
@@ -199,7 +199,7 @@
199 199 let optionsHtml = `<p class="bulk-modal-prompt bulk-modal-prompt--wide">Set project for ${selectedTaskIds.size} tasks:</p>`;
200 200 optionsHtml += `<button class="btn btn-sm text-left w-full bulk-modal-option-btn" onclick="GoingsOn.bulk._applyProject(null)">No Project</button>`;
201 201 for (const p of projects) {
202 - optionsHtml += `<button class="btn btn-sm text-left w-full bulk-modal-option-btn" onclick="GoingsOn.bulk._applyProject('${GoingsOn.utils.escapeAttr(p.id)}')">${GoingsOn.utils.escapeHtml(p.name)}</button>`;
202 + optionsHtml += `<button class="btn btn-sm text-left w-full bulk-modal-option-btn" onclick="GoingsOn.bulk._applyProject('${GoingsOn.utils.escapeAttrValue(p.id)}')">${GoingsOn.utils.escapeHtml(p.name)}</button>`;
203 203 }
204 204 GoingsOn.ui.openModal('Set Project', `<div class="bulk-modal-scroll">${optionsHtml}</div>`);
205 205 }
@@ -408,8 +408,8 @@ function showPromptDialog(title, message, options = {}) {
408 408 </div>
409 409 <div class="form-group">
410 410 <input type="text" class="form-input" id="${inputId}"
411 - value="${GoingsOn.utils.escapeAttr(defaultValue)}"
412 - placeholder="${GoingsOn.utils.escapeAttr(placeholder)}"
411 + value="${GoingsOn.utils.escapeAttrValue(defaultValue)}"
412 + placeholder="${GoingsOn.utils.escapeAttrValue(placeholder)}"
413 413 autofocus>
414 414 <div id="${errorId}" class="form-error"></div>
415 415 </div>
@@ -66,7 +66,7 @@ function emptyStateIcon(key) {
66 66 function renderEmptyState(message, buttonLabel, onClickFn, iconKey) {
67 67 let html = `<div class="empty-state">${emptyStateIcon(iconKey)}<p class="empty-state-text">${GoingsOn.utils.escapeHtml(message)}</p>`;
68 68 if (buttonLabel && onClickFn) {
69 - html += `<button class="btn btn-primary empty-state-action" onclick="${GoingsOn.utils.escapeAttr(onClickFn)}">${GoingsOn.utils.escapeHtml(buttonLabel)}</button>`;
69 + html += `<button class="btn btn-primary empty-state-action" onclick="${GoingsOn.utils.escapeAttrValue(onClickFn)}">${GoingsOn.utils.escapeHtml(buttonLabel)}</button>`;
70 70 }
71 71 html += `</div>`;
72 72 return html;
@@ -92,7 +92,7 @@ function renderEmptyState(message, buttonLabel, onClickFn, iconKey) {
92 92 function renderFormField(field) {
93 93 const utils = GoingsOn.utils;
94 94 const esc = utils.escapeHtml;
95 - const escAttr = utils.escapeAttr;
95 + const escAttr = utils.escapeAttrValue;
96 96 const kind = field.kind || field.type || 'text';
97 97 const inputId = field.id || field.name;
98 98 const value = field.value ?? '';
@@ -8,7 +8,7 @@
8 8 (function() {
9 9 'use strict';
10 10 const esc = GoingsOn.utils.escapeHtml;
11 - const escAttr = GoingsOn.utils.escapeAttr;
11 + const escAttr = GoingsOn.utils.escapeAttrValue;
12 12
13 13 let currentContactId = null;
14 14
@@ -6,7 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10 const escAttrVal = GoingsOn.utils.escapeAttrValue;
11 11 const safeUrl = GoingsOn.utils.safeUrl;
12 12
@@ -6,7 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10
11 11 // ============ Selection State ============
12 12
@@ -6,7 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10
11 11 // ============ Utility ============
12 12
@@ -6,7 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10
11 11 // ============ Constants ============
12 12
@@ -126,8 +126,8 @@
126 126 data-duration="${duration}"
127 127 onclick="GoingsOn.dayPlan.openTimelineItem('${escAttr(item.id)}', '${escAttr(item.itemType)}')"${dragHandler}
128 128 onkeydown="GoingsOn.dayPlan.handleTimelineItemKeydown(event, '${escAttr(item.id)}', '${escAttr(item.itemType)}')"
129 - title="${esc(item.title)}${titleHint}${keyboardHint}"
130 - tabindex="0" role="button" aria-label="${esc(item.title)}${keyboardHint}">
129 + title="${escAttr(item.title)}${titleHint}${keyboardHint}"
130 + tabindex="0" role="button" aria-label="${escAttr(item.title)}${keyboardHint}">
131 131 <div class="timeline-item-title">${esc(item.title)}</div>
132 132 <div class="timeline-item-meta">${esc(metaText)}</div>
133 133 </div>
@@ -6,7 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10
11 11 // ============ Schedule Task Modal ============
12 12
@@ -7,7 +7,7 @@
7 7 (function() {
8 8 'use strict';
9 9 const esc = GoingsOn.utils.escapeHtml;
10 - const escAttr = GoingsOn.utils.escapeAttr;
10 + const escAttr = GoingsOn.utils.escapeAttrValue;
11 11
12 12 // ============ Account Form Builder ============
13 13
@@ -9,7 +9,8 @@
9 9 (function() {
10 10 'use strict';
11 11 const esc = GoingsOn.utils.escapeHtml;
12 - const escAttr = GoingsOn.utils.escapeAttr;
12 + const escAttr = GoingsOn.utils.escapeAttrValue;
13 + const escArg = GoingsOn.utils.escapeHandlerArg;
13 14 const escAttrVal = GoingsOn.utils.escapeAttrValue;
14 15
15 16 // ============ Email Selection & Pagination ============
@@ -522,8 +523,8 @@
522 523 <span class="attachment-filename email-attachment-name"
523 524 title="${escAttr(a.filename)}">${esc(a.filename)}</span>
524 525 <span class="email-attachment-size">${esc(a.sizeFormatted)}</span>
525 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.openBlob('${escAttr(a.blobHash)}', '${escAttr(a.filename)}')" title="Open">Open</button>
526 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.saveBlob('${escAttr(a.blobHash)}', '${escAttr(a.filename)}')" title="Save">Save</button>
526 + <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.openBlob('${escAttr(a.blobHash)}', '${escArg(a.filename)}')" title="Open">Open</button>
527 + <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.saveBlob('${escAttr(a.blobHash)}', '${escArg(a.filename)}')" title="Save">Save</button>
527 528 </div>
528 529 `;
529 530 }).join('');
@@ -6,7 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10 const escAttrVal = GoingsOn.utils.escapeAttrValue;
11 11
12 12 let currentMonthDate = new Date();
@@ -8,7 +8,7 @@
8 8 (function() {
9 9 'use strict';
10 10 const esc = GoingsOn.utils.escapeHtml;
11 - const escAttr = GoingsOn.utils.escapeAttr;
11 + const escAttr = GoingsOn.utils.escapeAttrValue;
12 12
13 13 // ============ Reminder Presets ============
14 14
@@ -525,7 +525,7 @@
525 525 extraContent: (project ? `
526 526 <div class="form-group">
527 527 <label class="form-label">Project</label>
528 - <input type="text" class="form-input" value="${esc(project.name)}" disabled>
528 + <input type="text" class="form-input" value="${escAttr(project.name)}" disabled>
529 529 </div>
530 530 ` : '') + buildRemindersHtml(null),
531 531 });
@@ -6,7 +6,8 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 + const escArg = GoingsOn.utils.escapeHandlerArg;
10 11
11 12 // ============ Export Functions ============
12 13
@@ -118,8 +119,8 @@
118 119 <div class="backup-item-meta">${dateStr} - ${sizeStr}</div>
119 120 </div>
120 121 <div class="backup-item-actions">
121 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.export.restoreFromBackup('${escAttr(backup.filePath)}')">Restore</button>
122 - <button class="btn btn-sm btn-danger" onclick="GoingsOn.export.deleteBackup('${escAttr(backup.filePath)}')">Delete</button>
122 + <button class="btn btn-sm btn-secondary" onclick="GoingsOn.export.restoreFromBackup('${escArg(backup.filePath)}')">Restore</button>
123 + <button class="btn btn-sm btn-danger" onclick="GoingsOn.export.deleteBackup('${escArg(backup.filePath)}')">Delete</button>
123 124 </div>
124 125 </div>
125 126 `;
@@ -6,7 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10
11 11 // AbortController for modal event listeners — prevents duplicate submit handlers
12 12 let modalAbortController = null;
@@ -6,7 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttr;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
10 10 const escAttrVal = GoingsOn.utils.escapeAttrValue;
11 11
12 12 // ============ State ============
@@ -7,7 +7,7 @@
7 7 'use strict';
8 8
9 9 const esc = GoingsOn.utils.escapeHtml;
10 - const escAttr = GoingsOn.utils.escapeAttr;
10 + const escAttr = GoingsOn.utils.escapeAttrValue;
11 11
12 12 /**
13 13 * Truncate a string with an ellipsis.
@@ -254,7 +254,7 @@ async function showDaySummary(dateStr) {
254 254 try {
255 255 const day = await GoingsOn.api.dayPlanning.getDay(dateStr);
256 256 const esc = GoingsOn.utils.escapeHtml;
257 - const escAttr = GoingsOn.utils.escapeAttr;
257 + const escAttr = GoingsOn.utils.escapeAttrValue;
258 258
259 259 const dateDisplay = new Date(dateStr + 'T12:00:00')
260 260 .toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
@@ -63,7 +63,7 @@
63 63 */
64 64 function renderReflection({ idPrefix, prompts }) {
65 65 const esc = GoingsOn.utils.escapeHtml;
66 - const escAttr = GoingsOn.utils.escapeAttr;
66 + const escAttr = GoingsOn.utils.escapeAttrValue;
67 67
68 68 const fields = prompts.map(p => `
69 69 <div class="reflection-prompt">