Files
meshcore-analyzer/public/observers.js
T
f15b677981 fix(security): escape mesh node names before HTML render — stored XSS (#1536) (#1537)
## This PR fixes the stored XSS in full (closes #1536)

Mesh-advertised node names (`adv_name`) and observer names were rendered
into the dashboard DOM **without HTML-escaping** in multiple places —
the same class as the publicly disclosed MeshCore dashboard XSS
(CVE-2026-45323). `adv_name` has no protocol-level validation and the Go
`sanitizeName()` keeps `< > " &`, so a payload like `<img src=x
onerror=...>` reaches the frontend intact and executes.

**I audited every name/sender/text/channel render in `public/` and this
PR escapes all unescaped sinks. There are no known remaining XSS sinks
of this class after this change.**

### Sinks fixed (all escaped via the existing global `escapeHtml`, plus
a local helper for the standalone `area-map.html`)

| File | Sink |
|------|------|
| `app.js` | global search dropdown — node name + channel name |
| `nodes.js` | nodes-table row name; node-detail Leaflet popups (×2) |
| `observers.js` | observers-table name cell |
| `packets.js` | observer-name cells via `obsNameOnly` (×4) + observer
multi-select checkbox label |
| `live.js` | node-filter `<option>` + map marker tooltip |
| `analytics.js` | topology map node tooltip |
| `route-view.js` | hop + union marker tooltips (×2) |
| `area-map.html` | node popups (×2) — added a local `escapeHtml` (file
is standalone) |

### Already-safe (verified, not changed)
`map.js` popups (`safeEsc`), live-feed text (`escapeHtml(preview)`),
packet-detail text, channel messages (`channels.js`), `route-render.js`
popups, `hop-display.js`.

### Why escape at the sink (not the backend)
`sanitizeName()` only strips control chars; HTML-escaping stored names
server-side would be lossy and corrupt legitimate names containing `& <
>`, and break the `meshcore://` deep-links / exports. Output-encoding at
render is the correct OWASP fix and matches `meshcore-card` v0.3.3.

### Tests
- Added 6 `escapeHtml` regression tests including the CVE payload `<img
src=x onerror=alert(1)>` and an attribute-breakout payload.
- `node test-frontend-helpers.js`: **568 passed / 32 failed** — the 32
are pre-existing sandbox limitations (e.g. `AreaFilter is not defined`),
identical to the untouched baseline (562/32). Zero new failures.

### Cache busting
Automatic — the server rewrites `__BUST__` in `index.html` with a
restart timestamp, so no manual bump is needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: CoreScope Bot <bot@meshcore>
Co-authored-by: Kpa-clawbot <bot@clawbot.local>
2026-06-03 10:55:02 -07:00

255 lines
13 KiB
JavaScript

