## 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>
## Root cause
`makeLiveSandbox()` in `test-live.js` didn't load
`public/packet-helpers.js`, so `window.getParsedDecoded` /
`getParsedPath` were undefined. The `dbPacketToLive` and
`expandToBufferEntries` suites failed all 8 assertions with
`getParsedDecoded is not a function`. The `expandToBufferEntriesAsync`
suite was unaffected because it builds its sandbox manually and already
loads packet-helpers.js.
## Fix
- `test-live.js`: load `public/packet-helpers.js` in `makeLiveSandbox()`
before `live.js`. Mirrors the working pattern in
`expandToBufferEntriesAsync`.
- `.github/workflows/deploy.yml`: wire `node test-live.js` into the "Run
JS unit tests" step so this can't silently regress again.
- Adjusted one cross-realm `deepStrictEqual([], [])` → `.length === 0`
because the array literal lives inside the vm sandbox; host-side
`deepStrictEqual` rejects the proto mismatch even when the value is
semantically equal. Test-harness only.
No production code change.
## Mutation verification
With the new `loadInCtx(ctx, 'public/packet-helpers.js')` line removed,
all 8 original assertions return (`getParsedDecoded is not a function`).
With the fix in place, `node test-live.js` exits 0 — 95 passed, 0
failed.
## CI wire
`node test-live.js` now runs in deploy.yml under "Run JS unit tests
(packet-filter)" alongside the other root-level test files. YAML
validated with `yaml.safe_load`.
Fixes#1392
Co-authored-by: openclaw-bot <bot@openclaw.dev>
Extends VCR speed cycle to `[0.25, 0.5, 1, 2, 4, 8]` so users can watch
live paths in slow motion.
## Changes
- `vcrSpeedCycle()`: speed array extended to include `¼x` and `½x`;
saves preference to `localStorage('live-vcr-speed')`
- `speedLabel()`: new helper returning `¼x` / `½x` for sub-1x, used in
the speed button
- `drawAnimatedLine`: step interval scales with speed (`33 / VCR.speed`)
- `drawMatrixLine`: `DURATION_MS` scales with speed (`1100 / VCR.speed`)
- Speed preference restored from localStorage on page load
## Tests
3 new unit tests; 72 pass, 0 regressions.
Closes#771 (M1 of 3)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
After a path animation completes, keeps an invisible clickable polyline
on the map for 30s. Clicking it shows a compact Leaflet popup with type
badge, hop chain, relative time, and a link to the full packets page.
Popup auto-dismisses after 20s.
## Changes
- `clickablePathsLayer`: new Leaflet layer for invisible hit-target
polylines
- `buildClickablePathPopupHtml()`: pure function generating popup HTML
(type badge, hop chain, time, hash link)
- `pruneClickablePaths()`: TTL (30s) + FIFO eviction (max 50); runs on
existing `_pruneInterval`
- `registerClickablePath()`: adds invisible polyline with click → popup
handler
- `animatePath()`: accepts optional `pktMeta` (`hash`, `ts`); calls
`registerClickablePath` on completion
- Teardown clears `clickablePathsLayer` and `clickablePaths`
## Tests
7 new unit tests; 77 pass, 0 regressions.
Closes#771 (M2 of 3)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a node filter input to the live controls bar. When active, only
packets whose hop chain passes through the selected node(s) are
animated. A counter shows "Showing X of Y" so operators know traffic is
filtered, not absent.
## Changes
- `packetInvolvesFilterNode(pkt, filterKeys)`: pure filter function
using same prefix-matching logic as the favorites filter
- `setNodeFilter(keys)`: sets filter state, resets counters, persists to
localStorage
- `updateNodeFilterUI()`: updates counter + clear button visibility +
datalist autocomplete
- Filter input in controls bar (after Favorites toggle): text input +
datalist autocomplete + × clear button + counter div
- Filter wired into `renderPacketTree`: increments total/shown counters,
returns early when packet doesn't match
- URL hash sync: `?node=ABCD1234` — read on init, written on filter
change
## Tests
8 new unit tests covering filter logic, localStorage persistence, and
edge cases; 78 pass, 0 regressions.
Closes#771 (M3 of 3)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
Fixes#701 — Live feed timestamps showed stale relative times (e.g. "2s
ago" never updated to "5m ago").
## Root Cause
`formatLiveTimestampHtml()` was called once when each feed item was
created and never refreshed. The dedup path (when a duplicate hash moves
an item to the top) also didn't update the timestamp.
## Changes
### `public/live.js`
- **`data-ts` attribute on `.feed-time` spans**: All three feed item
creation paths (VCR replay, `addFeedItemDOM`, `addFeedItem`) now store
the packet timestamp as `data-ts` on the `.feed-time` span element
- **10-second refresh interval**: A `setInterval` queries all
`.feed-time[data-ts]` elements and re-renders their content via
`formatLiveTimestampHtml()`, keeping relative times accurate
- **Dedup path timestamp update**: When a duplicate hash observation
moves an existing feed item to the top, the `.feed-time` span is updated
with the new observation's timestamp
- **Cleanup**: The interval is cleared on page teardown alongside other
intervals
### `test-live.js`
- 3 new tests: formatting idempotency, numeric timestamp acceptance,
`data-ts` round-trip correctness
## Performance
- The refresh interval runs every 10s, iterating over at most 25
`.feed-time` DOM elements (feed is capped at 25 items via `while
(feed.children.length > 25)`). Negligible overhead.
- Uses `querySelectorAll` with attribute selector — O(n) where n ≤ 25.
## Testing
- All 3 new tests pass
- All pre-existing test suites pass (70 live.js tests, 62 packet-filter,
501 frontend-helpers)
- 8 pre-existing failures in `test-live.js` are unrelated
(`getParsedDecoded` missing from sandbox)
Co-authored-by: you <you@example.com>
## Summary
Show the transport badge ("T") in the live packet feed, matching the
packets table (#337).
## Changes
- Add `transportBadge(pkt.route_type)` to all 4 feed rendering paths in
`live.js`:
- Grouped feed items (initial history load)
- `addFeedItemDOM()` (VCR replay)
- Dedup new feed items (live WebSocket updates)
- Node detail panel recent packets list
- Uses existing `transportBadge()` from `app.js` and `.badge-transport`
CSS from `style.css`
## Testing
- 2 new source-level assertions in `test-live.js` verifying
`transportBadge()` calls exist
- All existing tests pass (67 passed in test-live.js, no new failures)
Fixes#338
Co-authored-by: you <you@example.com>
## Summary
VCR replay functions (`vcrReplayFromTs`, `vcrRewind`,
`fetchNextReplayPage`) fetch up to 10K packets and process them all
synchronously on the main thread via `expandToBufferEntries`, causing
multi-second UI freezes — especially on mobile.
## Fix
- Added `expandToBufferEntriesAsync()` — processes packets in chunks of
200, yielding to the event loop via `setTimeout(0)` between chunks
- Updated all three VCR replay callers to use the async variant
- Kept the synchronous `expandToBufferEntries()` for backward
compatibility (tests, small datasets)
- Exposed `_liveExpandToBufferEntriesAsync` on window for test access
## Perf justification
- **Before:** 10K packets × ~2 observations = 20K+ objects created
synchronously, blocking the main thread for 1-3 seconds on mobile
- **After:** Same work split into chunks of 200 packets (~400 entries)
with event loop yields between chunks. Each chunk takes <5ms, keeping
the UI responsive (well under the 16ms frame budget)
- Chunk size of 200 is tunable via `VCR_CHUNK_SIZE`
## Tests
- Added regression test: sync expand correctness at scale (500 packets →
1000 entries)
- Added structural test: verifies `VCR_CHUNK_SIZE` exists and async
function yields via `setTimeout`
- All existing tests pass (`npm test`)
Fixes#395
---------
Co-authored-by: you <you@example.com>