web: Confirmation tokens in /confirm/{token} URLs are recorded in TraceLayer spans #95
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
rosa/vernier#95
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
GET /confirm/{token}carries a live confirmation token (including password-reset tokens) in the URL path, and the middleware stack'sTraceLayer::new_for_http()(crates/web/src/routes.rs:56) records the full request URI in its span with the defaultmake_span.Path<SecretString>inget_confirmprotects the extractor's Debug output, but not the trace span'surifield — so anyone with log access holds working reset links until they expire.Suggested fix: give
TraceLayera custommake_span_withthat drops or redacts the path for/confirm/*(or logs only the route pattern rather than the concrete path). Storing only hashed tokens server-side mitigates DB exposure but does not help with log exposure, since the logged value is the live capability itself.Agent Brief
Problem
GET /confirm/{token}carries a live confirmation token in the URL path. The token is the capability itself —ConfirmationToken'sDisplayimpl (crates/domain/src/models/user.rs:374) encodes the realSplitToken(id + verifier), not a redacted form. Both flows that build these URLs are affected:crates/domain/src/services.rs:170crates/domain/src/services.rs:319TraceLayer::new_for_http()(crates/web/src/routes.rs:56) uses tower-http'sDefaultMakeSpan, which recordsuri = %request.uri()— the full path, token included — as a span field.Path<SecretString>inget_confirmonly guards the extractor'sDebug; it does nothing for the span'suri.Impact: whenever HTTP tracing is enabled at the span's level, live reset/confirmation links land in the logs and are exported to the OTLP backend (the subscriber wires
tracing_opentelemetry::layer()alongside the fmt layer —crates/server/src/main.rs:202-206). Anyone with log/trace access holds working reset links until they expire.Note on exposure:
DefaultMakeSpan's default level isDEBUG, gated by theVERNIER_LOGEnvFilter. At aninfoprod filter the span isn't created — but the leak is live the moment anyone raises the level to debug HTTP for troubleshooting. This is a real fix, not theoretical.Fix
Replace
TraceLayer::new_for_http()atcrates/web/src/routes.rs:56with aTraceLayerwhosemake_span_withredacts the token before it reaches theurispan field. Redact the path segment following/confirm/(e.g. render the URI as/confirm/[REDACTED]), preserving method, version, and the rest of the path so traces stay useful.Critical implementation gotcha:
TraceLayeris the outermost layer in thisServiceBuilderstack, so axum'sMatchedPathextension is not populated atmake_spantime (routing happens further in). The closure will only have the raw request URI — do the redaction by prefix-matching the raw path against/confirm/, not by readingMatchedPath. The "log the route pattern instead" idea won't work at this layer for that reason.Keep the scope narrow to the
/confirm/prefix for this fix. (A general sensitive-path-param redaction pass is a possible follow-up but is not required here and shouldn't be bundled in.)Acceptance criteria
GET /confirm/<token>produces a span whoseurifield does not contain the token (shows a redacted placeholder instead)./confirmrequest URIs are unchanged in spans.make_spancovers both).get_confirm(email confirmation + password reset) is unchanged.format,clippy, andcimise tasks pass.Suggested verification
Add a test (or manual check) that drives a request through the router with a known token and asserts the recorded span's
urifield is redacted — e.g. capture spans with a test tracing subscriber, or assert on themake_spanoutput for a/confirm/<token>URI.