diff --git a/cmd/server/perf_slowpath_392_test.go b/cmd/server/perf_slowpath_392_test.go index 7fc1d0087..b06278c9d 100644 --- a/cmd/server/perf_slowpath_392_test.go +++ b/cmd/server/perf_slowpath_392_test.go @@ -105,4 +105,17 @@ func TestNormalizeSlowPath(t *testing.T) { t.Fatalf("control chars survived in the key half: %q", got) } }) + + // #393 N4: the strip covers DEL (0x7f) as well as C0 (< 0x20); only the + // C0 half was pinned. Neighbouring '~' (0x7e) must survive. Mutant: drop + // `|| r == 0x7f` → red. + t.Run("DEL is stripped from both halves, '~' is kept", func(t *testing.T) { + if got := sanitizeSlowPathComponent("a\x7fb~"); got != "ab~" { + t.Fatalf("sanitizeSlowPathComponent: got %q, want %q", got, "ab~") + } + got := normalizeSlowPath("/api/\x7f:id", "/api/x\x7fy") + if got != "/api/:id /api/xy" { + t.Fatalf("normalizeSlowPath: got %q, want %q", got, "/api/:id /api/xy") + } + }) } diff --git a/docs/api-spec.md b/docs/api-spec.md index 53f08f2a5..d8a62ec3e 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -315,8 +315,9 @@ Server health and telemetry. Used by monitoring. "recentSlow": [ // last 5 { // Same SlowQuery shape as GET /api/perf → slowQueries[]: normalized - // route key + a sanitised, control-char-stripped, ≤128-rune-per-half - // copy of the raw request path, joined by a space (e.g. "/api/nodes/:id + // route key + a sanitised, control-char-stripped copy of the raw + // request path (each half ≤128 content runes plus an "…" truncation + // marker), joined by a space (e.g. "/api/nodes/:id // /api/nodes/abcd1234"); unmatched routes emit the capped raw path // once. Not HTML-escaped — clients MUST escape before DOM insertion // (#392, #394 F3/F4/F6). @@ -358,8 +359,9 @@ Detailed performance metrics per endpoint. // Normalized route key + a sanitised copy of the raw request path, // joined by a single space, e.g. "/api/nodes/:id /api/nodes/abcd1234". // BOTH halves are independently control-char-stripped (incl. CR/LF/TAB) - // and capped to ≤128 runes each (an "…" marks truncation), so the whole - // value is bounded at ≤257 runes regardless of input (#392, #394 F3/F4). + // and capped to ≤128 content runes each (an "…" marks truncation), so + // the whole value is bounded at ≤259 runes (2×128 content runes + two + // "…" markers + the join space) regardless of input (#392, #394 F3/F4). // Unmatched routes (key == raw) emit the capped raw path once. Not // HTML-escaped — clients MUST escape before inserting into the DOM. "path": string, diff --git a/public/analytics.js b/public/analytics.js index 9abe5751d..9f6cab8b6 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1102,7 +1102,7 @@ let html = '
Error: \$\{[^}]*err\.message[^}]*\}<\/p>)`;/, + { err: { message: PERF_PAYLOAD } } + ); + assertNoXss(html, '#393 N6 perf.js err.message'); +}); + +// ========================================================================= +// #393 follow-ups to #373 — analytics a11y (N6), delegated-nav pin (N7), +// compare.js onclick removal (N9), loadLocalEsc guard robustness (N10). +// ========================================================================= +console.log('\n=== #393 follow-ups to #373 ==='); + +// Minimal element whose closest(sel) honours an optional tag-name prefix, so +// a DIV row matches `[data-action="navigate"]` but NOT `tr[...]` and a TR row +// matches both — exactly the distinction the delegated selectors turn on. +function fakeNavRow(tag, value) { + const el = { tagName: tag.toUpperCase(), dataset: { action: 'navigate', value } }; + el.closest = (sel) => { + const mm = sel.match(/^([a-z]*)\[data-action="navigate"\]$/i); + if (!mm) return null; + if (mm[1] && mm[1].toUpperCase() !== el.tagName) return null; + return el.dataset.action === 'navigate' ? el : null; + }; + return el; +} + +// N6: the analytics repeater row is a