Skip to content

fix(ui): escape untrusted node, observer and route values in HTML rendering - #373

Merged
dborup merged 10 commits into
masterfrom
codex/issue-333-escape-untrusted-ui
Oct 13, 2026
Merged

dborup merged 10 commits into
masterfrom
codex/issue-333-escape-untrusted-ui

Conversation

@dborup-agent

Copy link
Copy Markdown
Collaborator

Relates to #333

What & why

Mesh/MQTT-supplied strings and URL-hash route text were interpolated into HTML
without escaping at several render sinks, so a hostile node/observer/region name
(or a crafted #/… route) could be interpreted as markup. This escapes those
values as text and closes a false-negative in the shared XSS gate.

Rechecked against current master (1f423e06); the issue was written against
6444294d. The clock-skew observer/node-name sinks were already escaped in our
fork, so they get a regression pin rather than a code change.

Approach (plan)

  1. Audit the sinks named in the issue across app.js, analytics.js,
    customize-v2.js, live.js, nodes.js, observers.js, packets.js and
    adjacent fork sinks.
  2. Prefer the existing shared helpers by context — escapeHtml (HTML/attr, 5
    OWASP chars incl. quotes) and the per-file textContent-based esc(). No new
    helpers; no encodeURIComponent/CSS-selector escaping confusion.
  3. Write hostile-string unit sinks first (red), then fix (green); add a
    browser-level E2E and extend the gate with a fixture + the N4 fix.

Sinks fixed

Sink File Change
Favorites dropdown node name app.js escapeHtml(h.node.name …)
Geo-filter prune preview node name customize-v2.js esc(n.name …)
Observer region badge (table) observers.js escapeHtml(o.iata)
Observer region badge (slide-over) observers.js escapeHtml(o.iata)
Packet region badge packets.js escapeHtml(groupRegion)
RF-health data-observer / sparkline id analytics.js esc(obs.observer_id)
Unknown-route <h2> heading app.js escapeHtml(route) + var(--text-muted)
Clock-skew observer/node names analytics.js already escaped — regression pin only

XSS gate (scripts/check-xss-sinks.sh) — #351 N4

The gate did not flag an unescaped ${…} inside a quoted attribute on a sink
line (el.innerHTML = \x`): the quote-stripper erased the interpolation before the template-literal handler ran. short_strnow preserves${…} fragments inside quoted runs, so such sinks are flagged while escaped ones (escapeHtml(…)) still pass. The remaining limitation (a literal "${allowlisted}"in a plain, non-template string on a sink line, and the cross-line.join('')` indirection) is documented in the script header.

Tests

  • Unit (test-xss-escape-sinks.js, section I): one hostile-string sink test
    per point — text + attribute contexts (<, >, &, ", ', backtick,
    javascript: style payloads). Each extracts the production template, so
    dropping the wrapper flips it red.
  • Browser E2E (test-xss-333-e2e.js): real Chromium parses the
    production-rendered HTML and asserts an inert DOM (no <img>/handler element,
    no fired side effect) plus verbatim display of benign Unicode (&, Danish
    letters, emoji) for unknown route, observer region, packet region badge and
    favorites. Registered in deploy.yml.
  • Gate (test-preflight-xss-gate.js + fixtures bad-13, bad-14,
    good-5): proves the N4 pattern is now caught and escaped attributes don't
    false-positive.

Constraints

  • cmd/server untouched; no Go changes; no new map[string]interface{}.
  • No hardcoded colours (unknown-route hex → var(--text-muted)).
  • scripts/check-xss-sinks.sh --diff origin/master is clean.
  • Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml.
  • No payloads sent anywhere — local server + fixtures only.

…dering

Relates to #333

Escape mesh/MQTT-supplied strings at the sinks named in the issue so they
display as inert text rather than markup:

- favorites dropdown node name (app.js)
- geo-filter prune preview node name (customize-v2.js)
- observer region badges, table + slide-over (observers.js)
- packet region badge (packets.js)
- RF-health observer_id in data-observer / sparkline id attributes (analytics.js)
- unknown-route heading, plus swap its hardcoded colour for var(--text-muted) (app.js)

Clock-skew observer/node names were already escaped in our fork; a regression
pin with a mutant guards them.

Close the #351 N4 gap in scripts/check-xss-sinks.sh: an unescaped interpolation
inside a quoted attribute on a sink line is now flagged (the quote-stripper
preserves ${...} fragments). Document the residual limitation.

Tests: hostile-string unit sinks + browser-level E2E (unknown route, observer
region, packet region badge, favorites) + gate fixtures (bad-13/14, good-5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-MacBook PR#373 #333 — head 97b4a1f

Status: Implemented and self-verified; draft open, all CI green. No merge/ready/approve actions taken. #271 flake not observed (no rerun needed).

Scope recheck

Rechecked current master (1f423e06); issue was written against 6444294d. Clock-skew observer/node names were already escaped in our fork, so they get a regression pin, not a code change. live.js and nodes.js sinks in this family were already escaped (obsIataBadgeHtml, the live observer list, escapeHtml(n.name), dupNameBadge) — audited, no change. [A][K]

Per-requirement evidence (test red→green + mutant)

# Sink / requirement Fix Test (red before → green after) Mutant
1 Favorites dropdown node name (app.js) escapeHtml(h.node.name…) test-xss-escape-sinks.js §I "favorites dropdown"; E2E "favorites dropdown" [T] remove escapeHtml → raw <img> in DOM
2 Geo-filter prune preview (customize-v2.js) esc(n.name…) §I "geo-filter prune preview" [T] remove esc → unit red
3 Observer regions table + slide-over (observers.js) escapeHtml(o.iata) ×2 §I "observers table/slide-over region badge"; E2E "observer region" [T] remove escapeHtml → unit + E2E red
4 Packet region badge (packets.js) escapeHtml(groupRegion) §I "packets region badge"; E2E "packet region badge" [T] remove escapeHtml → red
5 Clock-skew observer/node names (analytics.js) already escaped (pin) §I "clock-health table escapes node name" [T] remove esc() from 5101 → pin red
6 RF-health attributes (analytics.js) esc(obs.observer_id) in data-observer + sparkline id §I "RF-health cell escapes observer_id in attributes" [T] drop esc → "-breakout <img> survives
7 Unknown-route heading (app.js) escapeHtml(route) + var(--text-muted) §I "unknown-route heading" + "CSS var, no hex"; E2E "unknown route" [T] shown live: revert escapeHtml(route) → E2E "1 markup element injected", restore → green
8 XSS gate N4 gap (scripts/check-xss-sinks.sh) preserve ${…} in quoted-attr quote-stripper test-preflight-xss-gate.js bad-13/bad-14 (now exit 1), good-5 (exit 0) [T] before fix bad-13/bad-14 passed (exit 0) = red; after = exit 1 green

Preservation [T]: E2E asserts benign Æbleø & Mesh 🛰 café / KÖLN & Co render verbatim (ampersand shown as &, Danish + emoji intact); observer region E2E asserts the data-value sort key and .badge-region span survive.

Constraints [K]

  • cmd/server untouched; no .go changes; no new map[string]interface{} (staged diff checked).
  • No hardcoded colours added; unknown-route hex #6b7280 → var(--text-muted).
  • scripts/check-xss-sinks.sh --diff origin/master → exit 0 (clean).
  • Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml.
  • No payload left the machine — local static server + fixtures only.

Local suites [T]

  • sh test-all.sh → 234 passed, 0 failed.
  • node test-frontend-helpers.js → 712 passed, 0 failed.
  • node test-xss-escape-sinks.js → 43 passed, 0 failed (9 new).
  • node test-preflight-xss-gate.js → all assertions passed.
  • node test-xss-333-e2e.js → 7 passed, 0 failed (Chromium).
  • cmd/server go build . → OK. Go unaffected (no Go changes; multi-module repo, CI runs go test ./... per module).

CI per job [T]

Job Result Time
Go Build & Test ✅ pass 15m34s
Playwright E2E Tests ✅ pass 28m14s
Build & Publish Docker Image ✅ pass 53s
Release Artifacts ⏭️ skipping (fork guard) —
Deploy Staging ⏭️ skipping (fork guard) —
Publish Badges & Summary ⏭️ skipping —

Run: actions/runs/37751681187. No job failed; known flake #271 not triggered, so no rerun.

Residual / notes

  • RF-health getElementById('rf-spark-'+obs.observer_id) / querySelector('[data-observer="…"]') still use the raw id; for normal hex ids esc() is a no-op so lookups are unchanged. The querySelector is a CSS-selector concern (not HTML), deliberately left per the issue's "don't confuse HTML with CSS escaping" guidance.
  • Gate residual limitation documented in the script header: a literal "${allowlisted}" in a plain non-template string on a sink line may now be flagged (rare); cross-line .join('') indirection still not covered (needs sink + interpolation on one line).

@dborup dborup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of head 97b4a1f6 against the current PR base: no merge-blocking findings in this diff. The changed HTML sinks now escape node names, region labels, route text, and RF-health attribute values in the right context. In particular, analytics.js's esc() encodes both quote characters, so the new data-observer and id interpolations cannot break out of their quoted attributes. The quoted-attribute XSS-gate fixtures exercise the reported false-negative, and CI is green for Go, Playwright, and the local Docker build.

Non-blocking follow-up outside this PR: public/analytics.js:4561 still puts _rfHealthState.selectedObserver (read from the URL hash at line 4422) directly into querySelector('[data-observer="…"]'). A crafted observer parameter containing a quote can make that selector invalid and send the RF-health grid to its error state. This predates #373 and is a CSS-selector/functional issue, not an HTML-escaping regression. Please track it separately; use CSS.escape or select cells by comparing dataset.observer rather than interpolating the raw ID into selector syntax.

The new browser test renders extracted production sink fragments, not the complete page handlers; it is useful parser-level evidence but does not by itself prove every full navigation path. No PR changes requested on that basis. I have not merged or changed the branch.

@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-pve-agent2 PR#373 — head 97b4a1f

Dom: REQUEST CHANGES

Evidence tags: [T] = I ran it myself. [A] = based on reading the code. [K] = taken from the author's report or CI and not reproduced by me.

The seven sinks this PR changes are fixed correctly. Each one uses the right escaper for its context, nothing is double-escaped, and every fix has a unit test that is red before the fix and green after. The issue's first acceptance criterion also covers adjacent rendering sites, and that criterion is not met yet. Two of the three packet region-badge render paths are still unescaped. The shared region filter also renders a hostile observer IATA as live markup on page load, on Observers, Packets, Nodes and Analytics. I reproduced both in Chromium against a local server, and both also inject on the merged tree.

Findings

# Severity Where Finding Evidence
F1 Blocking public/packets.js:2730 (childRegion), public/packets.js:2767 (region) These are the same <span class="badge-region"> sink as the groupRegion fix at :2695, with the same source (observerMap.get(id).iata). They are still unescaped in the expanded group child rows and in the flat (ungrouped) view. [T] Local server, hostile IATA in fixture. Flat view: 15 injected <img> on the merged tree (13 on master). Expanding the seeded 3-observation group: 2 injected <img> in tr.group-child on the merged tree, while the group header is now inert.
F2 Blocking public/region-filter.js:109 (pill data-region + label), :135-136 (dropdown data-region + label), :126 (dropdownLabel) Observer region codes are concatenated raw into an HTML attribute and into text. This renders on page load with no user interaction, on every page that shows the region filter. [T] On the merged tree with a hostile IATA, onerror fired from .region-pill / .region-dropdown-* on #/observers, #/packets, #/nodes and #/analytics. In the rendered DOM, the pill's data-region="…" was broken out of by the " in the payload.
F3 Should-fix (OK as a tracked follow-up) public/observers.js:427 onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'". encodeURIComponent does not encode ', so an observer id that contains ' breaks out of the JS string. This is the "URL-encoding used as HTML/JS escaping" mix-up the issue warns about. The row already has a delegated data-action="navigate" handler, so the inline onclick can probably just be dropped. [T] With observer id Q'-console.error(location.host.toUpperCase())-', clicking the row ran the injected call (console hit observed). It exists on master too. [A] The ingestor takes the observer id from the MQTT topic segment and does not check that it is hex (cmd/ingestor/main.go:795). Exposure therefore depends on the broker ACL.
F4 Non-blocking scripts/check-xss-sinks.sh The N4 fix works as described. However, the gate catches none of the #333 sink mutants. All but one are cross-line (return '…' + x while innerHTML sits on another line). route, groupRegion and observer_id are not in the allowlist. The concat form of N4, el.innerHTML = '<span title="' + o.name + '">', still passes, because the "-run regex swallows ' + o.name + '. [T] See the mutant table below. A probe file has the template-form N4 line flagged, while the concat-form line and the ${obs.observer_id} line are not flagged.
F5 Non-blocking (test quality) test-xss-escape-sinks.js §I tests 2 and 6 Both tests inject app.js's escapeHtml as esc, so the escaper the code actually ships is never exercised. The RF-health attribute fix relies on analytics.js's own esc() (line 49) encoding ", and no test pins that. [T] Mutant M11 removes "→&quot; from analytics.js esc(). The unit suite stays green, and so does every other analytics.js-referencing test that can run without a server.
F6 Nit test-xss-333-e2e.js The packet-region and observer-region liftTemplate regexes hard-code escapeHtml(…). Under a mutant they therefore go red with "sink not found" instead of a behavioural failure. As already noted, the test also renders extracted fragments rather than real pages. [T] M5 and M12 E2E output.
F7 Nit / FYI public/app.js:1520 #6b7280 changes to var(--text-muted), which is --palette-gray-700 in light mode. That is a small, intentional visual change. Routes are still shown percent-encoded (foo&bar-%C3%86%C3%B8), which is pre-existing behaviour because route is never decoded. It is not a regression. [T] browser

Answers to the review points

Coverage of the sinks named in the issue

Sink Status
Favorites Fixed. [T] unit red→green; browser: hostile name inert, benign Æbleø & Mesh 🛰 café verbatim, click navigates to the node.
Geo-filter prune preview Fixed. [T] unit red→green. Not browser-verified.
Observer regions, table + slide-over Fixed. [T] browser, master → merged: table 10 → 0 injected, slide-over 1 → 0. Not fixed: the shared region filter (F2).
Packet region badges Fixed for the group header only. [T] browser 13 → 0. Not fixed: child rows and flat view (F1).
Clock-skew names Already escaped on this fork, now pinned. [T] the pin is green on master, and mutant M9 turns it red.
RF-health attributes Fixed. [T] unit red→green, mutants M6/M7 red. [A] analytics.js esc() encodes both quote characters, so data-observer/id cannot break out. getElementById('rf-spark-'+id) still matches, because the parser decodes entities in the id attribute. Not browser-verified: the fixture has no noise-floor data, so 0 RF cells render.
Unknown-route heading Fixed. [T] unit and E2E red→green. Chromium percent-encodes "<> in the fragment, so on master the real-browser exploit path was already narrow. The escaping is still the correct defence, and & is shown once.

Context and double-escaping. [A]/[T] Every change uses escapeHtml, the 5-char OWASP escaper (app.js:1217), or a context-appropriate esc. customize-v2.js's textContent esc is used only in a text context, which is fine. No encodeURIComponent or CSS.escape mix-ups are introduced. In the browser, & displays as &, and Danish letters and emoji are intact in observers, favorites and the packets badge.

Gate (#351 N4). [T] With the short_str change reverted, test-preflight-xss-gate.js fails 4 assertions (bad-13/14 exit 0). With the change in place, everything passes. I ran the new gate over every public/*.js file on master: it reports the same 3 pre-existing bottom-nav/nav-drawer flags as the old gate and no new false positives. --diff against master is clean (exit 0). Limitations are covered in F4.

Behaviour preserved. [T] Clicking an observer row navigates to the detail page, the narrow-viewport slide-over opens, favorites navigate, group expand/collapse works, and the observer sort key (data-value) is unchanged. The affected E2Es listed below are green.

Checklist

Item Result
Every acceptance criterion has a red-before / green-after test Yes for the 7 changed sinks [T]. The new unit file and E2E run against master code give 8 unit fails + 4 E2E fails; the clock pin is green there by design. No for the adjacent sinks in F1/F2, which have no tests.
My own mutants 12 sink/helper mutants + 1 gate mutant; see below [T]
No behaviour change beyond scope Yes, apart from the colour token in F7 [T]/[A]
cmd/server read-only, no new map[string]interface{} No Go files changed [T]
No hardcoded colours One hex removed, none added [T]
check-xss-sinks.sh --diff vs master Clean [T]
Fork guards 9 in deploy.yml, 1 in release-fast-path.yml, unchanged from master [T]
Closing keywords None in the commits or the body ("Relates to"); closingIssuesReferences is empty [T]
Commit author dborup <kontakt@meshview.dk> (author and committer) [T]
Head stable git ls-remote showed 97b4a1f6 both before and after the review [T]

Tests and mutants

These ran on the merged tree, git merge-tree of origin/master c7c75cb6 plus the head, extracted with git archive.

  • [T] cmd/server go test ./... → ok; cmd/ingestor go test ./... → ok. No Go changes.
  • [T] sh test-all.sh → 234 passed, 0 failed. The first run hit 1 failure in test-channels-client-state-152.js (N1 "Decrypting…" timing) while the Go suites were running in parallel. It passed 4/4 times standalone and in the clean rerun. The PR does not touch channels code.
  • [T] node test-frontend-helpers.js → 712/0. test-xss-escape-sinks.js → 43/0. test-preflight-xss-gate.js → all pass.
  • [T] E2E against a local Go server, using e2e-fixture.db prepared as in CI (freshen, seed SQL, corescope-migrate, seeds 2073/199/245): test-xss-333-e2e 7/0, test-observer-iata-1188 pass, test-issue-1639-observers-sort pass, test-nodes-favorite-rerender 4/0, test-slideover-1056 27/0, test-issue-189-group-caret 3/0, test-issue-147-packets-url-obs 12/0, test-issue-1486-collapse-reopens-detail 4/0, test-table-fluid 41/0.
  • [T] test-e2e-playwright.js stops at its fail-fast on "Version info lives on Perf dashboard", and it does so identically on master in my environment. That makes it environmental and unrelated to this PR. [K] CI ran it at 132/135 passed, 3 skipped.
  • [K] [T] CI run 37751681187: Go Build & Test ✅, Playwright E2E ✅ (the log shows test-xss-333-e2e.js executed), Docker ✅, Release/Deploy/Badges skipped by the fork guard. Neither flaky: #226 Hash Stats adopters sort E2E — server sorts multiByteNodes on packets only, ties come out in random order #256 nor flaky: TestResolvedPathBackfill_WriteHoldUnderBudget_188 — wall-clock write-hold budget fails under CI load #267 occurred.

Mutants [T] (unit = test-xss-escape-sinks.js; gate = --file and --diff vs master)

ID Mutation Context Unit E2E Gate
M1 drop escapeHtml on fav name text red red green
M2 drop esc on prune-preview name text red — green
M3 drop escapeHtml on observers table badge text red red green
M4 drop escapeHtml on slide-over badge text red — green
M5 drop escapeHtml(groupRegion) text red red (regex miss) green
M6 drop esc in RF data-observer attr red — green
M7 drop esc in RF sparkline id attr red — green
M8 drop escapeHtml(route) text red red green
M9 drop esc on clock-health node name text red — green
M10 app.js escapeHtml stops encoding " helper/attr red red green
M11 analytics.js esc stops encoding " helper/attr green — green
M12 drop escapeHtml on observers data-value attr green red (regex miss) green
G1 revert the short_str N4 change gate — — test-preflight-xss-gate red (4 fails)

Browser verification

[T] This used Playwright/Chromium against a local Go server only. Hostile IATA values and node names were injected into a copy of the CI-prepared fixture, and the same checks ran against the merged tree and against master.

Check master merged
Observers table badges 10 injected 0, payload shown as text
Observer slide-over 1 injected 0
Packets group header 13 injected 0
Favorites dropdown 1 injected 0
Packets flat view (F1) 13 injected 15 injected
Packets child rows (F1) 2 injected 2 injected
Region filter on Observers/Packets/Nodes/Analytics (F2) injected injected
Clock-health 0 injected 0 injected

The flat-view counts differ between the two runs (13 vs 15); I did not look into why. Benign values KØB & Æ 🛰 and Æbleø & Mesh 🛰 café render verbatim. No payload was sent anywhere except the local servers.

Suggested fix scope

  1. Wrap childRegion and region in escapeHtml at packets.js:2730/2767, and add matching unit tests. Ideally extract one regionBadgeHtml(code) helper so the three call sites cannot drift apart again.
  2. Escape code and label in region-filter.js for attributes and text in both pill and dropdown mode, plus the dropdownLabel trigger text. Add a hostile-code test. toggleRegion reads btn.dataset.region, which is already decoded, so selection keeps working.
  3. Drop or fix the inline onclick at observers.js:427. A tracked follow-up is acceptable here.
  4. Optionally, make the F5 tests use the real analytics.js / customize-v2.js escapers.

Not verified

  • I did not exercise the RF-health grid or the geo-filter prune preview in a browser: the fixture has no noise-floor data, and I did not drive the customizer geo-filter flow. Both are covered by unit tests and code reading only.
  • I did not run the full test-e2e-playwright.js past its environmental fail-fast, nor any coverage or instrumented runs.
  • I made no full audit of live.js / nodes.js beyond a grep for unescaped template interpolations of name/iata/observer/route fields. I found nothing new there; the author's audit is [K].
  • I did not assess exploitability against any real broker ACL. The observer id and IATA topic-segment trust analysis is [A].

dborup and others added 8 commits October 12, 2026 21:00
…her shape (#333)

Review of PR #373, findings F5 and F6:

- F5: the geo-filter prune preview, clock-health and RF-health tests
  injected app.js's escapeHtml as `esc`, so the escaper those files ship
  was never exercised. They now lift `function esc(s)` from
  customize-v2.js and analytics.js. analytics.js's esc() gets a 5-char
  contract test, because the RF-health attribute fix depends on it
  encoding both quotes. The prune preview also runs in Chromium with the
  real textContent-based esc().
- F6: the fragment E2E regexes for the observer and packet region cells,
  and the clock-health unit regex, matched only the escaped form, so a
  mutant failed with "sink not found". They now match both forms and fail
  on injected markup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of PR #373 found three more places that put observer-controlled
values into HTML unescaped:

- F1: packets.js region badge in expanded group child rows and in the
  flat view (only the group header was fixed).
- F2: region-filter.js data-region attributes, pill/dropdown labels and
  the dropdown trigger label, rendered on every page with the filter.
- F3: observers.js row onclick="location.hash='…${encodeURIComponent(id)}'";
  encodeURIComponent leaves ' alone, so an id with a quote runs as JS.

Unit tests (test-xss-escape-sinks.js §J) drive the real
buildFlatRowHtml/buildGroupRowHtml and RegionFilter render with app.js's
escapeHtml as the page global. test-xss-333-pages-e2e.js drives the real
pages against the CI fixture server, rewriting /api/observers and
/api/config/regions in the browser to inject hostile codes, and checks
that filtering by a hostile code and row navigation still work. Red on
this commit; the fixes follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t view (#333)

Review of PR #373, finding F1. The group-header badge was escaped in round
1, but the child rows (childRegion) and the flat view (region) put the same
observer IATA into the same <span class="badge-region"> sink raw. Wrap both
in escapeHtml, as the header does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of PR #373, finding F2. Region codes include observer IATA values
from MQTT and were concatenated raw into data-region attributes, the
pill/dropdown label text and the dropdown trigger label, rendering on
page load on Observers, Packets, Nodes, Analytics, Channels and Live.

Escape each with the global escapeHtml (5-char OWASP set, so it is safe
in the double-quoted attributes too). Selection keeps working because
toggleRegion() reads btn.dataset.region, which the parser has already
decoded back to the exact code.

The setSelected sandbox in test-frontend-helpers.js now loads roles.js +
app.js like the page does, so the global escaper exists there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of PR #373, finding F3. The row carried
onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'".
encodeURIComponent does not encode ', so an observer id containing a
quote broke out of the JS string and ran (observer ids come from the MQTT
topic). The row already has data-action="navigate" with the same target
in data-value, handled by the delegated click/keydown listeners, so the
inline handler is removed.

This also restores the Kpa-clawbot#1056 behaviour on narrow viewports: the inline
handler navigated to the detail page even when the delegated handler
opened the slide-over instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… misses (#333)

Review of PR #373, finding F4. New fixtures for scripts/check-xss-sinks.sh:

- bad-15: el.innerHTML = '<span title="' + o.name + '">' passes the gate:
  the "…" pass takes `"' + o.name + '"` for one string and erases the field.
- bad-16: the mirror with "…" pieces around a '…' attribute (already
  flagged; pinned).
- bad-17: '<a href="https://…">' + o.name passes: `//` inside the string
  is stripped as a comment before strings are parsed.
- good-6: the escaped forms must stay clean.
- good-7: a plain '…${name}…' literal is flagged by the round-1 N4
  workaround although it is not an interpolation.

Red on this commit (bad-15, bad-17, good-7); the gate fix follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mit (#333)

Review of PR #373, finding F4. strip() erased "…" runs, then '…' runs,
then // to end of line, each across the whole line, so one quote kind
could swallow code between strings of the other kind and a URL was cut at
`//`. It now walks the line once, recognising '…', "…", `…` and //
comments in order. A quote inside a string or template is then just text,
so the round-1 workaround that kept ${…} inside "…" runs is gone, along
with its false positive on plain '…${x}…' literals.

Measured on every public/*.js: the gate reports the same 3 pre-existing
flags (bottom-nav.js x2, nav-drawer.js) before and after.

The gate still only audits the line that holds the sink. Every #333 sink
builds HTML on another line (row builders returning strings), so it cannot
catch those mutants; the DOM-level tests do. Flagging every HTML-building
line was measured at 224 hits on public/*.js, nearly all pre-escaped locals
or static text, so it is not enabled. The script header documents this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-pve-agent1 PR#373 runde-2 — head a031513

Review feedback addressed (commit a0315131)

Evidence tags: [T] = I ran it myself. [A] = based on reading the code. [K] = taken from CI or the review and not reproduced by me.

  1. F1 (blocking), packets region badge in group children and the flat view. Both are now wrapped in escapeHtml, the same way as the groupRegion fix (public/packets.js childRegion / region). Commit 8401fa82; test commit 592d8412. [T]
  2. F2 (blocking), shared region filter. Each code and label is escaped with the global escapeHtml in the pill data-region and label, the dropdown data-region and label, and dropdownLabel() (the trigger text). Selection still works: toggleRegion() reads dataset.region, which the parser has already decoded back to the exact code. Commit d4bdbfd0. [T]
    • Deviation: the task said escapeAttr for attributes. There is no shared escapeAttr; only file-local copies exist in home.js and path-inspector.js, and they don't encode </>. escapeHtml encodes all 5 OWASP characters, so it is safe in these double-quoted attributes and is the codebase's convention for them (e.g. data-observer-id="${escapeHtml(o.id)}"). Adding another copy would duplicate it. [A]
    • The setSelected sandbox in test-frontend-helpers.js now loads roles.js + app.js like the page does, so the global escaper exists there. [T]
  3. F3 (should-fix), observers row inline onclick. Removed. The row already has data-action="navigate" + data-value and is handled by the delegated click and keydown listeners, so navigation is unchanged on desktop. On a narrow viewport the inline handler used to navigate to the detail page underneath the slide-over the delegated handler opened. Now only the slide-over opens, which is the documented [#1050] Task 3: Packets/Nodes/Observers tables — fluid columns + +N hidden pill Kpa-clawbot/CoreScope#1056 AC#4 behaviour. This is a visible behaviour change and the only one. [T] (probe, before → after, 500 px: hash #/observers/<id> + slide-over open → hash #/observers + slide-over open; 1400 px: navigates in both). Commit 4eb76dcc.
  4. F5 + F6 (test quality). Commit 7f6aa594.
    • The prune-preview, clock-health and RF-health unit tests now lift and run function esc(s) from customize-v2.js / analytics.js instead of injecting app.js's escapeHtml.
    • A new contract test pins that analytics.js esc() encodes the 5-char set; the RF-health attribute fix depends on it.
    • The prune preview also renders in Chromium with the real textContent-based esc().
    • The fragment-E2E regexes for the observer/packet region cells and the clock-health unit regex now match the escaped and the unescaped shape, so mutants fail on injected markup instead of "sink not found" (see M5, M9, M12 below). [T]
  5. F4 (gate). Commits ed9c45c1 (red fixtures) and a0315131 (fix).
    • What I changed: strip() now parses '…', "…", `…` and // comments in one left-to-right pass. Before, it erased "…" runs, then '…' runs, then //… across the whole line. This closes:
      • the concat form of N4 the review found: '<span title="' + o.name + '">', fixture bad-15;
      • URLs cut at //: '<a href="https://…">' + o.name, fixture bad-17;
      • the round-1 false positive on a plain '…${name}…' literal, fixture good-7.
    • No new false positives [T]: run over every public/*.js, the gate reports the same 3 pre-existing flags (bottom-nav.js ×2, nav-drawer.js) before and after. --diff origin/master exits 0.
    • Not extended, and documented instead: the cross-line case, i.e. HTML built in a row builder and assigned to innerHTML on another line. Every security(ui): escape untrusted node, observer and route values in HTML rendering #333 sink has this shape, so the gate still catches none of the sink mutants below. I measured the obvious heuristic (flag every HTML-building line with an unescaped allowlisted token) on public/*.js: 224 hits, nearly all pre-escaped locals or static text. That is false-positive territory, so per the instruction it is documented in the script header and here rather than enabled. Closing it needs data flow, not a line regex. [T]
  6. Reviewer mutants re-run, plus one or more new mutants per finding. See the table below. [T]

No other changes. origin/master 8eb1575a was merged in with a merge commit (d1852352, no conflicts). No rebase, amend or force-push; the push was a fast-forward 97b4a1f6..a0315131.

Tests (red first, then green)

Commit Content State on that commit [T]
7f6aa594 F5/F6 test changes green (44/0); the existing sinks are already escaped
592d8412 F1–F3 unit tests (test-xss-escape-sinks.js §J) + test-xss-333-pages-e2e.js + deploy.yml wiring red: unit 45 passed / 8 failed; page E2E 1 / 9 (only "plain id still navigates" passes)
8401fa82 F1 fix unit 48/5
d4bdbfd0 F2 fix unit 52/1
4eb76dcc F3 fix unit 53/0
ed9c45c1 F4 gate fixtures red: test-preflight-xss-gate.js 5 assertions fail (bad-15, bad-17, good-7 + 2 hint checks)
a0315131 F4 gate fix all gate assertions pass

New tests:

  • Unit, test-xss-escape-sinks.js §J. These call the real functions in a vm sandbox, with app.js's escapeHtml as the page global; no regex lifting.
    • buildFlatRowHtml / buildGroupRowHtml (header, expanded children, benign KØB & Æ with no double escape), with observerMap seeded through the page's own /api/observers path.
    • RegionFilter render in pill and dropdown mode, plus dropdownLabel. Selecting the hostile code gives getSelected(), regionQueryString() and the persisted selection equal to the exact code. A benign code renders verbatim.
    • The observers row: no inline handler carries the id, and data-value still holds the navigation target.
  • E2E, test-xss-333-pages-e2e.js. New; it drives the real pages against the CI fixture server, so it also answers the round-1 "fragments, not pages" note. page.route rewrites /api/observers (SJC → hostile IATA, plus one synthetic observer with id xss333'-alert(333)-') and /api/config/regions in the browser; the fixture DB is untouched. It checks:
    • the flat view and expanded group children (badges are literal text, OAK/OAK untouched);
    • the region filter pills and dropdown on #/observers, including that clicking the hostile pill or checkbox filters the table to exactly the hostile-region observers and persists the exact code;
    • the filter on #/packets, #/nodes and #/analytics;
    • an F3 click with the quote id (navigates to #/observers/<encoded id>, no dialog), plain-id navigation, and the narrow-viewport slide-over.
    • Any <img src=x>, onerror call or alert dialog anywhere in the document fails the step.
    • Wired into deploy.yml next to test-observer-iata-1188-e2e.js; the job runs bash -eo pipefail, so a failure fails the job.
  • Gate fixtures bad-15/16/17 and good-6/7. bad-16 (the mirror form) was already caught and is kept as a pin.

Mutants [T]

U = test-xss-escape-sinks.js, E = test-xss-333-e2e.js (fragments in Chromium), P = test-xss-333-pages-e2e.js (real pages, local Go server), G = check-xss-sinks.sh --file. Each mutant was applied to the final tree, the suites were run, and the file was restored. "—" = suite not relevant to that sink.

ID Mutation U E P G
M1 drop escapeHtml on favorites name red red — not flagged
M2 drop esc on prune-preview name red red — not flagged
M3 drop escapeHtml on observers table badge red red — not flagged
M4 drop escapeHtml on slide-over badge red — — not flagged
M5 drop escapeHtml(groupRegion) red red (behavioural: "1 markup element injected") red not flagged
M6 drop esc in RF data-observer red — — not flagged
M7 drop esc in RF sparkline id red — — not flagged
M8 drop escapeHtml(route) red red — not flagged
M9 drop esc on clock-health name red (behavioural: raw <img survived) — — not flagged
M10 app.js escapeHtml stops encoding " red red red (5 steps) —
M11 analytics.js esc stops encoding " red (was green in round 1) — — —
M12 drop escapeHtml on observers data-value green red (behavioural: "data-value sort key not preserved") — not flagged
F1a drop escapeHtml(childRegion) red — red not flagged
F1b drop escapeHtml(region) (flat) red — red ("15 injected <img>") not flagged
F2a pill data-region raw red — red (3 steps) not flagged
F2b pill label raw red — red (3) not flagged
F2c dropdown data-region raw red — red (2) not flagged
F2d dropdown label raw red — red (2) not flagged
F2e dropdownLabel unescaped red — red ("trigger label is not the literal code") not flagged
F3 re-add the inline onclick red — red ("1 dialog") not flagged
F5 customize-v2.js esc becomes identity red red — —
F4 revert strip() to the round-1 version — — — test-preflight-xss-gate.js red (5 assertions)

Every sink mutant is red in at least one suite. "Not flagged" in the G column is the documented same-line limit (item 5). M12 has no unit test (unchanged from round 1); it is caught behaviourally by the fragment E2E.

Local suites [T]

  • sh test-all.sh → 234 passed, 0 failed.
  • node test-frontend-helpers.js → 712 passed, 0 failed.
  • test-xss-escape-sinks.js 53/0, test-preflight-xss-gate.js all pass, scripts/check-xss-sinks.sh --diff origin/master exit 0.
  • Go: cmd/server go test ./... ok; cmd/ingestor go test ./... ok. No Go changes in this round.
    • A first cmd/server run, made while Playwright and two servers were loading the machine, failed TestHandleNodePaths_SortCountTiebreaker_1145 / …_FallbackUniquePrefix_1352 with 503 index loading. That is a startup-timing race. Both tests passed 5/5 in isolation and in a full idle re-run.
  • E2E against a local Go server with e2e-fixture.db prepared as in CI (freshen, the Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486/"Group Data" message type missing from packet view window's "message type" filter Kpa-clawbot/CoreScope#1791 seed SQL from deploy.yml, corescope-migrate, seeds 2073/199/245), on the final head:
    • test-xss-333-pages-e2e 10/0, test-xss-333-e2e 8/0, test-issue-1639-observers-sort pass, test-slideover-1056 27/0, test-slideover-1168-munger 3/0, test-issue-189-group-caret 3/0, test-issue-147-packets-url-obs 12/0, test-issue-1486-collapse-reopens-detail 4/0, test-table-fluid 41/0, test-nodes-favorite-rerender 4/0, test-filter-ux 12/0, test-issue-199-inactive-observer 3/0, test-issue-1692-packets-init-parallel 1/0, test-channels-client-state-152 6/0.
    • test-observer-iata-1188-e2e: passed on an earlier run of this code. On a freshly re-prepared fixture its 375 px step fails with "Element is not attached to the DOM" (the row re-renders between $() and click()). It fails identically on origin/master public/ against the same fixture, so it is environmental/timing and not caused by this PR. CI result below.

CI per job

Run actions/runs/38310606816, attempt 1, head a0315131 [T] (read via gh run view / job log):

Job Result Time
Go Build & Test ✅ pass 23m49s
Playwright E2E Tests ✅ pass 27m13s
Build & Publish Docker Image ✅ pass 49s
Release Artifacts ⏭️ skipped (fork guard) —
Deploy Staging ⏭️ skipped (fork guard) —
Publish Badges & Summary ⏭️ skipped —

The Playwright log shows test-xss-333-pages-e2e.js at 10 passed, 0 failed (all F1/F2/F3 steps listed) and test-xss-333-e2e.js at 8 passed, 0 failed (including the new prune-preview step). test-observer-iata-1188-e2e.js passed in CI, including its 375 px step. The log has no ✗/❌ lines. Neither known flake (#271, #301) occurred, so no rerun was needed.

Not done / notes

  • area-filter.js has the same raw-concat shape for area keys and labels. Those come from the operator's server config, not from observers or MQTT, so it is out of security(ui): escape untrusted node, observer and route values in HTML rendering #333 scope and left unchanged. [A]
  • The round-1 follow-up on analytics.js querySelector('[data-observer="…"]') (a CSS-selector issue) is untouched, as agreed there.
  • No merge, ready, issue closing, stg/prod access or upstream writes. No payload left the machine.

@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Review — CS-Macmini PR#373 — head a031513

Dom: REQUEST CHANGES

Evidence tags: [T] = I ran it myself. [A] = from reading the code. [K] = taken from the author's report or CI and not reproduced by me.

Round 2 resolves every finding from round 1. F1, F2 and F3 are fixed with the right escaper for each context, each one has a test that is red on master code and green here, and the gate change (F4) is a real improvement with its remaining gap documented in the script header. F5 and F6 are closed: the tests now run the escapers the production code actually calls, and the sink regexes match the escaped and the unescaped shape, so mutants fail behaviourally.

The verdict is still REQUEST CHANGES for the same reason as round 1, and only that reason: the issue's first acceptance criterion covers adjacent rendering sites, and two more are still unescaped. Both render the identical value — observer IATA from /api/nodes/{pk}/health — raw into innerHTML, both sit in files the issue names for audit, and in both cases the sibling sink a line or two away is already escaped. One of them I reproduced as a live injection in Chromium against the merged tree. Each is a one-line fix with the escaper already in scope.

Findings

# Severity Where Finding Evidence
N1 Blocking public/nodes.js:2005 <strong>Regions:</strong> ${regions.join(', ')} in the Nodes-page detail panel (renderDetail → panel.innerHTML), where regions = [...new Set(observers.map(o => o.iata)...)]. Raw. The same line in the full-page view at nodes.js:970 wraps each code in escapeHtml(r), and the "Heard By" rows below it (:2013) escape the IATA too — only this summary line leaks. [T] Chromium against a local Go server on the merged tree, with the hostile IATA injected into /api/nodes/{pk}/health via page.route: 1 injected <img src=x> in #nodesRight, onerror fired twice. Rendered HTML: <strong>Regions:</strong> "&gt;<img src="x" onerror="…">. The badge in the row below showed the payload as text in the same DOM. [A] Pre-existing on master; not introduced here.
N2 Blocking public/live.js:2727 Heard By${regions.length ? ' — Regions: ' + regions.join(', ') : ''} in showNodeDetail → content.innerHTML, same observers.map(o => o.iata) source. Raw. Line 2729, in the same if (observers.length) block, escapes o.iata for each observer row — so the fix was applied one line away and this heading was missed. live.js is one of the seven files the issue lists. [T] Rendering the extracted production expression with regions = ['SJC', '"><img src=x onerror=alert(1)>'] yields SJC, "><img src=x onerror=alert(1)> — raw tag. [A] Reached by clicking a node marker on the Live map, so it needs an interaction; nodes.js:2005 needs none beyond selecting a node. Pre-existing on master.
N3 Nit / FYI public/analytics.js:1105 onclick="location.hash='#/nodes/${encodeURIComponent(r.pubkey)}'" on the repeater row — the exact pattern F3 removed from observers.js:427, still present. encodeURIComponent does not encode '. Exposure is narrower (node public keys, not an MQTT topic segment), so this is a consistency note, not a blocker. A repo-wide scan found no other dynamic inline on* handler carrying unescaped untrusted data: compare.js:529 and hop-display.js:96 were the only other dynamic ones, and both interpolate hashes/JSON through escapeHtml. [T] grep over public/*.js; [A] reading.
N4 Nit test-xss-escape-sinks.js loadLocalEsc It lifts the first one-line function esc(s) { … } in a file by regex. analytics.js and customize-v2.js each have exactly one today, so it is correct now, but a second file-local esc added above the real one would silently retarget the contract test. A line anchor or an explicit expected body would pin it. [A]
N5 Nit / FYI public/app.js:1520 #6b7280 → var(--text-muted) on the unknown-route heading. A small intentional visual change, forced by the no-hardcoded-colours rule once the line was touched. Already raised as a nit in round 1; noted again only for the record. [A]

Answers to the review points

F1 — childRegion and region in packets.js. Fixed, one mutant per site. [T] escapeHtml(childRegion) at :2730 and escapeHtml(region) at :2767, matching the groupRegion fix at :2695. Dropping escapeHtml(childRegion) → unit 52/1 and the pages E2E red with a behavioural message (child badges … got ["OAK","\">'","OAK"]). Dropping escapeHtml(region) → unit 51/2. On master's public/ the same E2E step reports 14 injected <img> and 42 handler calls in the flat view, so the before/after is unambiguous.

F2 — region-filter.js, verified in a browser. Fixed, and filtering still works. [T] Against a local Go server I ran the PR's own page E2E (10/0) and inspected the behaviour it asserts: with a hostile code, the pill and the dropdown item carry it intact in data-region, the label and the dropdown trigger show it as literal text, clicking the hostile pill filters the observers table down to exactly the hostile-region rows, the persisted meshcore-region-filter entry is the exact code, and the pages stay inert (0 <img>, 0 handler calls, 0 dialogs) on #/observers, #/packets, #/nodes and #/analytics. A benign code (KØB & Æ) round-trips verbatim in both modes with no double escaping. On master's public/, the same checks fail: the hostile " breaks data-region out to the empty string on all four pages, so the code is lost and the markup goes live. Four mutants (pill attr, pill label, dropdown attr, dropdownLabel) are red in the unit suite; the pill-attr mutant is red in 3 E2E steps. [A] escapeHtml is a top-level declaration in app.js, so it is a page global by the time render() runs even though region-filter.js loads earlier; toggleRegion reads dataset.region, which the parser has already decoded, so selection matches _regions exactly. [A] /api/config/regions merges cfg.Regions with db.GetDistinctIATAs() (cmd/server/routes.go:747), which confirms the premise that region codes carry MQTT-derived observer IATAs.

F3 — observers.js inline onclick. Removed, and navigation is intact. [T] On master's public/, the observer row with id xss333'-alert(333)-' fires the injected call — 1 alert dialog plus 13 injected <img> — and the narrow-viewport click navigates away to #/observers/<id>. On the merged tree: no dialog, the quote id navigates to #/observers/<encoded>, a plain id still navigates, and at 500 px only the slide-over opens with location.hash left at #/observers. [A] That last change is a fix, not a regression: the delegated handler at observers.js:202-212 and the keydown handler at :224-232 implement #1056 AC#4 (slide-over instead of page navigation at narrow widths), and the inline handler was defeating it. Re-adding the onclick turns the unit suite red (52/1).

F5 and F6 — tests run the real escapers; E2E regexes are not hardcoded. Both closed. [T] loadLocalEsc lifts function esc(s) from analytics.js and customize-v2.js, a direct contract test pins that analytics.js esc() encodes all five OWASP characters, and §J drives the real buildFlatRowHtml / buildGroupRowHtml / RegionFilter.render in a vm sandbox with app.js's own escapeHtml as the page global — no template is lifted there at all. The round-1 escapee is now caught: making analytics.js esc() stop encoding " turns the unit suite red (52/1), where it was green in round 1. Making customize-v2.js esc() the identity is also red (52/1). The §I sink regexes are dual-form (\$\{[^}]*o\.iata[^}]*\}, <span class="fav-dd-name">' + (…) + '), so a mutant fails on behaviour, not on "sink not found" — I confirmed that on every mutant I ran.

F4 — gate extended or limitation documented. Documented, and the script is genuinely better. [T] strip() now parses '…', "…", `…` and // in one left-to-right pass instead of three whole-line passes, which closes the concat form of N4 ('<span title="' + o.name + '">'), the URL-cut-at-// case, and the round-1 false positive on a literal '…${x}…'. Reverting strip() to the master version makes test-preflight-xss-gate.js fail 8 assertions; the five new fixtures (bad-15/16/17, good-6/7) are paired bad/good, so the fix is pinned in both directions. No flood of false positives on master [T]: I ran the new gate over every public/*.js file on origin/master and diffed it against the old gate's output — byte-identical, the same 3 pre-existing bottom-nav.js / nav-drawer.js flags and nothing else. scripts/check-xss-sinks.sh --diff origin/master exits 0 on the merged tree. The remaining cross-line gap (a row builder returning HTML, assigned to innerHTML elsewhere — the shape of every #333 sink) is stated in the header with the measured reason it is not closed by a line regex; it is why the gate flags none of the sink mutants, and the DOM-level tests cover them instead.

More adjacent sinks of the same type. Yes — N1 and N2 above. I scanned public/*.js for ${…} / ' + x + ' interpolations of name / iata / region / observer* / route / scopes fields on HTML-building lines (206 raw hits) and then read every candidate whose value plausibly comes from observer, node or route data. Everything else was either already escaped at the sink or a pre-escaped local (nameHtml, safeName, nameCell, regionsHtml, regionList, obsLabel, traces.js obsLink). All 13 badge-region sinks in the tree are escaped. analytics.js:4553's getElementById('rf-spark-' + obs.observer_id) with the raw id is correct, because the parser decodes the entities in the id attribute. area-filter.js:75 has the identical raw-concat shape for a.key / a.label, and I agree with the author that it is out of scope: those come from /api/config/areas, i.e. operator config, not from MQTT. analytics.js:4562's querySelector('[data-observer="…"]') is the pre-existing CSS-selector issue agreed as out of scope in round 1.

Scope creep. None. [T] 21 files: 6 public/*.js production files (16 changed lines total), 2 new E2Es wired into deploy.yml (2 lines), the gate script, 8 gate fixtures and 3 test files. No Go files touched at all.

Checklist

Item Result
Every acceptance criterion has a red-before / green-after test Yes for the sinks this PR changes [T] — the pages E2E is 1 passed / 9 failed against master's public/ and 10/0 on the merged tree. No for AC#1's adjacent sites: N1 and N2 have no test.
At least two of my own mutants 9 run (5 production sinks, 3 escaper helpers, 1 gate), all red [T] — table below
No behaviour change beyond the purpose One, intended and documented: the narrow-viewport observer row no longer navigates behind the slide-over (#1056 AC#4) [T]. Plus the colour token in N5.
cmd/server read-only, no new map[string]interface{} outside tests No .go file changed; no map[string]interface{} added anywhere in the diff [T]
No hardcoded colours One hex removed, none added [T]
scripts/check-xss-sinks.sh --diff origin/master Exit 0 on the merged tree [T]
Fork guards 9 in deploy.yml, 1 in release-fast-path.yml — identical counts to master [T]
Closing keywords None in the commit messages or the body ("Relates to #333"); closingIssuesReferences is empty [T]
Commit author / committer dborup <kontakt@meshview.dk> on all 9 commits, author and committer [T]
Head stable git ls-remote returned a0315131 before and after the review [T]

Tests and mutants

Everything ran on the merged tree against current origin/master 8eb89720. git merge-tree --write-tree origin/master a0315131 → be2344fc, no conflicts; a clean clone merged to the same tree hash.

  • [T] Go: cmd/server go test ./... → ok 39.9s; cmd/ingestor go test ./... → ok 104.9s. No Go changes in the PR.
  • [T] sh test-all.sh → 235 passed, 0 failed (235 files), exit 0, first run, no retries. Its own registry tests confirm both new E2Es are wired into deploy.yml.
  • [T] node test-frontend-helpers.js → 712 / 0. test-xss-escape-sinks.js → 53 / 0. test-preflight-xss-gate.js → all assertions pass.
  • [T] E2E against a local Go server with e2e-fixture.db prepared as in CI (freshen, the #1486/#1791 seed SQL, corescope-migrate, seeds 2073 / 199 / 245), on a port I verified free first: test-xss-333-pages-e2e 10/0, test-xss-333-e2e 8/0, test-issue-1639-observers-sort-e2e pass, test-slideover-1056-e2e 27/0, test-issue-189-group-caret-e2e 3/0.
  • [T] Red-before baseline: a second server on a separate port and a separate DB copy, serving origin/master's public/ against the same prepared fixture. test-xss-333-pages-e2e → 1 passed, 9 failed, with 14 injected <img>, 42 handler calls and 1 alert dialog across the steps. That is the whole of F1/F2/F3 demonstrated as live injection before the fix.
  • [T] test-observer-iata-1188-e2e fails its 375 px step on the merged tree with Element is not attached to the DOM — and fails identically on origin/master's public/ against the same fixture, so it is environmental, matching the author's note. [K] It passed in CI, including that step.
  • [K] [T] CI run 38310606816, attempt 1, head a0315131, read per job: Go Build & Test ✅ 23m49s, Playwright E2E ✅ 27m13s, Docker ✅ 49s, Release / Deploy / Badges skipped by the fork guard. I grepped both job logs: no line matching ✗/❌ and no N passed, M failed with a non-zero M; the three FAIL strings in the Go log are PASS=26 FAIL=0 and the a11y self-test's own expected [FAIL] output. The Playwright log shows test-xss-333-pages-e2e.js at 10/0 with all ten steps named. Neither known flake (flaky: #226 Hash Stats adopters sort E2E — server sorts multiByteNodes on packets only, ties come out in random order #256 Hash Stats sort, flaky: TestResolvedPathBackfill_WriteHoldUnderBudget_188 — wall-clock write-hold budget fails under CI load #267 backfill write-hold) appears.

Mutants [T] — U = test-xss-escape-sinks.js, P = test-xss-333-pages-e2e.js against my local server, G = test-preflight-xss-gate.js. Each was applied to the merged tree, the suites were run, and the file was restored and byte-compared against the head blob afterwards.

ID Mutation U P G
A drop escapeHtml(childRegion) (packets.js) red 52/1 red (behavioural: child badges show the raw payload) not flagged (documented limit)
B drop escapeHtml(region), flat view (packets.js) red 51/2 — not flagged
C pill data-region raw (region-filter.js) red 50/3 red (3 steps: pills, #/nodes, #/analytics) not flagged
D dropdownLabel unescaped (region-filter.js) red 52/1 — not flagged
E re-add the inline onclick (observers.js) red 52/1 — not flagged
F analytics.js esc() stops encoding " red 52/1 (green in round 1) — —
G customize-v2.js esc() becomes the identity red 52/1 — —
H app.js escapeHtml stops encoding ' red 19/34 — —
I revert strip() to the master version — — red, 8 assertions

Not verified

  • I did not drive the RF-health grid or the geo-filter prune preview in a browser: the fixture has no noise-floor data, and I did not walk the customizer geo-filter flow. Both rest on unit tests plus code reading here.
  • N2 (live.js:2727) is evidenced by a static read plus rendering the extracted production expression, not by a click on the Live map; reaching it in a browser needs a Leaflet marker click, which I did not set up.
  • I did not run test-e2e-playwright.js, any coverage or instrumented run, or the full E2E list from deploy.yml — only the PR's own two and the suites listed above. The rest of the Playwright job is [K] from CI.
  • I did not assess exploitability against any real broker ACL; the trust analysis for observer ids and IATA topic segments is [A] from reading the ingestor and the regions handler.
  • No payload left this machine. Everything ran against local servers on free high ports, and the hostile values were injected by rewriting API responses in the browser, so the fixture DB was never modified with a payload.

…#333)

Round 3 of PR #373, addressing the re-review (N1-N4).

N1 nodes.js: the node-detail panel's "Regions:" summary joined
observers.map(o => o.iata) raw into panel.innerHTML, one line above the
already-escaped per-observer badge. Wrap each code in escapeHtml, matching
the full-page view at nodes.js:970.

N2 live.js: showNodeDetail's "Heard By - Regions: ..." heading joined the
same observer IATAs raw into content.innerHTML; the per-observer rows one
line below were already escaped. Same escapeHtml(join) fix.

N3 analytics.js: renderRepeaterTable's row carried
onclick="location.hash='#/nodes/${encodeURIComponent(r.pubkey)}'".
encodeURIComponent leaves ' ( ) alone, so a pubkey quote breaks out of the
JS string - the pattern F3 removed from observers.js. Drop the inline
handler, navigate via data-action="navigate"/data-value, and broaden the
delegated handler's selector from tr[...] to [...] so the div row is
covered. tabindex/role match the sibling table rows for keyboard access.

N4 test-xss-escape-sinks.js: loadLocalEsc lifts the first one-line
function esc(s); assert exactly one esc() definition per file so a second
one added above the real escaper fails loudly instead of silently
retargeting the contract test.

Tests: Section K drives the real extracted production expressions for
N1/N2/N3 with a hostile IATA/pubkey (red before, green after); a new N1
step in test-xss-333-pages-e2e.js injects the hostile IATA into
/api/nodes/{pk}/health and asserts the detail panel stays inert.

Relates to #333

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-MacBook PR#373 runde-3 — head 8474b49

Review feedback addressed (commit 8474b494). Evidence tags: [T] ran it myself · [A] from reading the code · [K] from CI.

  1. N1 (blocking) — public/nodes.js:2005. The node-detail panel (renderDetail → panel.innerHTML) joined observers.map(o => o.iata) raw into the "Regions:" summary, one line above the already-escaped per-observer badge at :2013. Now ${regions.map(escapeHtml).join(', ')}, matching the full-page view at :970. [T] Unit test lifts the real IIFE and renders it with a hostile IATA — red before (raw <img> survived), green after. [T] New E2E step injects the hostile IATA into /api/nodes/{pk}/health and drives the panel in Chromium: inert after the fix, 1 injected <img> + 2 handler calls before it.

  2. N2 (blocking) — public/live.js:2727. showNodeDetail (content.innerHTML) appended the same observers.map(o => o.iata) join raw into the "Heard By — Regions: …" heading; the per-observer rows one line below (:2729) already escaped o.iata. Now regions.map(escapeHtml).join(', '). [T] Unit test lifts the real join expression and renders it with a hostile IATA — red before, green after.

  3. Systematic sweep of public/*.js. Full table below. The only raw, exploitable sinks of observer / region / node / scope / channel / route data were N1, N2 and N3; all fixed this round. Every other candidate is escaped at the sink, a pre-escaped local, format-constrained (hex), or operator/self-config out of security(ui): escape untrusted node, observer and route values in HTML rendering #333 scope.

  4. N3 — public/analytics.js:1105. renderRepeaterTable's row carried onclick="location.hash='#/nodes/${encodeURIComponent(r.pubkey)}'". encodeURIComponent leaves ' ( ) alone, so a pubkey quote breaks out of the single-quoted JS string — the exact pattern F3 removed from observers.js. Dropped the inline handler; the row now navigates via data-action="navigate" / data-value (plus tabindex/role for keyboard parity with the sibling table rows), and the delegated handler at :333 was broadened from tr[data-action="navigate"] to [data-action="navigate"] so the <div> row is covered. [T] Unit test: no inline on* handler carries the pubkey and data-value is preserved — red before, green after. [T] Browser probe: clicking the row navigates to #/nodes/<pubkey>. Grep for more onclick="…${: the only other dynamic-interpolation inline handler is analytics.js:4117 (data-pt-configured="${b}", a number — false positive). compare.js:529 uses the concat form; see the table.

  5. N4 — test-xss-escape-sinks.js loadLocalEsc. It lifts the first one-line function esc(s). Added an assertion that the file contains exactly one esc() definition, so a second one added above the real escaper fails loudly instead of silently retargeting the contract test. [T] Self-check: injecting a second function esc( makes the count 2 and throws.

Systematic sink sweep — public/*.js

Method: grepped all innerHTML / insertAdjacentHTML / outerHTML / document.write sinks and every interpolation of iata / region / observer* / .name / pubkey / scope / channel / route / path / label in template and concat form, plus every inline on*= handler, and traced each wrapper (transportBadge, scopeCellHtml, favStar, obsName/obsNameOnly, regionsHtml, …) to its sink across lines/functions.

File:Line Value (source) Status
nodes.js:2005 iata region join (observer health) RAW → FIXED (N1)
live.js:2727 iata region join (observer health) RAW → FIXED (N2)
analytics.js:1105 r.pubkey in inline onclick JS-string RAW double-context → FIXED (N3)
nodes.js:970, 2013; live.js:2729; observers.js:430,544; observer-detail.js:389; compare.js:163; map.js:2340; analytics.js:6812 iata / region badges SAFE — escapeHtml/esc at sink
packets.js (groupRegion/childRegion/region badges, scope via scopeCellHtml/transportBadge, pkt.scope_name) region / scope SAFE — escaped (F1 + pre-existing)
region-filter.js (pills, dropdown items, trigger) region codes/labels SAFE — escaped (F2)
analytics.js:5445/5454 regionsHtml esc(r) SAFE — pre-escaped local
node names across nodes.js/live.js/home.js/analytics.js/compare.js/new-nodes.js/node-changes.js/reach-rank.js node .name SAFE — esc/escapeHtml at sink
channels.js channel names; home.js labels (escapeAttr/_escHtml) channel/name SAFE — escaped
hop-display.js:96 static onclick + escapeHtml(JSON.stringify(...)) data-attr SAFE — static handler, escaped attr
compare.js:529 escapeHtml(h) in inline onclick (packet hash) SAFE in practice — hash is hex; inline onclick is load-bearing (compare.js has no delegated click handler for nav rows, only keydown at :114), so removing it would regress mouse navigation. Left unchanged, consistent with the round-2 acceptance; noted that escapeHtml alone does not neutralise ' in this double context — only the hex constraint does.
live.js:2729/2737; analytics.js:1152/1203/2266/4017; home.js:201 encodeURIComponent(observer_id/pubkey/q) in #/… href SAFE — URL context, not JS-string
favStar (app.js:712) + data-fav/data-key/href pubkey sites in home.js, nodes.js, channels.js, map.js, live.js, packets.js raw public_key/pubkey in double-quoted attributes SAFE in practice — pubkeys are hex, no breakout char; pre-existing; defense-in-depth escaping is a broader change outside this round
app.js routeTypeName/ROUTE_TYPES; observers.js h.label; static card/label configs numeric / fixed in-code enums SAFE — not untrusted
perf.js:335,345 s.path / q.path (HTTP request-path telemetry on the admin perf dashboard) RAW, but out of #333 scope — not observer/region/node/scope/channel/route mesh data; low exploitability (endpoint paths); recommend a separate issue rather than widening this PR. [NEW find from the sweep]
area-filter.js:75 a.key / a.label (/api/config/areas, operator config) Out of scope — operator config, not MQTT/advert (as agreed round 2)
customize.js / customize-v2.js logo-URL <img src> branding self-config Out of scope — operator self-config (self-XSS)

Tests, mutants

  • [T] node test-xss-escape-sinks.js → 56 / 0 (was 53; +3 for N1/N2/N3 in new Section K). The N4 assert is live in loadLocalEsc.
  • [T] Red-before / green-after, each test drives the real extracted production expression with a hostile value:
    • N1 nodes summary: red raw <img> survived → green.
    • N2 live heading: red raw <img> survived → green.
    • N3 repeater row: red node pubkey reaches an inline JS handler → green.
  • [T] Mutants, one per finding (each applied to the fixed tree, suite run, then restored):
    • N1 — revert regions.map(escapeHtml).join → regions.join: unit red; new E2E step red with a live injection (1 injected <img>, 2 handler calls).
    • N2 — revert regions.map(escapeHtml).join → regions.join: unit red (raw <img> survived).
    • N3 — re-add the inline onclick: unit red (pubkey reaches an inline handler).
    • N4 — inject a second function esc(: loadLocalEsc throws (count 2).
  • [T] node test-frontend-helpers.js → 712 / 0. node test-preflight-xss-gate.js → all pass. sh test-all.sh → 234 / 0. scripts/check-xss-sinks.sh --diff origin/master → exit 0.
  • [T] E2E against a local Go server (port 13900) with the CI-prepared e2e-fixture.db (freshen, Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486/"Group Data" message type missing from packet view window's "message type" filter Kpa-clawbot/CoreScope#1791 seed SQL from deploy.yml, corescope-migrate, seeds 2073/199/245):
    • test-xss-333-pages-e2e 11 / 0 (10 round-2 steps + the new N1 panel step).
    • test-xss-333-e2e 8 / 0, test-issue-1639-observers-sort-e2e pass, test-slideover-1056-e2e 27 / 0, test-issue-199-inactive-observer-e2e 3 / 0, test-filter-ux-e2e 12 / 0, test-observer-iata-1188-e2e pass (the step the round-2 reviewer saw as environmentally flaky passed cleanly here).
  • [T] Go: cmd/server go test ./... → ok 43.5s. No Go files changed in this round.

N2 is covered by the unit test plus a static read, not by a browser click: reaching it live needs a Leaflet marker click on the Live map, which I did not wire. N1 is the one the fix is most load-bearing on and it has the full E2E.

CI per job

[K] Run 38322081004, head 8474b494, read per job via gh run view:

Job Result Time
Go Build & Test ✅ success 23m
Playwright E2E Tests ✅ success 28m
Build & Publish Docker Image ✅ success ~1m
Release Artifacts ⏭️ skipped (fork guard) —
Deploy Staging ⏭️ skipped (fork guard) —
Publish Badges & Summary ⏭️ skipped —

The Playwright log shows test-xss-333-pages-e2e.js at 11 passed, 0 failed (the new ✓ N1 nodes detail panel: Regions summary renders the hostile IATA as text step included) and test-xss-333-e2e.js at 8 passed, 0 failed. No ✗/❌ line anywhere in either job log; neither known flake (#271, #301) occurred, so no rerun was needed.

Not done / scope

  • No merge, ready, issue closing, stg/prod access, upstream writes or API-key use. No payload left the machine — hostile values were injected by rewriting API responses in the browser; the fixture DB was never written with a payload.
  • perf.js:335/345 (above) is a genuine raw sink but outside security(ui): escape untrusted node, observer and route values in HTML rendering #333's data categories; flagged for a separate issue rather than changed here.
  • compare.js:529 and the pubkey-in-attribute sites are left unchanged with the reasoning in the table.

@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Review — CS-Macmini PR#373 — head 8474b49

Dom: APPROVE med nits

Evidence tags: [T] = I ran it myself. [A] = from reading the code. [K] = taken from the author's report or CI and not reproduced by me.

Round 3 closes both round-2 blockers and the three nits. N1 and N2 are escaped with the right escaper, each has a test that is red on the unmutated-escaper code and green here, and I reproduced N2 as a live injection in a real browser on the Live map — the one piece of evidence neither the author nor my round-2 review had. N3 removes the last dynamic inline on* handler carrying untrusted data and replaces it with working delegated navigation. My own sink sweep over all 71 public/*.js files found no remaining unescaped sink carrying observer, region, node, scope, channel or route data, so the issue's first acceptance criterion is now met.

The nits below are all non-blocking and none of them is an escaping problem. The one I would most like fixed before merge is N6: the role="row" added alongside the N3 fix introduces 11 new critical axe violations that the repo's own a11y gate cannot see.

Findings

# Severity Where Finding Evidence
N6 Nit (new a11y regression) public/analytics.js:1105 The N3 fix adds role="row" to the repeater <div>, whose parent is a plain <div class="repeater-list"> with no role — so the row has neither a required rowgroup/table/grid parent nor any cell-role children. The sibling tr rows at :1262, :1630, :1725 carry role="row" legitimately because they sit inside real <table>s. tabindex="0" alone (or role="link" / role="button") gives the same keyboard access without the broken row semantics. [T] axe-core on #/analytics?tab=topology against a local server: head = 2 violations, aria-required-parent 11 nodes + aria-required-children 12 nodes (both critical); the same run on master's public/ = 1 violation, aria-required-children 1 node (the pre-existing #analyticsTabs tablist). So 11 of the 12 aria-required-children nodes and all 11 aria-required-parent nodes are new here. [A] The repo's gate test-a11y-axe-1668.js does run this route, but its 13-rule list (RULES_DESKTOP, lines 112–126) contains neither rule, so CI reports 0 violations — [T] I ran it: PASS: zero violations across 120 cells.
N7 Nit (test gap) public/analytics.js:333 Broadening the delegated selector from tr[data-action="navigate"] to [data-action="navigate"] is the half of the N3 fix that makes the row clickable at all, and nothing in the repo pins it. The N3 unit test only inspects the rendered template. [T] Mutant M4: narrow the selector back to tr[...]. test-xss-escape-sinks.js 56/0, test-frontend-helpers.js 712/0, test-xss-333-pages-e2e.js 11/0 — all green, while my own browser probe shows repeater-row navigation fully dead: click → hash unchanged, Enter → hash unchanged (unmutated: both reach #/nodes/<pubkey>). A one-line assertion driving a click on the rendered row would close it.
N8 Nit / FYI public/perf.js:324, :334, :345 The author's sweep flags these three raw sinks and recommends a separate issue; I confirm the sinks are live, and add the provenance. SlowQuery.Path is set to the raw r.URL.Path with no normalisation (cmd/server/routes.go:509), unlike the endpoint key a few lines above, which is normalised to the mux route template. Exploitability is narrower than the raw sink suggests, though: a request has to both route under /api/ and exceed 100 ms to be recorded. [T] Browser: injecting a hostile endpoint key and slow-query path into /api/perf renders 2 injected <img>, onerror fired twice on #/perf. [T] I could not get attacker-controlled text in by sending requests: five hostile /api/... paths all 404'd fast and the recorded key collapsed to /api/, leaving no hostile key or slow query. [A] Reading perfMiddleware. I agree this is out of #333's data categories; it is worth filing, and the raw-r.URL.Path detail is the part a follow-up needs.
N9 Nit / FYI public/compare.js:529 I verified the author's reason for leaving this one inline onclick: compare.js really does have no delegated click handler for nav rows — only the #209 keydown handler at :114 on tr[data-action="navigate"] — so removing the handler would regress mouse navigation. Their note that escapeHtml alone does not neutralise ' in this double context (the parser decodes &#39; before the JS is parsed) is also correct; only the hex shape of the packet hash protects it. Consistent with the round-2 acceptance; mentioned only so the reasoning is on the record. [T] grep + reading compare.js:105–130, :434–442.
N10 Nit (residual on N4) test-xss-escape-sinks.js loadLocalEsc The new guard counts function esc\s*\( and throws unless there is exactly one — that pins the case N4 raised. It does not pin a differently declared second escaper: a const esc = s => … added above would keep the count at 1, so the contract test would still exercise the function esc(s) while the sink calls the arrow one. Low likelihood; noted for completeness. [A]

Answers to the review points

N1 — nodes.js:2005, with a mutant. Fixed. ${regions.map(escapeHtml).join(', ')}, matching the full-page view at :970. [T] Mutant M1 (revert to regions.join(', ')): test-xss-escape-sinks.js → 55/1 with a behavioural message (raw <img tag survived → angle-bracket escape broken), and test-xss-333-pages-e2e.js → 10/1 with a live injection (nodes detail Regions summary: 1 injected <img>, 2 handler call(s)). Restored and byte-compared against the head blob afterwards. [T] On master's public/ the same E2E step reports the identical injection, so the before/after is unambiguous.

N2 — live.js:2727, with a mutant, now browser-confirmed. Fixed. regions.map(escapeHtml).join(', '), matching the per-observer rows at :2729. [T] Mutant M2 (revert to regions.join(', ')): unit → 55/1, behavioural message. [T] New evidence: I drove the real path in Chromium — #/live, hostile IATA injected into /api/nodes/{pk}/health via page.route, then clicked Leaflet node markers until a node with a "Heard By" block opened (178 markers on the fixture map). On the merged tree the heading HTML is Heard By — Regions: SJC-N2"&gt;&lt;img src=x onerror=…&gt; → 0 injected <img>, 0 handler elements, 0 fired, 0 dialogs = INERT. With M2 applied, and separately against master's public/, the same click gives Heard By — Regions: SJC-N2"&gt;<img src="x" onerror="…"> → 1 injected <img>, 1 handler element, onerror fired once. So N2 is now verified at the DOM level in both directions, not only by the lifted expression.

N3 — analytics.js:1105, and no more onclick="…${. Fixed, and navigation improved rather than lost. [T] grep -nE 'on[a-z]*="[^"]*\$\{' public/*.js → the only two hits are false positives (data-pt-configured="${b}" at analytics.js:4117 and data-action="${…}" at packets.js:2676; the regex matches the letters on inside configured/other words, not an event handler). [T] A separate scan for the concat form (on<event>="…' +) over all public/*.js → no hits. [T] The remaining inline on*= handlers in the tree are 14, all static or operator-config: hop-display.js:96 (static handler, escapeHtml'd data-attr), audio-lab.js:213, observers.js:446/449 (event.stopPropagation()), analytics.js:2055 (static), compare.js:529 (N9), and the customize*.js logo-preview onerror="this.style.display='none'" sites. [T] Browser probe: with a hostile pubkey the row carries 0 inline on* attributes, nothing fires at render, and clicking the hostile row, clicking the benign row and pressing Enter on it all reach #/nodes/<encoded pubkey>. [T] Mutant M3 (re-add the inline onclick): unit → 55/1 (node pubkey reaches an inline JS handler), and in the browser the injected alert(333) runs — location.hash ends up #NaN, i.e. '#/nodes/aa11' - alert(333) - 'bb' was evaluated as JS. With the mutant, Enter on the row also stops navigating, confirming the tabindex/role addition is a real keyboard gain (see N6 for the role value).

The sink list, verified independently. Verified, and I found nothing missing. Method [T]: 531 innerHTML / outerHTML / insertAdjacentHTML / document.write sinks across 71 public/*.js files; then two scripted passes over every line — one over HTML-building lines, one over template lines with no < (multi-line template bodies) — matching ${…} and '…' + x + '…' interpolations of name / long_name / short_name / observer_name / scope_name / channel_name / iata / region / observer_id / pubkey / public_key / text / alias / topic / label / path / message that are not wrapped in an escaper, giving 125 + 9 candidates. I then read every candidate and traced each local and wrapper to its sink. Spot-checks against the author's table, ~25 sites: analytics.js:1180/4018/4192/3141 (esc(tip))/4756/5532 regionCodeHtml/6670 senderTriggerHtml, app.js:91 renderPathHashSize/117 transportBadge/141 scopeCellHtml, live.js:116 obsIataBadgeHtml/2780/4654–4671, nodes.js:1219/2129/1444 dupNameBadge, map.js:2339/2446, compare.js:315/477 nameA/nameB, observer-detail.js:192/689, node-reach.js:171, node-analytics.js:51, ping-scores.js:115, route-view.js:825, rx-coverage.js:193, traces.js:49, channels.js:640 highlightMentions, packets.js:655 obsName — every one escapes at the sink or is a pre-escaped local, and the table's status for each matches what I read. Three worth naming:

  • packets.js:655 obsName() returns ${o.name} (${o.iata}) unescaped, which looks like a leak until you follow it: its five call sites are a sort accessor (:3125), two buildReplayPackets fields (:3233, :3242) and one unused local (:3999). The replay observer field is consumed by live.js:2926 (favourite matching, no HTML) and live.js:4654 (escapeHtml(observer)). Safe, but it is the one helper in the tree that hands out raw mesh text, so it is worth a comment if anyone touches it.
  • channels.js:644 interpolates name raw into a ch-mention span — safe only because highlightMentions runs escapeHtml(text) before the regex, so name is already-escaped text. Correct, non-obvious.
  • favStar() (app.js:712) and the data-fav/data-key/href pubkey sites: raw pubkey in double-quoted attributes, pre-existing, hex-shaped — matches the author's table.
    Also confirmed out of scope as the table says: area-filter.js:75 (operator config), the customize*.js logo-URL <img src> sites (operator self-config; note :768/:1304 use escAttr while :801/:1116/:1563/:2409/:2770 do not — self-XSS either way), and analytics.js:4553's raw id in getElementById (correct: the parser decodes the entities in the id attribute).

N4 — loadLocalEsc robustness. Addressed. [A] It now asserts exactly one function esc( per file before lifting, with an error message that names the failure mode. [A] The three Section K tests also fail loudly rather than silently when extraction breaks (assert.ok(m, '… not found')), and [T] every mutant I ran on those sinks produced a behavioural message, never "not found" — so the F6 class of problem does not recur in the new tests. One residual: see N10.

Browser check with hostile region, observer and node names. Done, on a local Go server with the CI-prepared fixture. [T] The PR's own test-xss-333-pages-e2e.js is 11/0 on the merged tree and 1 passed / 10 failed against master's public/ on the same fixture (14 injected <img>, 42 handler calls and 1 alert dialog across the steps) — that is F1/F2/F3 plus N1 demonstrated as live injection before the fix. [T] test-xss-333-e2e.js 8/0. [T] My own additional probe injected a hostile node name and a hostile observer name (not just region codes) into every /api/** response and loaded ten routes — #/nodes, #/packets, #/observers, #/analytics (overview, topology, rf, clock-health), #/live, #/home, #/channels: 0 injected <img>, 0 handler-carrying elements, 0 fired, 0 dialogs on all ten, on head and on master (node-name sinks were already escaped). The one place where head and master differ in that probe is the favorites dropdown: head shows the hostile name as text with 0 injected elements, master shows 1 injected <img> and 1 fired handler — which independently confirms the app.js favorites fix in a browser.

Navigation, favorites and Unicode. All intact. [T] Favorites: starring a node writes the exact pubkey to meshcore-favorites, the dropdown lists 2 items, and the hostile name renders as text. [T] Navigation: observers rows (quote id and plain id) navigate, the narrow-viewport row opens the slide-over with the hash unchanged, repeater rows navigate by click and by Enter, region-filter pills still filter. [T] Unicode: Æbleø & Mesh 🛰 café <ok> renders verbatim on #/nodes, #/observers and #/analytics?tab=topology with no double escaping; the PR's E2E asserts the same for &, Danish letters and emoji on the unknown-route, observer-region and favorites paths.

Checklist

Item Result
Every acceptance criterion has a red-before / green-after test Yes [T]. AC#1 (listed and adjacent sites): N1/N2/N3 each have a Section K test that is red under my mutant, N1 additionally has an E2E step red with a live injection, and my own sweep found no further unescaped site. AC#2 (quotes, angle brackets, ampersands, normal names): covered in §I/§J/§K and in both E2Es. AC#3 (unknown routes readable, no markup): test-xss-333-e2e.js steps 1–2.
At least two of my own mutants 4 run [T], table below
No behaviour change beyond the purpose Three, all intended or minor: the repeater row now navigates by delegation (a fix — it also gains keyboard activation), the N5 colour token, and the role="row" of N6, which I would drop
cmd/server read-only, no new map[string]interface{} outside tests No .go file in the diff at all; grep over added lines → 0 map[string]interface{} [T]
No hardcoded colours No hex, rgb() or hsl() added anywhere in the diff (the only #NNN matches are issue references) [T]
scripts/check-xss-sinks.sh --diff origin/master Exit 0 on the merged tree [T] (run inside a real git clone — in a plain git archive directory the script reports "no changes to scan" and still exits 0, so that form proves nothing)
Fork guards 9 in deploy.yml, 1 in release-fast-path.yml — identical counts to master [T]
Closing keywords None in any of the 10 commit messages or in the body ("Relates to #333"); closingIssuesReferences is empty [T]
Commit author / committer dborup <kontakt@meshview.dk> on all 10 commits, author and committer [T]
Head stable git ls-remote returned 8474b494 before and after the review [T]
Scope 23 files: 6 public/*.js production files (16 changed production lines), 2 new E2Es wired into deploy.yml (2 lines), the gate script, 8 gate fixtures, 3 test files. No scope creep [T]

Tests and mutants

Everything ran on the merged tree against current origin/master 5b9cef8e. git merge-tree --write-tree origin/master 8474b494 → 8f18dfe5; an independent clone merged origin/master into the head commit and produced the same tree hash 8f18dfe5, no conflicts.

  • [T] Go: cmd/server go test ./... → ok 39.3s; cmd/ingestor go test ./... → ok 106.0s. No Go changes in the PR.
  • [T] sh test-all.sh → 235 passed, 0 failed (235 files), exit 0, first run, no retries. Its registry tests confirm both new E2Es are wired into deploy.yml.
  • [T] node test-frontend-helpers.js → 712 / 0. node test-xss-escape-sinks.js → 56 / 0. node test-preflight-xss-gate.js → all assertions pass.
  • [T] E2E against a local Go server with e2e-fixture.db prepared exactly as in CI (freshen, the #1486/#1791 seed SQL, corescope-migrate, seeds 2073 / 199 / 245), on a high port I verified free first: test-xss-333-pages-e2e 11/0, test-xss-333-e2e 8/0, test-slideover-1056-e2e 27/0, test-issue-1639-observers-sort-e2e pass, test-issue-236-analytics-sorting-e2e pass, test-issue-1136-live-region-e2e 5/0, test-a11y-axe-1668 zero violations across 120 cells, test-observer-iata-1188-e2e 3/3.
  • [T] Red-before baseline: a second server on a separate port with a separate DB copy, serving origin/master's public/ against the same prepared fixture. test-xss-333-pages-e2e → 1 passed, 10 failed, with 14 injected <img>, 42 handler calls and 1 alert dialog across the steps, including the N1 nodes-panel injection.
  • [T] test-observer-iata-1188-e2e failed its 375 px step once, on the very first run against a freshly started server (Element is not attached to the DOM), then passed 3/3 on the merged tree and 3/3 on master's public/. Flaky under load at startup, not a regression and not head-specific — this supersedes my round-2 note that it fails on master too.
  • [K] [T] CI run 38322081004, head 8474b494, read per job: Go Build & Test ✅ 23m21s, Playwright E2E ✅ 28m31s, Docker ✅ 52s, Release / Deploy / Badges skipped by the fork guard. I grepped both job logs: the only ✗ line is a unit test asserting a glyph literally equals ✗, and the only [FAIL] is the a11y self-test's own expected output (PASS: a11y-axe-1668 selftest follows it); no N passed, M failed with a non-zero M anywhere. The Playwright log shows test-xss-333-pages-e2e.js at 11 passed, 0 failed with the new N1 step named, and test-xss-333-e2e.js at 8 passed, 0 failed. Neither known flake (flaky: #226 Hash Stats adopters sort E2E — server sorts multiByteNodes on packets only, ties come out in random order #256 Hash Stats sort — its step passes in this run; flaky: TestResolvedPathBackfill_WriteHoldUnderBudget_188 — wall-clock write-hold budget fails under CI load #267 backfill write-hold) appears.

Mutants [T] — U = test-xss-escape-sinks.js, P = test-xss-333-pages-e2e.js and my own probes against my local server. Each was applied to the merged tree, the suites were run, then the file was restored and byte-compared against the head blob.

ID Mutation U Browser
M1 N1: regions.map(escapeHtml).join → regions.join (nodes.js:2005) red 55/1, behavioural red — P 10/1, 1 injected <img>, 2 handler calls
M2 N2: regions.map(escapeHtml).join → regions.join (live.js:2727) red 55/1, behavioural red — my Live-map probe: 1 injected <img>, onerror fired
M3 N3: re-add onclick="location.hash='#/nodes/${encodeURIComponent(r.pubkey)}'" red 55/1 red — injected alert(333) runs, hash becomes #NaN; Enter stops navigating
M4 N3's delegated selector narrowed back to tr[data-action="navigate"] green 56/0 not caught — P 11/0, helpers 712/0, but click and Enter navigation on the repeater row are dead (this is N7)

Not verified

  • The geo-filter prune preview and the RF-health grid were not driven in a browser by me: the fixture has no noise-floor data and I did not walk the customizer geo-filter flow. Both rest on the PR's unit tests (which do run the real customize-v2.js / analytics.js esc()) plus code reading here. The author reports a Chromium run of the prune preview [K].
  • I did not re-run the gate mutant from round 2 (strip() reverted to the master version, 8 assertions red) — scripts/check-xss-sinks.sh is unchanged since a0315131, so that evidence still stands [K] from my own round-2 run.
  • I did not run test-e2e-playwright.js, any coverage or instrumented run, or the full E2E list from deploy.yml — only the suites named above. The rest of the Playwright job is [K] from CI.
  • I did not assess exploitability against any real broker ACL; the trust analysis for observer ids and IATA topic segments is [A] from reading the regions handler and the ingestor.
  • N8's reachability is only partially settled: I proved the perf.js sinks render injected markup and that SlowQuery.Path is the raw request path, but I did not construct a request that is both routed under /api/ and slower than 100 ms while carrying markup, so I cannot say whether the path is reachable in practice.
  • No payload left this machine. Everything ran against local servers on free high ports, and every hostile value was injected by rewriting API responses in the browser, so the fixture DB was never written with a payload.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants