web: Confirmation tokens in /confirm/{token} URLs are recorded in TraceLayer spans #95

Closed
opened 2026-07-04 17:50:35 +00:00 by rosa · 1 comment
Owner

GET /confirm/{token} carries a live confirmation token (including password-reset tokens) in the URL path, and the middleware stack's TraceLayer::new_for_http() (crates/web/src/routes.rs:56) records the full request URI in its span with the default make_span.

Path<SecretString> in get_confirm protects the extractor's Debug output, but not the trace span's uri field — so anyone with log access holds working reset links until they expire.

Suggested fix: give TraceLayer a custom make_span_with that 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.

`GET /confirm/{token}` carries a live confirmation token (including password-reset tokens) in the URL path, and the middleware stack's `TraceLayer::new_for_http()` (`crates/web/src/routes.rs:56`) records the full request URI in its span with the default `make_span`. `Path<SecretString>` in `get_confirm` protects the extractor's Debug output, but not the trace span's `uri` field — so anyone with log access holds working reset links until they expire. **Suggested fix:** give `TraceLayer` a custom `make_span_with` that 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.
Author
Owner

This was generated by AI during triage.

Agent Brief

Problem

GET /confirm/{token} carries a live confirmation token in the URL path. The token is the capability itself — ConfirmationToken's Display impl (crates/domain/src/models/user.rs:374) encodes the real SplitToken (id + verifier), not a redacted form. Both flows that build these URLs are affected:

  • email confirmation — crates/domain/src/services.rs:170
  • password resetcrates/domain/src/services.rs:319

TraceLayer::new_for_http() (crates/web/src/routes.rs:56) uses tower-http's DefaultMakeSpan, which records uri = %request.uri() — the full path, token included — as a span field. Path<SecretString> in get_confirm only guards the extractor's Debug; it does nothing for the span's uri.

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 is DEBUG, gated by the VERNIER_LOG EnvFilter. At an info prod 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() at crates/web/src/routes.rs:56 with a TraceLayer whose make_span_with redacts the token before it reaches the uri span 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: TraceLayer is the outermost layer in this ServiceBuilder stack, so axum's MatchedPath extension is not populated at make_span time (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 reading MatchedPath. 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

  • A request to GET /confirm/<token> produces a span whose uri field does not contain the token (shows a redacted placeholder instead).
  • Method, HTTP version, and non-/confirm request URIs are unchanged in spans.
  • Both the fmt layer output and the OTLP-exported span carry the redacted value (they share the same span, so redacting at make_span covers both).
  • Existing behavior of get_confirm (email confirmation + password reset) is unchanged.
  • format, clippy, and ci mise 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 uri field is redacted — e.g. capture spans with a test tracing subscriber, or assert on the make_span output for a /confirm/<token> URI.

> *This was generated by AI during triage.* ## Agent Brief ### Problem `GET /confirm/{token}` carries a **live** confirmation token in the URL path. The token is the capability itself — `ConfirmationToken`'s `Display` impl (`crates/domain/src/models/user.rs:374`) encodes the real `SplitToken` (id + verifier), not a redacted form. Both flows that build these URLs are affected: - email confirmation — `crates/domain/src/services.rs:170` - **password reset** — `crates/domain/src/services.rs:319` `TraceLayer::new_for_http()` (`crates/web/src/routes.rs:56`) uses tower-http's `DefaultMakeSpan`, which records `uri = %request.uri()` — the full path, token included — as a span field. `Path<SecretString>` in `get_confirm` only guards the extractor's `Debug`; it does nothing for the span's `uri`. 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 is `DEBUG`, gated by the `VERNIER_LOG` `EnvFilter`. At an `info` prod 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()` at `crates/web/src/routes.rs:56` with a `TraceLayer` whose `make_span_with` redacts the token before it reaches the `uri` span 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:** `TraceLayer` is the *outermost* layer in this `ServiceBuilder` stack, so axum's `MatchedPath` extension is **not** populated at `make_span` time (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 reading `MatchedPath`. 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 - A request to `GET /confirm/<token>` produces a span whose `uri` field does not contain the token (shows a redacted placeholder instead). - Method, HTTP version, and non-`/confirm` request URIs are unchanged in spans. - Both the fmt layer output and the OTLP-exported span carry the redacted value (they share the same span, so redacting at `make_span` covers both). - Existing behavior of `get_confirm` (email confirmation + password reset) is unchanged. - `format`, `clippy`, and `ci` mise 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 `uri` field is redacted — e.g. capture spans with a test tracing subscriber, or assert on the `make_span` output for a `/confirm/<token>` URI.
rosa closed this issue 2026-07-04 19:05:52 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rosa/vernier#95
No description provided.