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>
36 files changed,
+158 insertions,
-68 deletions
| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
|
(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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
|
'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 |
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 |
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">
|