Skip to main content

max / goingson

UX/security: encode attacker-controlled values for HTML attributes, guard URL schemes Ultra-fuzz Run #28 Phase 5 (escaping cluster). escapeAttr backslash-escapes for a JS-string context, so a `"` in sync/email-supplied content still terminated a double-quoted attribute (same XSS class as the C1 label sink). Switch the attacker-controlled HTML-attribute-value sinks to escapeAttrValue (entity-encodes & and "): attachment filenames, event titles, contact names, imported cell values. Contact social/custom URLs additionally go through a new safeUrl helper that rejects javascript:/data: and other non-http(s)/mailto schemes before the href. NEEDS EYEBALL: frontend not runtime-verified here (node --check only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 20:07 UTC
Commit: a5b3305e46432df925c2aed04e9fe8115765ae2e
Parent: 9d72370
6 files changed, +28 insertions, -8 deletions
@@ -7,6 +7,7 @@
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 9 const escAttr = GoingsOn.utils.escapeAttr;
10 + const escAttrVal = GoingsOn.utils.escapeAttrValue;
10 11
11 12 // MIME type to icon mapping
12 13 const MIME_ICONS = {
@@ -60,7 +61,7 @@
60 61 <span class="attachment-icon">${getIcon(a.mimeType)}</span>
61 62 <div class="attachment-info">
62 63 <div class="attachment-filename"
63 - title="${escAttr(a.filename)}">${esc(a.filename)}</div>
64 + title="${escAttrVal(a.filename)}">${esc(a.filename)}</div>
64 65 <div class="attachment-meta">${esc(a.fileSizeFormatted)}</div>
65 66 </div>
66 67 <div class="attachment-actions">
@@ -7,6 +7,8 @@
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 9 const escAttr = GoingsOn.utils.escapeAttr;
10 + const escAttrVal = GoingsOn.utils.escapeAttrValue;
11 + const safeUrl = GoingsOn.utils.safeUrl;
10 12
11 13 // ============ Helpers ============
12 14
@@ -98,7 +100,7 @@
98 100 // Social handles
99 101 const socialRows = (contact.socialHandles || []).map(s => `
100 102 <div class="sub-item">
101 - <span><strong>${esc(s.platform)}:</strong> ${s.url ? `<a href="${escAttr(s.url)}" target="_blank">${esc(s.handle)}</a>` : esc(s.handle)}</span>
103 + <span><strong>${esc(s.platform)}:</strong> ${s.url ? `<a href="${escAttrVal(safeUrl(s.url))}" target="_blank">${esc(s.handle)}</a>` : esc(s.handle)}</span>
102 104 <button class="btn btn-sm btn-danger" onclick="GoingsOn.contacts.removeSocialHandle('${escAttr(contact.id)}', '${escAttr(s.id)}')" title="Remove">x</button>
103 105 </div>
104 106 `).join('');
@@ -106,7 +108,7 @@
106 108 // Custom fields
107 109 const customFieldRows = (contact.customFields || []).map(f => `
108 110 <div class="sub-item">
109 - <span><strong>${esc(f.label)}:</strong> ${f.url ? `<a href="${escAttr(f.url)}" target="_blank">${esc(f.value)}</a>` : esc(f.value)}</span>
111 + <span><strong>${esc(f.label)}:</strong> ${f.url ? `<a href="${escAttrVal(safeUrl(f.url))}" target="_blank">${esc(f.value)}</a>` : esc(f.value)}</span>
110 112 <button class="btn btn-sm btn-danger" onclick="GoingsOn.contacts.removeCustomField('${escAttr(contact.id)}', '${escAttr(f.id)}')" title="Remove">x</button>
111 113 </div>
112 114 `).join('');
@@ -7,6 +7,7 @@
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 9 const escAttr = GoingsOn.utils.escapeAttr;
10 + const escAttrVal = GoingsOn.utils.escapeAttrValue;
10 11
11 12 let currentMonthDate = new Date();
12 13 let currentWeekDate = new Date();
@@ -126,7 +127,7 @@
126 127 const maxShow = 3;
127 128 dayEvents.slice(0, maxShow).forEach(e => {
128 129 const blockClass = e.blockType ? `block-${e.blockType}` : '';
129 - html += `<div class="cal-event-chip ${blockClass}" onclick="event.stopPropagation(); GoingsOn.events.open('${escAttr(e.id)}')" title="${escAttr(e.title)}">${esc(truncate(e.title, 18))}</div>`;
130 + html += `<div class="cal-event-chip ${blockClass}" onclick="event.stopPropagation(); GoingsOn.events.open('${escAttr(e.id)}')" title="${escAttrVal(e.title)}">${esc(truncate(e.title, 18))}</div>`;
130 131 });
131 132 if (dayEvents.length > maxShow) {
132 133 html += `<div class="cal-event-more">+${dayEvents.length - maxShow} more</div>`;
@@ -254,7 +255,7 @@
254 255 html += '<div class="cal-week-allday-cell">';
255 256 dayEvts.forEach(e => {
256 257 const blockClass = e.blockType ? `block-${e.blockType}` : '';
257 - html += `<div class="cal-event-chip ${blockClass}" onclick="GoingsOn.events.open('${escAttr(e.id)}')" title="${escAttr(e.title)}">${esc(truncate(e.title, 14))}</div>`;
258 + html += `<div class="cal-event-chip ${blockClass}" onclick="GoingsOn.events.open('${escAttr(e.id)}')" title="${escAttrVal(e.title)}">${esc(truncate(e.title, 14))}</div>`;
258 259 });
259 260 html += '</div>';
260 261 adCursor.setDate(adCursor.getDate() + 1);
@@ -298,7 +299,7 @@
298 299 const heightPx = Math.max((durationMinutes / 15) * SLOT_HEIGHT, SLOT_HEIGHT);
299 300 const blockClass = e.blockType ? `block-${e.blockType}` : '';
300 301
301 - html += `<div class="cal-week-event ${blockClass}" style="top: ${topPx}px; height: ${heightPx}px;" onclick="GoingsOn.events.open('${escAttr(e.id)}')" title="${escAttr(e.title + ' ' + e.timeFormatted)}">
302 + html += `<div class="cal-week-event ${blockClass}" style="top: ${topPx}px; height: ${heightPx}px;" onclick="GoingsOn.events.open('${escAttr(e.id)}')" title="${escAttrVal(e.title + ' ' + e.timeFormatted)}">
302 303 <div class="cal-week-event-title">${esc(truncate(e.title, 20))}</div>
303 304 <div class="cal-week-event-time">${esc(e.timeFormatted)}</div>
304 305 </div>`;
@@ -7,6 +7,7 @@
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 9 const escAttr = GoingsOn.utils.escapeAttr;
10 + const escAttrVal = GoingsOn.utils.escapeAttrValue;
10 11
11 12 // ============ State ============
12 13
@@ -268,7 +269,7 @@
268 269 <tr>
269 270 ${columns.map(c => {
270 271 const value = data[c.key] || '';
271 - return `<td title="${escAttr(value)}">${esc(truncate(value, 50))}</td>`;
272 + return `<td title="${escAttrVal(value)}">${esc(truncate(value, 50))}</td>`;
272 273 }).join('')}
273 274 </tr>
274 275 `;
@@ -7,6 +7,7 @@
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 9 const escAttr = GoingsOn.utils.escapeAttr;
10 + const escAttrVal = GoingsOn.utils.escapeAttrValue;
10 11
11 12 // ============ Task Rendering Helpers ============
12 13
@@ -96,7 +97,7 @@
96 97 <span class="task-description-text">${esc(displayDesc)}</span>
97 98 ${renderTimeBadge(t)}
98 99 ${renderTaskBadges(t)}
99 - ${t.contactName ? `<span class="contact-badge" title="${escAttr(t.contactName)}">${esc(t.contactName)}</span>` : ''}
100 + ${t.contactName ? `<span class="contact-badge" title="${escAttrVal(t.contactName)}">${esc(t.contactName)}</span>` : ''}
100 101 ${t.isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}">Snoozed</span>` : ''}
101 102 </div>
102 103 <div class="task-cell task-project">${esc(t.projectName) || '-'}</div>
@@ -55,6 +55,19 @@ function escapeAttrValue(str) {
55 55 }
56 56
57 57 /**
58 + * Return a URL only if it uses a safe scheme (http/https/mailto), else "#".
59 + * Blocks `javascript:`, `data:`, and custom-handler schemes in user/sync-supplied
60 + * links (e.g. contact social URLs) before they reach an href (ultra-fuzz Run #28).
61 + * Still pass the result through escapeAttrValue for the attribute itself.
62 + * @param {string} url
63 + * @returns {string}
64 + */
65 + function safeUrl(url) {
66 + const s = String(url ?? '').trim();
67 + return /^(https?:|mailto:)/i.test(s) ? s : '#';
68 + }
69 +
70 + /**
58 71 * Human-friendly prefixes for machine-readable API error codes.
59 72 * Backend sends structured ApiError { code, message, details }.
60 73 */
@@ -649,6 +662,7 @@ GoingsOn.utils = {
649 662 escapeHtml,
650 663 escapeAttr,
651 664 escapeAttrValue,
665 + safeUrl,
652 666 getErrorMessage,
653 667 showError,
654 668