/* === CoreScope — observers.js === */
'use strict';
// Issue #1478 — naive-clock ⚠️ chip.
// Exposed as a window global so the renderer (and a jsdom-style test) can
// call it without depending on the IIFE-scoped helpers below. Returns the
// chip HTML when the observer's clock is currently flagged naive, or "" when
// it's clean — never throws.
window.ObserversNaiveChip = {
render: function (o) {
if (!o || o.clock_naive !== true) return '';
var sec = Number(o.clock_skew_seconds || 0);
var absSec = Math.abs(sec);
var magnitude;
if (absSec >= 3600) {
magnitude = (absSec / 3600).toFixed(absSec >= 36000 ? 0 : 1) + 'h';
} else if (absSec >= 60) {
magnitude = Math.round(absSec / 60) + 'm';
} else {
magnitude = absSec + 's';
}
var dir = sec < 0 ? 'behind' : 'ahead of';
var count = Number(o.clock_skew_count_24h || 0);
var tip = 'Observer\u2019s local clock is ' + magnitude + ' ' + dir
+ ' UTC — per-packet timing for this observer is being clamped to ingest time'
+ (count > 0 ? ' (' + count + ' events in last 24h)' : '')
+ '. Fix: set the host clock to UTC, or emit Z-suffixed/offset-aware timestamps.';
return '<span class="obs-clock-naive-chip" title="' + tip.replace(/"/g, '&quot;')
+ '" aria-label="' + tip.replace(/"/g, '&quot;')
+ '" style="margin-left:6px;cursor:help">\u26A0\uFE0F</span>';
},
};
(function () {
let observers = [];
let obsSkewMap = {}; // observerID → {offsetSec, samples}
let wsHandler = null;
let refreshTimer = null;
let regionChangeHandler = null;
function init(app) {
app.innerHTML = `
<div class="observers-page">
<div class="page-header">
<h2>Observer Status</h2>
<a href="#/compare" class="btn-icon" title="Compare observers" aria-label="Compare observers" style="text-decoration:none">🔍</a>
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
</div>
<div id="obsRegionFilter" class="region-filter-container"></div>
<div id="obsContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>
</div>`;
RegionFilter.init(document.getElementById('obsRegionFilter'));
regionChangeHandler = RegionFilter.onChange(function () { render(); });
loadObservers();
// Event delegation for data-action buttons
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
var row = e.target.closest('tr[data-action="navigate"]');
if (row) {
// #1056 AC#4: at narrow widths, open detail in slide-over instead of
// navigating to a separate page.
if (window.SlideOver && window.SlideOver.shouldUse()) {
e.preventDefault();
openObserverSlideOver(row.dataset.value);
return;
}
location.hash = row.dataset.value;
}
});
// #209 — Keyboard accessibility for observer rows
app.addEventListener('keydown', function (e) {
var row = e.target.closest('tr[data-action="navigate"]');
if (!row) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (window.SlideOver && window.SlideOver.shouldUse()) {
openObserverSlideOver(row.dataset.value);
return;
}
location.hash = row.dataset.value;
});
// Auto-refresh every 30s
refreshTimer = setInterval(loadObservers, 30000);
wsHandler = debouncedOnWS(function (msgs) {
if (msgs.some(function (m) { return m.type === 'packet'; })) loadObservers();
});
}
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
regionChangeHandler = null;
observers = [];
obsSkewMap = {};
}
async function loadObservers() {
try {
const [data, skewData] = await Promise.all([
api('/observers', { ttl: CLIENT_TTL.observers }),
api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; })
]);
observers = data.observers || [];
obsSkewMap = {};
(Array.isArray(skewData) ? skewData : []).forEach(function(s) {
if (s && s.observerID) obsSkewMap[s.observerID] = s;
});
render();
} catch (e) {
document.getElementById('obsContent').innerHTML =
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Error loading observers: ${e.message}</div>`;
}
}
// NOTE: Comparing server timestamps to Date.now() can skew if client/server
// clocks differ. We add ±30s tolerance to thresholds to reduce false positives.
function healthStatus(lastSeen) {
if (!lastSeen) return { cls: 'health-red', label: 'Unknown' };
const ago = Date.now() - new Date(lastSeen).getTime();
const tolerance = 30000; // 30s tolerance for clock skew
if (ago < 600000 + tolerance) return { cls: 'health-green', label: 'Online' }; // < 10 min + tolerance
if (ago < 3600000 + tolerance) return { cls: 'health-yellow', label: 'Stale' }; // < 1 hour + tolerance
return { cls: 'health-red', label: 'Offline' };
}
function packetBadge(o) {
if (!o.last_packet_at) return '<span title="No packets ever observed">📡⚠ never</span>';
const pktAgo = Date.now() - new Date(o.last_packet_at).getTime();
const statusAgo = o.last_seen ? Date.now() - new Date(o.last_seen).getTime() : Infinity;
const gap = pktAgo - statusAgo;
if (gap > 600000) {
return `<span title="Last packet ${timeAgo(o.last_packet_at)} — status is newer by ${Math.round(gap/60000)}min. Observer may be alive but not forwarding packets.">📡⚠ ${timeAgo(o.last_packet_at)}</span>`;
}
return timeAgo(o.last_packet_at);
}
function uptimeStr(firstSeen) {
if (!firstSeen) return '—';
const ms = Date.now() - new Date(firstSeen).getTime();
const d = Math.floor(ms / 86400000);
const h = Math.floor((ms % 86400000) / 3600000);
if (d > 0) return `${d}d ${h}h`;
const m = Math.floor((ms % 3600000) / 60000);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function sparkBar(count, max) {
if (max === 0) return `<span class="text-muted">0/hr</span>`;
const pct = Math.min(100, Math.round((count / max) * 100));
return `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap"><span style="display:inline-block;width:60px;height:12px;background:var(--border);border-radius:3px;overflow:hidden;vertical-align:middle"><span style="display:block;height:100%;width:${pct}%;background:linear-gradient(90deg,#3b82f6,#60a5fa);border-radius:3px"></span></span><span style="font-size:11px">${count}/hr</span></span>`;
}
function render() {
const el = document.getElementById('obsContent');
if (!el) return;
// Apply region filter
const selectedRegions = RegionFilter.getSelected();
const filtered = selectedRegions
? observers.filter(o => o.iata && selectedRegions.includes(o.iata))
: observers;
if (filtered.length === 0) {
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No observers found.</div>';
return;
}
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
// Summary counts
const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
el.innerHTML = `
<div class="obs-summary">
<span class="obs-stat"><span class="health-dot health-green">●</span> ${online} Online</span>
<span class="obs-stat"><span class="health-dot health-yellow">▲</span> ${stale} Stale</span>
<span class="obs-stat"><span class="health-dot health-red">✕</span> ${offline} Offline</span>
<span class="obs-stat">📡 ${filtered.length} Total</span>
</div>
<div class="obs-table-scroll table-fluid-wrap"><table class="data-table obs-table" id="obsTable">
<caption class="sr-only">Observer status and statistics</caption>
<thead><tr>
<th scope="col" data-priority="1">Status</th><th scope="col" data-priority="1">Name</th><th scope="col" data-priority="3">Region</th><th scope="col" data-priority="2">Last Status</th><th scope="col" data-priority="2">Last Packet</th>
<th scope="col" data-priority="3">Packet Health</th><th scope="col" data-priority="4">Total Packets</th><th scope="col" data-priority="3">Packets/Hour</th><th scope="col" data-priority="4">Clock Offset</th><th scope="col" data-priority="4">Uptime</th>
</tr></thead>
<tbody>${filtered.map(o => {
const h = healthStatus(o.last_seen);
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
<td><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
<td class="mono">${escapeHtml(o.name || o.id)}${window.ObserversNaiveChip.render(o)}</td>
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
<td>${timeAgo(o.last_seen)}</td>
<td>${o.last_packet_at ? timeAgo(o.last_packet_at) : '<span class="text-muted">—</span>'}</td>
<td>${packetBadge(o)}</td>
<td>${(o.packet_count || 0).toLocaleString()}</td>
<td>${sparkBar(o.packetsLastHour || 0, maxPktsHr)}</td>
<td>${(function() {
var sk = obsSkewMap[o.id];
if (!sk || sk.samples == null || sk.samples === 0) return '<span class="text-muted">—</span>';
var sev = observerSkewSeverity(sk.offsetSec);
return renderSkewBadge(sev, sk.offsetSec) + ' <span class="text-muted" title="Computed from ' + sk.samples + ' multi-observer packets. Positive = observer ahead of consensus.">(' + sk.samples + ')</span>';
})()}</td>
<td>${uptimeStr(o.first_seen)}</td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths');
// #1056: fluid columns + +N hidden pill
if (window.TableResponsive) {
var _obsTbl = document.getElementById('obsTable');
if (_obsTbl) window.TableResponsive.register(_obsTbl);
}
}
registerPage('observers', { init, destroy });
// #1056 AC#4: row-detail slide-over (narrow viewports). Renders a compact
// summary from the in-memory observer + a link to the full page.
function openObserverSlideOver(hashHref) {
if (!window.SlideOver) return;
var m = String(hashHref || '').match(/#\/observers\/(.+)$/);
if (!m) return;
var id = decodeURIComponent(m[1]);
var o = (observers || []).find(function (x) { return String(x.id) === id; });
if (!o) return;
var h = healthStatus(o.last_seen);
var sk = obsSkewMap[o.id];
var skewLine = (sk && sk.samples) ? renderSkewBadge(observerSkewSeverity(sk.offsetSec), sk.offsetSec) + ' (' + sk.samples + ' samples)' : '—';
var pkts = sparkBar(o.packetsLastHour || 0, Math.max(1, o.packetsLastHour || 1));
var content = window.SlideOver.open({ title: (o.name || o.id) });
var naiveChipHTML = window.ObserversNaiveChip.render(o);
content.innerHTML =
(naiveChipHTML ? '<div style="margin-bottom:10px">' + naiveChipHTML + ' <span class="text-muted">Clock is naive — per-packet timing clamped to ingest time.</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 + '">●</span> ' + h.label + '</dd>' +
'<dt>Region</dt><dd>' + (o.iata ? '<span class="badge-region">' + 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>' +
'<dt>Packets/hr</dt><dd>' + pkts + '</dd>' +
'<dt>Clock offset</dt><dd>' + skewLine + '</dd>' +
'<dt>Uptime</dt><dd>' + uptimeStr(o.first_seen) + '</dd>' +
'</dl>' +
'<p style="margin-top:14px"><a class="btn-primary" href="' + hashHref + '">Open full detail →</a></p>';
}
})();