mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-14 11:37:21 +00:00
fix(ui): escape observer iata, name and id, and node names, in eight render sinks (#2118)
Anyone who can publish to an MQTT broker that CoreScope reads controls three strings: the observer `iata` (a topic segment), the observer `name` (the JSON `origin` field) and the observer `id` (a topic segment). Anyone with a radio controls the node `name` in an ADVERT. Eight places wrote these into the page with `innerHTML` without escaping, so a crafted value runs as script in every visitor's browser: | file | what | |---|---| | `packets.js` | Region badge in the group, child and single packet rows | | `observers.js` | Region badge in the observers table and in the slide-over | | `live.js` | "Heard By — Regions" header in the node detail | | `nodes.js` | observer name in the clock-skew evidence panel | | `analytics.js` | `observer_id` inside `data-observer=` and `id=` attributes | | `app.js` | node name in the favorites dropdown | | `customize-v2.js` | node name in the geofilter prune preview (admin page) | **Fix:** each value now goes through the existing `escapeHtml()` / `esc()` helper. No behaviour change for normal values. **Tests:** one test per sink added to `tests/unit/test-xss-escape-sinks.js`, following the file's existing pattern (pull the template out of the source, render it with a hostile value, check the output is escaped). Reverting any single fix makes its test fail. 41/41 pass. **Notes for reviewers:** - The ingestor uppercases `iata` before storing it. That is not a defence: tag and attribute names are case-insensitive, and script can be written with numeric character references. - An operator who has set an `observerIATAWhitelist` is protected from the `iata` sinks but not from the name/id sinks. - Follow-ups worth a separate PR: validate `iata` and observer `id` to a safe character set at ingest, cap the `origin` length, and add a `Content-Security-Policy` header. This PR is escaping only so it is easy to review and safe to ship. Running in production on our instance since 2026-10-07. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Mythos 5.1
parent
29095658e9
commit
57395e2b28
+2
-2
@@ -4319,13 +4319,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
|
||||
else if (obs.current_noise_floor >= -100) nfClass = 'rf-nf-warning';
|
||||
}
|
||||
|
||||
return `<div class="rf-cell${isSelected ? ' rf-cell-selected' : ''}" data-observer="${obs.observer_id}" tabindex="0" role="button" aria-label="Observer ${esc(name)}, noise floor ${nf} dBm">
|
||||
return `<div class="rf-cell${isSelected ? ' rf-cell-selected' : ''}" data-observer="${esc(obs.observer_id)}" tabindex="0" role="button" aria-label="Observer ${esc(name)}, noise floor ${nf} dBm">
|
||||
<div class="rf-cell-header">
|
||||
<span class="rf-cell-name">${esc(name)}</span>
|
||||
<span class="rf-cell-nf ${nfClass}">${nf} dBm</span>
|
||||
${batt ? `<span class="rf-cell-batt">${batt}</span>` : ''}
|
||||
</div>
|
||||
<div class="rf-cell-sparkline" id="rf-spark-${obs.observer_id}"></div>
|
||||
<div class="rf-cell-sparkline" id="rf-spark-${esc(obs.observer_id)}"></div>
|
||||
<div class="rf-cell-stats">
|
||||
<span>avg: ${avgNf}</span>
|
||||
<span>max: ${maxNf}</span>
|
||||
|
||||
+1
-1
@@ -1734,7 +1734,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
const statusLabel = age === null ? 'unknown' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent';
|
||||
return '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
|
||||
+ '<span class="fav-dd-status ' + statusCls + '" title="' + statusLabel + '" aria-label="' + statusLabel + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>'
|
||||
+ '<span class="fav-dd-name">' + (h.node.name || truncate(pk, 12)) + '</span>'
|
||||
+ '<span class="fav-dd-name">' + escapeHtml(h.node.name || truncate(pk, 12)) + '</span>'
|
||||
+ '<span class="fav-dd-meta">' + (h.stats.lastHeard ? timeAgo(h.stats.lastHeard) : 'never') + '</span>'
|
||||
+ favStar(pk, 'fav-dd-star')
|
||||
+ '</a>';
|
||||
|
||||
@@ -2069,7 +2069,7 @@
|
||||
}
|
||||
listEl.innerHTML = _gfPruneNodes.map(function (n) {
|
||||
var coords = n.lat != null ? (' · ' + n.lat.toFixed(4) + ', ' + n.lon.toFixed(4)) : '';
|
||||
return '<div>' + (n.name || n.pubkey.slice(0, 12)) + coords + '</div>';
|
||||
return '<div>' + esc(n.name || n.pubkey.slice(0, 12)) + coords + '</div>';
|
||||
}).join('');
|
||||
confirmBtn.textContent = 'Delete ' + count + ' node' + (count !== 1 ? 's' : '');
|
||||
resultEl.style.display = '';
|
||||
|
||||
+1
-1
@@ -2644,7 +2644,7 @@
|
||||
|
||||
if (observers.length) {
|
||||
const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))];
|
||||
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Heard By${regions.length ? ' — Regions: ' + regions.join(', ') : ''}</h4>
|
||||
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Heard By${regions.length ? ' — Regions: ' + regions.map(r => escapeHtml(r)).join(', ') : ''}</h4>
|
||||
<div style="font-size:11px;">` +
|
||||
observers.map(o => `<div style="padding:2px 0;"><a href="#/observers/${encodeURIComponent(o.observer_id)}" style="color:var(--link-color);text-decoration:none;">${escapeHtml(o.observer_name || o.observer_id.slice(0, 12))}${o.iata ? ' (' + escapeHtml(o.iata) + ')' : ''}</a> — ${o.packetCount || o.count || 0} pkts</div>`).join('') +
|
||||
'</div>';
|
||||
|
||||
+1
-1
@@ -1205,7 +1205,7 @@
|
||||
}
|
||||
var header = '<div style="font-weight:600;font-size:12px;margin-top:6px">Hash ' + shortHash + ' · ' + obsCount + ' observer' + (obsCount !== 1 ? 's' : '') + ' · median corrected: ' + medianLabel + '</div>';
|
||||
var lines = (ev.observers || []).map(function(o) {
|
||||
var name = o.observerName || o.observerID;
|
||||
var name = escapeHtml(o.observerName || o.observerID);
|
||||
return '<div style="font-size:11px;padding-left:16px;font-family:var(--mono)">' +
|
||||
name + ' raw=' + formatSkew(o.rawSkewSec) + ' corrected=' + formatSkew(o.correctedSkewSec) + ' (observer offset ' + formatSkew(o.observerOffsetSec) + ')' +
|
||||
'</div>';
|
||||
|
||||
+2
-2
@@ -413,7 +413,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" data-observer-id="${escapeHtml(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
||||
<td data-value="${_healthRank}"><span class="health-dot ${h.cls}" title="${h.label}">${shapeIcon}</span> ${h.label}</td>
|
||||
<td data-testid="obs-cell-name" data-value="${escapeHtml(String(o.name || o.id))}" class="mono">${escapeHtml(o.name || o.id)}${window.ObserversNaiveChip.render(o)}${o.can_relay === false ? ' <span class="badge-listener" title="Firmware reported repeat:off — listener-only; excluded from path-hop disambiguator (issue #1290)">listener</span>' : (o.can_relay === true ? ' <span class="badge-repeater" title="Firmware reported repeat:on — eligible as a path hop">repeater</span>' : '')}</td>
|
||||
<td data-value="${escapeHtml(o.iata || '')}">${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
|
||||
<td data-value="${escapeHtml(o.iata || '')}">${o.iata ? `<span class="badge-region">${escapeHtml(o.iata)}</span>` : '—'}</td>
|
||||
<td data-value="${_lastSeenMs}">${timeAgo(o.last_seen)}</td>
|
||||
<td data-testid="obs-cell-last-packet" data-value="${_lastPktMs}">${o.last_packet_at ? timeAgo(o.last_packet_at) : '<span class="text-muted">—</span>'}</td>
|
||||
<td data-value="${_healthRank}">${packetBadge(o)}</td>
|
||||
@@ -526,7 +526,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
(naiveChipHTML ? '<div style="margin-bottom:10px">' + naiveChipHTML + ' <span class="text-muted">Clock is naive — per-packet timing clamped to ingest time. Clears itself 24h after the last skew event.</span></div>' : '') +
|
||||
'<dl class="slide-over-dl" style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:13px">' +
|
||||
'<dt>Status</dt><dd><span class="health-dot ' + h.cls + '"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"></use></svg></span> ' + h.label + '</dd>' +
|
||||
'<dt>Region</dt><dd>' + (o.iata ? '<span class="badge-region">' + o.iata + '</span>' : '—') + '</dd>' +
|
||||
'<dt>Region</dt><dd>' + (o.iata ? '<span class="badge-region">' + escapeHtml(o.iata) + '</span>' : '—') + '</dd>' +
|
||||
'<dt>Last status</dt><dd>' + timeAgo(o.last_seen) + '</dd>' +
|
||||
'<dt>Last packet</dt><dd>' + (o.last_packet_at ? timeAgo(o.last_packet_at) : '—') + '</dd>' +
|
||||
'<dt>Total packets</dt><dd>' + (o.packet_count || 0).toLocaleString() + '</dd>' +
|
||||
|
||||
+3
-3
@@ -2546,7 +2546,7 @@
|
||||
const _grpStyle = _grpHashStripe + _grpChanStyle;
|
||||
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_grpStyle ? ' style="' + _grpStyle + '"' : ''}>
|
||||
<td class="col-expand" style="text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-right"/></svg>')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${escapeHtml(groupRegion)}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${groupSize || ''}">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
@@ -2581,7 +2581,7 @@
|
||||
const childPathStr = renderPath(childPath, c.observer_id, { summary: true });
|
||||
const _childHashStripe = _hashStripeStyle(c.hash || p.hash);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_childHashStripe ? ' style="' + _childHashStripe + '"' : ''}>
|
||||
<td class="col-expand"></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-expand"></td><td class="col-region">${childRegion ? `<span class="badge-region">${escapeHtml(childRegion)}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(c.hash || '')}">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
@@ -2618,7 +2618,7 @@
|
||||
const _flatHashStripe = _hashStripeStyle(p.hash);
|
||||
const _flatStyle = _flatHashStripe + _chanStyle;
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}"${_flatStyle ? ' style="' + _flatStyle + '"' : ''}>
|
||||
<td class="col-expand"></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-expand"></td><td class="col-region">${region ? `<span class="badge-region">${escapeHtml(region)}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
|
||||
@@ -600,6 +600,101 @@ test('app.js unknown-route page escapes the route taken from location.hash', ()
|
||||
assertNoXss(html, 'app.js unknown route');
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// H. Observer-controlled strings (iata / name / id) and node names
|
||||
// These values come straight from the MQTT topic or JSON (observer iata,
|
||||
// name, id) or from an ADVERT packet (node name). Anyone who can publish
|
||||
// to a broker, or transmit on the mesh, controls them.
|
||||
// =========================================================================
|
||||
console.log('\n=== H. observer iata / name / id and node-name sinks ===');
|
||||
|
||||
// Like evalTemplate, but for a string-concatenation expression rather than
|
||||
// a template literal. The captured group is evaluated as a JS expression.
|
||||
function evalConcat(srcFile, regex, bindings) {
|
||||
const src = fs.readFileSync(srcFile, 'utf8');
|
||||
const m = src.match(regex);
|
||||
if (!m) throw new Error(`expression not found in ${srcFile} via ${regex}`);
|
||||
const argNames = Object.keys(bindings);
|
||||
const args = Object.values(bindings);
|
||||
const truncate = (s, n) => (s == null ? '' : String(s).slice(0, n));
|
||||
const fn = new Function(...argNames, 'escapeHtml', 'esc', 'truncate', 'return ' + m[1] + ';');
|
||||
return fn(...args, escapeHtml, escapeHtml, truncate);
|
||||
}
|
||||
|
||||
const PAYLOAD = TAG_PAYLOAD + ATTR_PAYLOAD;
|
||||
|
||||
test('packets.js group row: Region badge escapes observer iata', () => {
|
||||
const html = evalTemplate('public/packets.js',
|
||||
/(<td class="col-region">\$\{groupRegion[\s\S]*?<\/td>)/, { groupRegion: PAYLOAD });
|
||||
assertNoXss(html, 'packets.js groupRegion');
|
||||
});
|
||||
|
||||
test('packets.js child row: Region badge escapes observer iata', () => {
|
||||
const html = evalTemplate('public/packets.js',
|
||||
/(<td class="col-region">\$\{childRegion[\s\S]*?<\/td>)/, { childRegion: PAYLOAD });
|
||||
assertNoXss(html, 'packets.js childRegion');
|
||||
});
|
||||
|
||||
test('packets.js single row: Region badge escapes observer iata', () => {
|
||||
const html = evalTemplate('public/packets.js',
|
||||
/(<td class="col-region">\$\{region \?[\s\S]*?<\/td>)/, { region: PAYLOAD });
|
||||
assertNoXss(html, 'packets.js region');
|
||||
});
|
||||
|
||||
test('observers.js table row: Region badge escapes o.iata', () => {
|
||||
const html = evalTemplate('public/observers.js',
|
||||
/(<td data-value="\$\{escapeHtml\(o\.iata \|\| ''\)\}">[\s\S]*?<\/td>)/, { o: { iata: PAYLOAD } });
|
||||
assertNoXss(html, 'observers.js row iata');
|
||||
});
|
||||
|
||||
test('observers.js slide-over: Region <dd> escapes o.iata', () => {
|
||||
const html = evalConcat('public/observers.js',
|
||||
/'<dt>Region<\/dt><dd>' \+ (\(o\.iata \? '<span class="badge-region">' \+ [^;]*?\+ '<\/span>' : '—'\))/,
|
||||
{ o: { iata: PAYLOAD } });
|
||||
assertNoXss(html, 'observers.js slide-over iata');
|
||||
});
|
||||
|
||||
test('live.js node detail: "Heard By — Regions" header escapes each iata', () => {
|
||||
const html = evalTemplate('public/live.js',
|
||||
/(<h4 style="[^"]*">Heard By\$\{regions\.length[\s\S]*?<\/h4>)/, { regions: [PAYLOAD] });
|
||||
assertNoXss(html, 'live.js regions header');
|
||||
});
|
||||
|
||||
test('nodes.js clock-skew evidence: observer name/id is escaped', () => {
|
||||
const src = fs.readFileSync('public/nodes.js', 'utf8');
|
||||
const m = src.match(/var name = ([^;]*o\.observerName \|\| o\.observerID[^;]*);/);
|
||||
assert.ok(m, 'clock-skew observer name assignment not found');
|
||||
const fn = new Function('o', 'escapeHtml', 'return ' + m[1] + ';');
|
||||
const html = fn({ observerName: PAYLOAD }, escapeHtml);
|
||||
assertNoXss(html, 'nodes.js clock-skew observer name');
|
||||
});
|
||||
|
||||
test('analytics.js RF Health cell: observer_id is escaped inside attributes', () => {
|
||||
const html = evalTemplate('public/analytics.js',
|
||||
/(<div class="rf-cell\$\{isSelected[\s\S]*?id="rf-spark-\$\{[^}]*\}"><\/div>)/,
|
||||
{ isSelected: false, obs: { observer_id: '"' + PAYLOAD }, esc: escapeHtml,
|
||||
name: 'obs', nf: '-100', nfClass: '', batt: '' });
|
||||
assertNoXss(html, 'analytics.js rf-cell observer_id');
|
||||
// A raw double quote in the id would end the attribute and start a new one.
|
||||
assert.ok(!/data-observer="[^"]*"\s*'?\s*onfocus/i.test(html),
|
||||
'raw " in observer_id broke out of data-observer attribute: ' + html);
|
||||
assert.ok(html.includes('"'), 'double quote in observer_id was not escaped: ' + html);
|
||||
});
|
||||
|
||||
test('app.js favorites dropdown: node name is escaped', () => {
|
||||
const html = evalConcat('public/app.js',
|
||||
/'<span class="fav-dd-name">' \+ (\S+\(h\.node\.name \|\| truncate\(pk, 12\)\)|\(h\.node\.name \|\| truncate\(pk, 12\)\)) \+ '<\/span>'/,
|
||||
{ h: { node: { name: PAYLOAD } }, pk: 'abcdef' });
|
||||
assertNoXss(html, 'app.js favorites node name');
|
||||
});
|
||||
|
||||
test('customize-v2.js prune preview: node name is escaped', () => {
|
||||
const html = evalConcat('public/customize-v2.js',
|
||||
/return '<div>' \+ (\S*\(n\.name \|\| n\.pubkey\.slice\(0, 12\)\)) \+ coords \+ '<\/div>';/,
|
||||
{ n: { name: PAYLOAD, pubkey: 'abcdef0123456789' } });
|
||||
assertNoXss(html, 'customize-v2.js prune preview node name');
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// SUMMARY
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user