From ddcc4fa7cd04827d98cf8ea7c6a4117339e6b627 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 11 Feb 2026 20:18:03 +0200 Subject: [PATCH] fix(FFESUPPORT-433): do not expose request URL on non-200 response --- .changeset/lazy-hats-battle.md | 9 ++ eppo_core/src/configuration_fetcher.rs | 116 +++++++++++++++++- .../src/event_ingestion/event_delivery.rs | 2 + 3 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 .changeset/lazy-hats-battle.md diff --git a/.changeset/lazy-hats-battle.md b/.changeset/lazy-hats-battle.md new file mode 100644 index 00000000..55de94cd --- /dev/null +++ b/.changeset/lazy-hats-battle.md @@ -0,0 +1,9 @@ +--- +"eppo_core": patch +"elixir-sdk": patch +"python-sdk": patch +"ruby-sdk": patch +"rust-sdk": patch +--- + +Sanitize sdkKey from logs on non-200 responses. diff --git a/eppo_core/src/configuration_fetcher.rs b/eppo_core/src/configuration_fetcher.rs index 90ca5715..fd718510 100644 --- a/eppo_core/src/configuration_fetcher.rs +++ b/eppo_core/src/configuration_fetcher.rs @@ -72,7 +72,7 @@ impl ConfigurationFetcher { ], ) .map_err(|err| { - log::warn!(target: "eppo", "failed to parse flags configuration URL: {err:?}"); + log::warn!(target: "eppo", "failed to parse flags configuration URL: {err}"); Error::InvalidBaseUrl(err) })?; @@ -85,8 +85,9 @@ impl ConfigurationFetcher { self.unauthorized = true; return Error::Unauthorized; } else { - log::warn!(target: "eppo", "received non-200 response while fetching new configuration: {:?}", err); - return Error::from(err); + let err = Error::from(err); // sanitize URL to avoid exposing SDK key + log::warn!(target: "eppo", "received non-200 response while fetching new configuration: {err}"); + return err; } })?; @@ -112,7 +113,7 @@ impl ConfigurationFetcher { ], ) .map_err(|err| { - log::warn!(target: "eppo", "failed to parse bandits configuration URL: {err:?}"); + log::warn!(target: "eppo", "failed to parse bandits configuration URL: {err}"); Error::InvalidBaseUrl(err) })?; @@ -125,8 +126,9 @@ impl ConfigurationFetcher { self.unauthorized = true; return Error::Unauthorized; } else { - log::warn!(target: "eppo", "received non-200 response while fetching new configuration: {:?}", err); - return Error::from(err); + let err = Error::from(err); // sanitize URL to avoid exposing SDK key + log::warn!(target: "eppo", "received non-200 response while fetching new configuration: {err}"); + return err; } })?; @@ -138,3 +140,105 @@ impl ConfigurationFetcher { Ok(configuration) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Error, SdkMetadata}; + use log::{Level, Log, Metadata, Record}; + use std::sync::{Arc, Mutex}; + use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate}; + + // Simple logger that captures log messages + static CAPTURED_LOGS: std::sync::OnceLock>>> = std::sync::OnceLock::new(); + + struct TestLogger; + + unsafe impl Send for TestLogger {} + unsafe impl Sync for TestLogger {} + + impl Log for TestLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.target() == "eppo" + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + if let Some(logs) = CAPTURED_LOGS.get() { + let message = format!("{}", record.args()); + logs.lock().unwrap().push(message); + } + } + } + + fn flush(&self) {} + } + + fn setup_test_logger() -> Arc>> { + let logs = Arc::new(Mutex::new(Vec::new())); + CAPTURED_LOGS.set(logs.clone()).ok(); + // Try to set logger, ignore error if already set + let _ = log::set_boxed_logger(Box::new(TestLogger)); + log::set_max_level(log::LevelFilter::Warn); + logs + } + + #[tokio::test] + async fn test_sdk_key_not_exposed_in_error_logs() { + let logs = setup_test_logger(); + logs.lock().unwrap().clear(); + + let test_api_key = "secret-api-key-12345"; + + // Create a mock server that returns 500 error + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock_server) + .await; + + // Create ConfigurationFetcher with the test API key pointing to mock server + let mut fetcher = ConfigurationFetcher::new(ConfigurationFetcherConfig { + base_url: mock_server.uri(), + api_key: test_api_key.to_string(), + sdk_metadata: SdkMetadata { + name: "test-sdk", + version: "1.0.0", + }, + }); + + // Attempt to fetch configuration, which will fail and trigger error logging + let result = fetcher.fetch_configuration().await; + + // Verify the request failed + assert!(result.is_err(), "Expected configuration fetch to fail"); + + // Get captured logs + let captured_logs = logs.lock().unwrap(); + let all_logs = captured_logs.join(" "); + + // Verify the API key is NOT in any of the log messages + assert!( + !all_logs.contains(test_api_key), + "API key should not appear in log messages. Logs: {}", + all_logs + ); + + // Also verify the returned error doesn't contain the API key + if let Err(eppo_error) = result { + let error_string = format!("{}", eppo_error); + let error_debug = format!("{:?}", eppo_error); + + assert!( + !error_string.contains(test_api_key), + "API key should not appear in error Display: {}", + error_string + ); + assert!( + !error_debug.contains(test_api_key), + "API key should not appear in error Debug: {}", + error_debug + ); + } + } +} diff --git a/eppo_core/src/event_ingestion/event_delivery.rs b/eppo_core/src/event_ingestion/event_delivery.rs index 3c1f5ba9..fa9e0629 100644 --- a/eppo_core/src/event_ingestion/event_delivery.rs +++ b/eppo_core/src/event_ingestion/event_delivery.rs @@ -29,6 +29,8 @@ pub(super) enum EventDeliveryError { impl From for EventDeliveryError { fn from(err: reqwest::Error) -> Self { + let err = err.without_url(); // sanitize URL to avoid exposing SDK key + if err.is_builder() || err.is_request() { // Issue with request. Most likely a json serialization error. EventDeliveryError::NonRetriableError(err)