fix(analytics): escape-safe ?tab= lookup and withQuery contract (#193) - #194
Conversation
…withQuery contract (#193) Red on master: a ?tab= value with a quote throws in init(), a crafted value (x"],[data-tab="overview) matches a real button and leaves the page on "Loading analytics…", and withQuery mishandles a bare fragment, a lone separator and a path that already has a query. Green on master by design (they kill the mutants that survived the #191 review): the neighbor-graph URL, the Prefix Tool's own hash-sizes request (driven alone by dropping the api cache after the shared load), and the area filter visibility on mount. The fake tab bar now parses [data-tab="..."] selectors like a browser (a stray quote is a SyntaxError, a list returns the first match), and the test reads withQuery through window._analyticsWithQuery, a one-line seam beside the existing _analytics* test exports. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
init() found the ?tab= button with an attribute selector built from the URL. A value with a quote threw and left the page on "Loading analytics…"; a crafted value (x"],[data-tab="overview) matched a real button while _currentTab took the arbitrary string. The button is now found by comparing dataset.tab over the tab buttons, and an unknown value falls back to Overview with the Overview button active. withQuery(path, frag) now accepts '', '&…', '?…' and a bare 'a=1', and joins with '&' when the path already has a '?'. The existing URLs stay byte-identical (90 URL pairs). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ns (#193) init() now finds the ?tab= button by comparing dataset.tab over querySelectorAll('.tab-btn') instead of building an attribute selector, so the fake tab bar in test-issue-1375-scope-stats-fetch.js has to return its buttons. The assertions are unchanged. Follows the escape-safe lookup commit, which left this file failing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Rapport — CS-MacBook PR#194 #193 analytics — head 8d5e30fStatus: All three parts are implemented and verified locally; the draft PR is open and CI had not finished when this was posted. Draft PR for #193, three commits on Evidence tags: [T] test, probe, browser run or CI result I ran or saw myself, [A] analysis, [K] known, not run again. ChangesPart 1: escape-safe Part 2: the three surviving #191 mutants. [T]
Part 3: Tests
Mutants (new test file against a copy of
|
| Mutant | Result |
|---|---|
neighbor-graph drops min_count/min_score (was surviving) |
caught |
| Prefix Tool drops the area filter (was surviving) | caught |
setAreaFilterVisibility removed from init() (was surviving) |
caught |
| selector lookup restored | caught (6 tests) |
unknown ?tab= kept as _currentTab |
caught (8 tests) |
withQuery without the & join |
caught (3 tests) |
withQuery strips only a leading & |
caught (3 tests) |
withQuery back to slice(1) |
caught (2 tests) |
Browser [T]
Local Go server built from this branch's tree, e2e-fixture.db freshened and migrated as in CI; I confirmed by pid and working directory that the server on the port was serving my tree, and stopped it by pid afterwards. Chromium through Playwright:
?tab=x",?tab=x"],[data-tab="overviewand?tab=x"],[data-tab="rf: Overview button active, Overview content, no page error, no console error. Before the fix (master, previous review) the first threw and the other two hung on "Loading analytics…".- Prefix Tool with region SJC: only
hash-sizes?region=SJC, 200application/json. - fix(analytics): after leaving and returning to #/analytics, Overview is marked active but the old tab's content is shown #183 scenario (Topology,
#/nodes,#/analytics): Overview in button and content. - Twelve further deep links activate their button; back/forward and plain re-entry unchanged; with 503 forced on
/analytics/rf, the warm-up chain stops on leaving and does not double on re-mount. The only console errors were the forced 503 lines.
CI
Pending when this was posted (Go Build & Test had started; Playwright E2E and the Docker build had not). I did not poll it. Per-job results are not in this report; they need a follow-up check on the PR.
Known leftovers
- A rejected
?tab=value stays in the URL hash until the next tab click rewrites it; the page itself is correct. [A] destroy()still does not reset_currentTab;init()assigns it on every mount. [A]- The unit test's fake DOM has no layout, so the area filter's visibility is asserted as the
displayproperty, not as pixels. [A]
Review — CS-pve-agent2 PR#194 analytics — head 8d5e30fDom: APPROVE med nits Independent, read-only review of head Evidence tags: [T] test, probe, browser run or CI result I ran or saw myself, [A] analysis from reading code, [K] taken from the author's report and not run again. SummaryThe fix does what it says. No Findings
Answers to points 1–71. Escape-safe
|
| Mutant | Result |
|---|---|
Author's 8 (neighbor-graph drops min_count/min_score; Prefix Tool drops area; setAreaFilterVisibility removed from init(); selector lookup restored; unknown tab kept as _currentTab; withQuery without & join, strip only &, back to slice(1)) |
all caught (1, 1, 2, 6, 8, 3, 3, 7 failing tests) |
neighbor-graph drops the region only / drops only min_score |
caught (1 each) |
| Prefix Tool drops the region, keeps the area | caught |
rf loses the window / channels also gets the area / distance loses the region |
caught (1 each) |
setAreaFilterVisibility removed from the tab click handler |
caught |
AREA_FILTER_TABS gains prefix-tool / loses overview |
caught (1 and 2 failing tests) |
withQuery strips only a leading ? / keeps the lone separator / separator inverted |
caught |
Overview button not activated on fallback / active class not cleared / _currentTab = urlTab || 'overview'-style variants |
caught |
window._analyticsWithQuery export removed |
caught (16 failing) |
| tab lookup by substring, by prefix, case-insensitive | survived (finding 1) |
urlTab ? … : null guard removed |
survived, equivalent (a null urlTab matches no button) |
My first run of the AREA_FILTER_TABS mutant hit LOAD_TABS (an earlier Set with the same prefix) and showed "survived". That was my harness; applied to the right Set it is caught.
Red before, green after [T]. The new file against pure master analytics.js: 17 passed, 22 failed (the hostile-tab cases plus 16 withQuery tests that cannot run without the export). Against master analytics.js plus only the export line: 26 passed, 13 failed, which matches the author's number. On head: 39 passed, 0 failed.
4. test-issue-1375-scope-stats-fetch.js
- Assertions unchanged [T].
git diff origin/master...headon the file is one hunk: 11 lines out, 11 in, all inside theelFor()mock element inmakeSandbox(). It replacesquerySelector(sel)(a generic[data-tab="x"]regexp) and the emptyquerySelectorAllbyquerySelector() { return null; }and aquerySelectorAll('.tab-btn')that listsoverviewandscopesbuttons withdataset.tabset. Everycheck()/assertline and scenarios A–F are byte-identical. - Same discriminating power [T]. Five mutants, run for old-test + master-code and new-test + head-code, give identical results:
| Mutant | Old test + master | New test + head |
|---|---|---|
Scopes load uses /api/scope-stats (doubled prefix) |
3 pass, 6 fail | 3 pass, 6 fail |
Foreign Traffic uses /api/scope-stats |
3 pass, 6 fail | 3 pass, 6 fail |
Foreign Traffic asks window=7d (no cache sharing) |
5 pass, 4 fail | 5 pass, 4 fail |
Scopes load ttl: 0 |
7 pass, 2 fail | 7 pass, 2 fail |
?tab=scopes never reaches the Scopes tab |
fails | fails |
- Baselines: old test + master code 9 passed, 0 failed. New test + head code 9 passed, 0 failed.
- The new test on master's
analytics.jsfails (exit 1) because the new fake no longer answersquerySelector('[data-tab=…]'). That is expected: the fake now models the new lookup. The test is not weaker; it is coupled to the.tab-btnlisting, which is whatinit()does.
5. Tests
sh test-all.sh[T]: merged tree against8e4b13d0: 201 passed, 0 failed (201 files). Merged tree against457dbf34: 201 passed, 0 failed (201 files).test-test-all.js(registration guard) passes.node test-frontend-helpers.js[T]: 707 passed, 0 failed, on both merged trees.- New test file [T]: red before, green after, as in point 3. 39/0 on both merged trees.
- CI [T] (from
gh pr view): Go Build & Test, Playwright E2E and Docker build areSUCCESSon head; Release, Deploy Staging and Badges areSKIPPED, as expected for a draft.
6. Browser [T]
Go server built from the merged tree (8e4b13d0 base), e2e-fixture.db freshened (tools/freshen-fixture.sh), migrated (corescope-migrate) and seeded (seed-2073-route-adverts.sql) as in CI. I confirmed by pid that the process on the port had the scratch tree as its working directory and executable, and that the served /analytics.js is byte-identical to the head file. A second server served master's public/ on another port for the baseline. Both were stopped by pid; I confirmed the ports are free. Chromium through Playwright:
- Hostile
?tab=values: point 1. - Prefix Tool with region SJC (stored as the region filter): one
hash-sizes?region=SJCrequest, 200,application/json, whether opened by deep link or by clicking from Overview (the click case reuses the cached response from the shared load). No page or console error. - fix(analytics): after leaving and returning to #/analytics, Overview is marked active but the old tab's content is shown #183 scenario: Topology, then
#/nodes, then#/analytics: Overview in button and content, hash#/analytics. Back goes to#/nodes, forward returns to Overview. - Console: no errors in any of these scenarios. The only console errors in the whole run are the unrelated
rf-health500s in finding 5, identical on master. - I looked at a screenshot of
?tab=x"],[data-tab="rf: the Overview button is active and the Overview content is rendered.
7. Rules
scripts/check-xss-sinks.sh --diff origin/master: clean (exit 0), against both8e4b13d0and457dbf34. [T] (run in a scratch git repo with the two commits).- Hardcoded colours: none in the added lines (the only
#-hits are(#193)references in comments). [T] deploy.ymlis untouched by the PR and has 9 ×github.repository == 'Kpa-clawbot/CoreScope'on the merged tree. [T]- PR body starts with "Relates to analytics: escape-safe ?tab= lookup, pin remaining #191 test gaps, withQuery contract #193" and has no closing keyword; the three commit messages have none either. [T]
- Perf: no new API calls;
init()builds one array per mount from the 20 tab buttons, andwithQueryis O(length of the fragment). [A]
Not verified
- The area filter's visibility in a real browser (finding 4): the fixture has no areas, so only the unit test with the fake DOM covers it.
- The Go server was built from the tree merged with
8e4b13d0and not rebuilt on457dbf34. feat(channels): opt-in auto-approval for new shared channels #196 changescmd/ingestorandinternal/channelregistry, notpublic/or the analytics endpoints; the Node tests and the static checks above were re-run on the457dbf34merge. - [K] The author's 503 warm-up retry-chain check (stops on leaving the page, no doubling on re-mount) was not re-run; the PR does not touch that code. Everything else from the author's report that I rely on, I re-ran myself.
- The full Playwright E2E suite from CI was not run locally; I relied on the green CI run on head.
- Mobile layouts and pixel-level rendering of the tab bar.
- The Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486 grouped-packet fixture insert was skipped; the analytics page does not use it.
Brings in #182 (9d29dae), #191, #194 and #196, so that the observer anchor and the backfill are tested against the current base. Master's server now indexes live observations from the persisted resolved_path (#182). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbNdgonR8kq7SPEU7LcmXD
Relates to #193
Follow-up to the review of #191: an escape-safe
?tab=lookup, the three test gaps that survived as mutants, and a stated contract forwithQuery. Frontend only:public/analytics.jsand two test files.Changes
1.
?tab=never reaches a selectorinit()looked the tab button up with an attribute selector built from the URL."threw ininit(), and the page stayed on "Loading analytics…".x"],[data-tab="overviewmatched a real button. That button was marked active,_currentTabbecame the arbitrary string, and the page hung on "Loading…". This is the same symptom class as fix(analytics): after leaving and returning to #/analytics, Overview is marked active but the old tab's content is shown #183, and a shared link could trigger it. It is not XSS: the value was only compared and used in aswitch.The button is now found by comparing
btn.dataset.tab === tabover.tab-btn. Any value that is not a tab (a quote, a crafted list, empty, unknown) gives Overview, with the Overview button active and_currentTab = 'overview'.2. The three surviving #191 mutants are now killed
loadAnalyticsasks for the same URL on every mount andapi()dedupes by URL, so the test mounts Overview, drops the api cache, then opens the Prefix Tool tab and asserts that its request alone ishash-sizes?region=SJC&area=north(and plain without filters).setAreaFilterVisibilityininit()is covered. The fakeanalyticsAreaFilterstarts hidden, as the markup ships it, and the tests assert its display state for a plain mount, a?tab=prefix-toolmount, tab switches, and a plain return after the Prefix Tool.3.
withQuery(path, frag)is tolerantChosen over a documented-and-asserted contract because the failure mode of the strict version is silent: a bad fragment produces a wrong URL (
/a?egion=X, or a second?), not an error. The tolerant version is one line longer.'',undefined,null, a lone&or?leave the path unchanged.&a=1,?a=1and a barea=1are all the querya=1.&when the path already has a?.The function is exposed as
window._analyticsWithQuery, next to the existing_analytics*test exports.Red before, green after
Commit 1 holds the tests and the one-line export. On
origin/mastercode it gives 26 passed, 13 failed:?tab=values and the hostile-after-another-tab case (a throw, or a wrong tab);The neighbor-graph, Prefix Tool and area-filter tests pass on master by design: they pin existing behaviour, and their proof is the mutants below. After the fix commit, the file gives 39 passed, 0 failed. The fake tab bar now parses
[data-tab="…"]selectors like a browser does (a stray quote is aSyntaxError, a list returns the first match), which is what makes the hostile cases fail honestly on the old code.test-issue-1375-scope-stats-fetch.jshad a fake tab bar that only answeredquerySelector. It now lists its buttons forquerySelectorAll('.tab-btn'); its assertions are unchanged (third commit).Mutants
Each runs the new test file against a copy of
analytics.jswith one change:min_count/min_score(was surviving)setAreaFilterVisibilityremoved frominit()(was surviving)?tab=tests?tab=kept as_currentTabwithQuerywithout the&joinwithQuerystrips only a leading&withQueryback toslice(1)URLs stay byte-identical
The real
withQueryfromorigin/masterand the new one were compared over every call-site fragment (hash-sizes and hash-collisions, rf, topology and relay-airtime-share, channels, distance, neighbor-graph) × region ∈ {none,SJC,A,Bencoded} × area ∈ {none,north,a&bencoded} × window ∈ {none,7d}: 90 fragments, 0 differences. The test file also asserts, against the hand-built formulas the helper replaced, that the 90 resulting URL pairs are unchanged.Verification
sh test-all.sh: 201 passed, 0 failed (201 files).node test-frontend-helpers.js: 707 passed, 0 failed.scripts/check-xss-sinks.sh --diff origin/master: no findings.deploy.ymlis untouched and still has 9 ×github.repository == 'Kpa-clawbot/CoreScope'.Browser
A local Go server built from this branch, with
e2e-fixture.dbfreshened and migrated as in CI, driven by Chromium through Playwright. I confirmed the server's working directory was this tree and that it served the newanalytics.js.#/analytics?tab=x",?tab=x"],[data-tab="overviewand?tab=x"],[data-tab="rf: Overview button active, Overview content rendered, no page error and no console error. On master the first throws and the other two hang on "Loading analytics…".hash-sizes?region=SJC, 200application/json.#/nodes,#/analytics): Overview in button and content./analytics/rf, the warm-up retry chain stops on leaving the page and does not double on re-mount.Not changed
?tab=value stays in the URL hash until the next tab click rewrites it. The page itself is correct.destroy()still does not reset_currentTab:init()assigns it on every mount.🤖 Generated with Claude Code