fix(telemetry): stop logging L1 provider credentials - #4783
Conversation
Operator L1 RPC API keys, embedded in the provider URL path, were shipped
in plaintext to the telemetry S3 bucket. Two forms leaked, sharing no
common substring:
- `reqwest::Error` formats the full request URL into its own `Debug` and
`Display`, so any rendering of an L1 transport error emitted it. `{err:#}`
is not a safe alternative to `{err:?}` here; both print the URL.
- `url::Url`'s own `Debug` is field-wise (`path: "/v2/KEY"`) and contains
no `://` at all, so it is invisible to URL-shaped text matching. It
reached logs via the startup struct dump of `HotShotInitializer`.
Fixes:
- `espresso_utils::redact` provides `scrub` for rendered text, handling
both forms, and `redact_url` for a `Url` in hand.
- `RedactingLogExporter` wraps the OTLP `LogExporter` and scrubs every
record body and attribute. One boundary rather than one call site per
log line, so a new log line cannot reintroduce the leak.
- `#[debug(...)]` on the `L1ClientOptions`, `SwitchingTransport` and
`SingleTransport` URL fields keeps the field-wise form out of any dump.
`SingleTransport` now carries the redacted URL, so the "L1 client error"
warning names the provider that failed.
- `Redacted` at the ten L1-backed handlers in the node API, whose error
bodies are served to unauthenticated callers and never pass the exporter.
Operator-local stdout is deliberately left unredacted: it is the
operator's own credential on their own host, and every Rust program
logging a `reqwest` error behaves the same way.
The redaction boundary is fail-closed. `ends_url_token` may only contain
characters `Url::as_str()` percent-encodes in every component; anything
else is legal in a path and would end the token early, leaking the tail.
|
Claude finished @sveitser's task in 8m 33s —— View job Review:
|
| Where | Item |
|---|---|
redact.rs:121-124 |
Redacted uses scrub_urls, not scrub — so the one boundary with no exporter behind it, serving bodies to unauthenticated callers, is closed against only one of the two forms. Intentional? |
redact_exporter.rs:179-187 |
passes_through_record_without_url_unchanged leaves all fields but body and one attribute unset, so the assert_eq! can't catch scrub_record dropping a field. Nine fields are hand-copied from a dependency-owned type; an SDK upgrade adding a tenth would silently lose it on every record. |
redact.rs:13-18 |
ends_url_token's comment misattributes `, {, } to the path (they're encoded there; they're legal in query/fragment). Same imprecision underlies the scrub_url_debug bug. Set itself is correct. |
redact_exporter.rs:121-127 |
"Url {" matches as a substring, so a future BaseUrl/ApiUrl/ParsedUrl would get its body collapsed. Over-redaction only, no leak. Same comment notes the unconditional per-record rebuild. |
utils/Cargo.toml:50 |
serde_json dev-dependency is unreferenced in the crate. |
redact.rs:65,87 |
redact_url / redact_urls are public with no doc comment, unlike the rest of the module. |
Things I checked that are fine
- Other
Url-holding structs that getDebug-dumped.Options.l1_provider_urlinnode/src/options.rs:306is already#[derivative(Debug = "ignore")];L1ClientOptionshas no URL field other thanl1_ws_provider.SingleTransport.client: Http<Client>(which holds the URL) is correctly#[debug(skip)]. - Other exporters. Only the log exporter carries free-text; metrics go out via
remote_writeandL1ClientMetricslabels are index-based, not URL-based. TheResourceforwarded throughset_resourceis justSERVICE_NAME+ instance id, so leaving it unscrubbed is fine. - The remaining unredacted
{err:#}sites inendpoints.rs(lines 730-778, 903-1076, 1123) are storage-, catchup- and config-backed, not L1-backed. The "ten handlers" scope is accurate. scrub_urlstermination and slicing.posstrictly increases each iteration (no infinite loop), all slice indices land on char boundaries, and the://, empty-input, and authority-runs-to-EOF edge cases behave as the tests assert.- cfg gating.
espresso-utilsis a non-optional dependency ofespresso-typesandredactis ungated, so the unconditionalredact_urlsimport (used by non-node-gatedL1ClientOptions) alongside thenode-gatedredact_urlis correct. derive_more::Debugfield expressions.redact_url(url)andredact_urls(urls.iter())both typecheck against the&Url/&Arc<Vec<Url>>bindings derive_more provides.
I was not able to run cargo test/clippy in this environment (cargo invocations require approval here), so the above is static review only — CI coverage for the new tests is worth confirming.
--- • branch ma/sanitize-l1-url
- `scrub_url_debug` stopped at the first `}`, but braces are legal in a
query or fragment, so `Url { .. query: Some("filter={a}&apikey=KEY") .. }`
ended the body early and emitted the rest verbatim. Track quoting instead:
`"` is percent-encoded in every component, so a `}` outside quotes is the
real terminator.
- `SingleTransport` held a raw `Url` and the L1-error warning rendered it
with `%`, i.e. `Display`, so the field attribute did not apply and the
full URL was written to stdout. Store the redacted string instead, so the
struct cannot hold the credential.
- `switch_to` logged the failover target with `%url` at INFO, leaking the
same way on every provider switch. Pre-existing, same class.
- `Redacted` scrubbed only the `://` form. It guards the node API error
bodies, which never reach the exporter, so it now runs `scrub`.
- Require a type-name boundary before `Url {` so `BaseUrl {` and friends
keep their bodies.
- Correct the `ends_url_token` rationale: `` ` ``, `{` and `}` are encoded
in a path but legal in a query or fragment. That imprecision is what made
the brace assumption above look safe.
- Populate every field in the exporter pass-through test, which previously
left them all `None` and so passed even if `scrub_record` dropped one.
Confirms `trace_flags` round-trips.
- Drop the unused `serde_json` dev-dependency from espresso-utils.
|
@claude review again and check what has been fixed |
|
Claude encountered an error after 3m 49s —— View job Re-review of
|
| # | Finding | Status | Verification |
|---|---|---|---|
| 1 | scrub_url_debug fail-closed violation (brace in query/fragment) |
Fixed | redact.rs:118-133 uses the quote toggle. Slicing is right: k is a byte offset relative to body_start, pos = body_start + k lands on the } so the trailing push re-emits it — Url { + " *** " + }. Truncated-input branch preserved. test_scrub_url_debug_brace_in_query_or_fragment covers both ?filter={a}&apikey= and #{a} and asserts exact equality with Url { *** }, not just absence of the key. |
| 2 | L1 client error logged the raw credential via % |
Fixed, stronger form | SingleTransport.url: Url → redacted_url: String (v0_1/l1.rs:309), built in SingleTransport::new (impls/l1.rs:299). The struct genuinely cannot hold the credential now — the #[debug] attribute is gone because it's unnecessary, not because it was dropped. client: Http<Client> still holds the raw URL and is still #[debug(skip)]. Grep confirms no other reader of the old field (impls/l1.rs:1070,1601 use transport.urls, a different field). |
| — | (found by the author) switch_to leaked the same way at INFO on every failover |
Fixed | impls/l1.rs:425. This one was pre-existing and I missed it — it's the same %url-on-a-raw-Url class, on a path that fires on every provider switch. Good catch. |
| 3 | Redacted used scrub_urls, not scrub |
Fixed | redact.rs:158,169, both Debug and Display. This is the boundary with no exporter behind it, so it now closes both forms. |
| 4 | Pass-through test couldn't catch a dropped field | Fixed | redact_exporter.rs:190-202 populates all nine. set_trace_context(.., Some(TraceFlags::SAMPLED)) confirms the flags round-trip through ctx.trace_flags rather than getting silently dropped, which was the specific thing I wasn't sure about. |
| 5 | "Url {" substring-matched BaseUrl { etc. |
Fixed | redact.rs:105-113 requires the preceding char to be a non-identifier (is_alphanumeric() || '_'). Start-of-string is handled — text[..0].chars().next_back() is None, so is_some_and is false and it proceeds. Nested MyUrl { inner: Url { .. } } still collapses the inner one (preceded by a space); newtype Foo(Url { .. }) still collapses (preceded by (). |
| 6 | ends_url_token comment misattributed `/{/} |
Fixed | redact.rs:13-16. |
| 7 | Unused serde_json dev-dependency |
Fixed | Dropped from Cargo.toml and Cargo.lock. |
The quote-toggle argument holds for everything this code will see: every field in url::Url's Debug is a slice of the serialization (scheme, username, password, host, path, query, fragment; port is a u16, cannot_be_a_base a bool), so the only quotes in the body are the delimiters Rust adds. Host::Domain("…") doesn't break it either. Pretty-print ({:#?}, which Redacted forwards) still matches "Url {" and terminates on the dedented closing brace.
Residual notes — none blocking
- No test for
Redactedcatching the field-wise form. The switch toscrubin Diagram plugin architecture (VM-agnostic framework interacting with a specific zkEVM application layer, including RPC, prover, and rollup contract) #3 is the fix for the exact leak class the PR opens with, buttest_redacted_scrubs_debug_and_display(redact.rs:349) still only exercises the://form. ARedacted(SomeStruct { url })case would pin the behaviour, otherwise a future revert toscrub_urlsthere passes the suite. redact_url/redact_urlsare still undocumented (redact.rs:66,88), unlike everything else in the module. Was in my smaller-items list; carried over.scrub_recordstill rebuilds every record even when nothing changes. Explicitly optional last time and it's on the batched background path — noting only so it isn't lost.- One theoretical hole left in the toggle, not reachable here. It assumes
"never survives in a rendered URL. That's true for every URL with an authority, but WHATWG percent-encodes opaque paths (scheme:opaque, no//) with a narrower set that may not include". If so, an opaque-path URL with an odd number of quotes ahead of a brace could end the body early. Every URL this codebase parses ishttp/https/ws/wss/postgres— all authority-bearing — so I'd leave it; flagging only because the module's framing is "no assumption without the every-component guarantee", and this one is scheme-conditional. I couldn't run the parser to confirm the premise.
Caveat
Same as last time: cargo invocations require approval in this environment, so this is static review only — I could not build or run the new tests. The one thing I'd want CI to confirm is TraceId::from(1u128) / SpanId::from(2u64) in the new test (redact_exporter.rs:197-198); I couldn't check whether opentelemetry 0.32 exposes those From impls versus from_bytes/from_hex. Everything else typechecks by inspection, including the #[cfg(feature = "node")] split — redact_url is now imported only in impls/l1.rs under the node gate and used only in node-gated code, while v0_1/l1.rs keeps the ungated redact_urls for L1ClientOptions.
• branch ma/sanitize-l1-url
Operator L1 RPC API keys, embedded in the provider URL path, were shipped in plaintext to the telemetry S3 bucket. Two forms leaked, sharing no common substring:
reqwest::Errorformats the full request URL into its ownDebugandDisplay, so any rendering of an L1 transport error emitted it.{err:#}is not a safe alternative to{err:?}here; both print the URL.url::Url's ownDebugis field-wise (path: "/v2/KEY") and contains no://at all, so it is invisible to URL-shaped text matching. It reached logs via the startup struct dump ofHotShotInitializer.Fixes:
espresso_utils::redactprovidesscrubfor rendered text, handling both forms, andredact_urlfor aUrlin hand.RedactingLogExporterwraps the OTLPLogExporterand scrubs every record body and attribute. One boundary rather than one call site per log line, so a new log line cannot reintroduce the leak.#[debug(...)]on theL1ClientOptions,SwitchingTransportandSingleTransportURL fields keeps the field-wise form out of any dump.SingleTransportnow carries the redacted URL, so the "L1 client error" warning names the provider that failed.Redactedat the ten L1-backed handlers in the node API, whose error bodies are served to unauthenticated callers and never pass the exporter.Operator-local stdout is deliberately left unredacted: it is the operator's own credential on their own host, and every Rust program logging a
reqwesterror behaves the same way.The redaction boundary is fail-closed.
ends_url_tokenmay only contain charactersUrl::as_str()percent-encodes in every component; anything else is legal in a path and would end the token early, leaking the tail.