Skip to content

fix(ui): encode trace/span ids in cross-signal deep-links (JEF-534)#143

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-534-encode-tracespan-ids-in-ui-links-with-encodeuricomponent
Jul 27, 2026
Merged

fix(ui): encode trace/span ids in cross-signal deep-links (JEF-534)#143
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-534-encode-tracespan-ids-in-ui-links-with-encodeuricomponent

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Closes JEF-534.

Problem

trace_id/span_id come from ingested OTLP data — attacker-shapeable — but
several UI components interpolated them raw into react-router
to=/navigate() targets, while the neighboring service was consistently
encodeURIComponent-wrapped:

  • TraceWaterfall.tsx — the span-detail "logs for this span" link
    (trace_id+span_id) and the header "logs" link (trace_id).
  • LogView.tsx — the trace/span drill-in links (traceLink,
    spanInTraceLink) and the trace-cell link.
  • MetricChart.tsx — the exemplar-click navigation target.
  • TraceList's row link, wired up in App.tsx.

react-router keeps these same-origin/client-side, so no XSS, no
open-redirect
— worst case an id containing ?/#/& lands on an
unexpected in-app route or injects a stray query param. Pure hardening /
consistency, not an active exploit.

Fix

Extracted the fix into one shared ui/src/links.ts:

  • traceHref(traceId, { spanId?, service? }) builds /traces/:id
    (encodeURIComponent-ed path segment, optional span/service via
    URLSearchParams).
  • logsHref(traceId, spanId?) builds /logs the same way.

All five call sites now import from there instead of each re-deriving its
own raw-interpolated string — this also collapsed three near-identical
/traces/:id?service= builders the first pass of this fix had introduced
per-file (caught in the /simplify pass, see below).

Sites fixed (grep-confirmed against rg -n "trace_id=\$\{|span_id=\$\{|/traces/\$\{|/logs\?" ui/src plus a manual sweep for to(t.trace_id)-style indirection that grep missed):

  • ui/src/components/TraceWaterfall.tsx (2 sites)
  • ui/src/components/LogView.tsx (3 sites)
  • ui/src/components/MetricChart.tsx (1 site)
  • ui/src/App.tsx (1 site — TraceList's row link, found by tracing the to prop, not by the literal grep pattern)

Testing

ui/src/links.test.ts pins the well-formed-hex-id shape byte-identical to
the pre-fix output (no behavior change for the common case) and locks
that a &/# in an id is encodeURIComponent-encoded rather than landing
as a stray query param or truncating the route.

  • npm run build (tsc --noEmit && vite build) — clean
  • npx vitest run — clean, 6 test files / 33 tests
  • npm run lint (eslint) — clean

Decisions

  • DECISION: ui/src/api.ts's getTrace(traceId) builds a backend fetch
    URL (/api/traces/${traceId}), not a react-router link — different threat
    model (a malformed id there just 404s against the actual HTTP request, not
    a client-side route). Left out of scope per the ticket's framing ("UI
    links"); flagging in case a follow-up wants the same treatment for
    API-fetch paths.
  • Consolidated four near-duplicate per-file href builders (each introduced by
    this diff) into one ui/src/links.ts module during the /simplify pass —
    reduces the diff's own footprint rather than leaving four copies of the
    same logic.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo

trace_id/span_id are ingested OTLP data — attacker-shapeable — but several
components interpolated them raw into react-router `to=`/`navigate()`
targets while the neighboring `service` was already `encodeURIComponent`-ed
(TraceWaterfall's span-detail "logs for this span" link and its header
"logs" link, LogView's trace/span drill-in links, MetricChart's exemplar
click, and TraceList's row link via App.tsx). react-router keeps these
same-origin/client-side, so no XSS and no open-redirect — worst case an id
containing `?`/`#`/`&` lands on an unexpected in-app route or injects a
stray query param. Pure hardening for consistency, not an active exploit.

Extracted the fix into one shared `ui/src/links.ts`: `traceHref` builds
`/traces/:id` (path segment `encodeURIComponent`-ed, optional span/service
via URLSearchParams) and `logsHref` builds `/logs` the same way. All five
call sites now import from there instead of each re-deriving its own
raw-interpolated string, which also collapses three near-identical
`/traces/:id?service=` builders the first pass of this fix had introduced
per-file.

Test: ui/src/links.test.ts pins the well-formed-hex-id shape byte-identical
to the pre-fix output (no behavior change for the common case) and locks
that a `&`/`#` in an id is encoded rather than landing as a stray param or
route. `npm run build` (tsc + vite) and `npx vitest run` both clean (6 test
files, 33 tests); `npm run lint` clean.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@thejefflarson
thejefflarson merged commit 8ce890f into main Jul 27, 2026
4 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-534-encode-tracespan-ids-in-ui-links-with-encodeuricomponent branch July 27, 2026 01:57
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.

1 participant