This is an automated email from the ASF dual-hosted git repository.
liurenjie1024 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 6c197fe8b fix(rest): Filter sensitive headers from error logs (#2117)
(#2130)
6c197fe8b is described below
commit 6c197fe8b02e24dc020ca327926d74ed77c13743
Author: Cole Mackenzie <[email protected]>
AuthorDate: Thu Feb 12 18:42:11 2026 -0800
fix(rest): Filter sensitive headers from error logs (#2117) (#2130)
---
crates/catalog/rest/src/catalog.rs | 109 +++++++++++++++++++---
crates/catalog/rest/src/client.rs | 184 ++++++++++++++++++++++++++++++++++++-
2 files changed, 277 insertions(+), 16 deletions(-)
diff --git a/crates/catalog/rest/src/catalog.rs
b/crates/catalog/rest/src/catalog.rs
index ddbf6a4e0..eeea1f13e 100644
--- a/crates/catalog/rest/src/catalog.rs
+++ b/crates/catalog/rest/src/catalog.rs
@@ -50,6 +50,8 @@ use crate::types::{
pub const REST_CATALOG_PROP_URI: &str = "uri";
/// REST catalog warehouse location
pub const REST_CATALOG_PROP_WAREHOUSE: &str = "warehouse";
+/// Disable header redaction in error logs (defaults to false for security)
+pub const REST_CATALOG_PROP_DISABLE_HEADER_REDACTION: &str =
"disable-header-redaction";
const ICEBERG_REST_SPEC_VERSION: &str = "0.14.1";
const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -293,6 +295,17 @@ impl RestCatalogConfig {
params
}
+ /// Check if header redaction is disabled in error logs.
+ ///
+ /// Returns true if the `disable-header-redaction` property is set to
"true".
+ /// Defaults to false for security (headers are redacted by default).
+ pub(crate) fn disable_header_redaction(&self) -> bool {
+ self.props
+ .get(REST_CATALOG_PROP_DISABLE_HEADER_REDACTION)
+ .map(|v| v.eq_ignore_ascii_case("true"))
+ .unwrap_or(false)
+ }
+
/// Merge the `RestCatalogConfig` with the a [`CatalogConfig`] (fetched
from the REST server).
pub(crate) fn merge_with_config(mut self, mut config: CatalogConfig) ->
Self {
if let Some(uri) = config.overrides.remove("uri") {
@@ -378,7 +391,11 @@ impl RestCatalog {
match http_response.status() {
StatusCode::OK =>
deserialize_catalog_response(http_response).await,
- _ =>
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => Err(deserialize_unexpected_catalog_error(
+ http_response,
+ client.disable_header_redaction(),
+ )
+ .await),
}
}
@@ -479,7 +496,13 @@ impl Catalog for RestCatalog {
"The parent parameter of the namespace provided does
not exist",
));
}
- _ => return
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => {
+ return Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await);
+ }
}
}
@@ -514,7 +537,11 @@ impl Catalog for RestCatalog {
ErrorKind::Unexpected,
"Tried to create a namespace that already exists",
)),
- _ =>
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await),
}
}
@@ -538,7 +565,11 @@ impl Catalog for RestCatalog {
ErrorKind::Unexpected,
"Tried to get a namespace that does not exist",
)),
- _ =>
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await),
}
}
@@ -555,7 +586,11 @@ impl Catalog for RestCatalog {
match http_response.status() {
StatusCode::NO_CONTENT | StatusCode::OK => Ok(true),
StatusCode::NOT_FOUND => Ok(false),
- _ =>
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await),
}
}
@@ -586,7 +621,11 @@ impl Catalog for RestCatalog {
ErrorKind::Unexpected,
"Tried to drop a namespace that does not exist",
)),
- _ =>
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await),
}
}
@@ -623,7 +662,13 @@ impl Catalog for RestCatalog {
"Tried to list tables of a namespace that does not
exist",
));
}
- _ => return
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => {
+ return Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await);
+ }
}
}
@@ -677,7 +722,13 @@ impl Catalog for RestCatalog {
"The table already exists",
));
}
- _ => return
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => {
+ return Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await);
+ }
};
let metadata_location =
response.metadata_location.as_ref().ok_or(Error::new(
@@ -732,7 +783,13 @@ impl Catalog for RestCatalog {
"Tried to load a table that does not exist",
));
}
- _ => return
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => {
+ return Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await);
+ }
};
let config = response
@@ -774,7 +831,11 @@ impl Catalog for RestCatalog {
ErrorKind::Unexpected,
"Tried to drop a table that does not exist",
)),
- _ =>
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await),
}
}
@@ -792,7 +853,11 @@ impl Catalog for RestCatalog {
match http_response.status() {
StatusCode::NO_CONTENT | StatusCode::OK => Ok(true),
StatusCode::NOT_FOUND => Ok(false),
- _ =>
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await),
}
}
@@ -821,7 +886,11 @@ impl Catalog for RestCatalog {
ErrorKind::Unexpected,
"Tried to rename a table to a name that already exists",
)),
- _ =>
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await),
}
}
@@ -865,7 +934,13 @@ impl Catalog for RestCatalog {
"The given table already exists.",
));
}
- _ => return
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => {
+ return Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await);
+ }
};
let metadata_location =
response.metadata_location.as_ref().ok_or(Error::new(
@@ -934,7 +1009,13 @@ impl Catalog for RestCatalog {
"A server-side gateway timeout occurred; the commit state
is unknown.",
));
}
- _ => return
Err(deserialize_unexpected_catalog_error(http_response).await),
+ _ => {
+ return Err(deserialize_unexpected_catalog_error(
+ http_response,
+ context.client.disable_header_redaction(),
+ )
+ .await);
+ }
};
let file_io = self
diff --git a/crates/catalog/rest/src/client.rs
b/crates/catalog/rest/src/client.rs
index 361c036bb..07dc0620d 100644
--- a/crates/catalog/rest/src/client.rs
+++ b/crates/catalog/rest/src/client.rs
@@ -43,6 +43,8 @@ pub(crate) struct HttpClient {
extra_headers: HeaderMap,
/// Extra oauth parameters to be added to each authentication request.
extra_oauth_params: HashMap<String, String>,
+ /// Whether to disable header redaction in error logs (defaults to false
for security).
+ disable_header_redaction: bool,
}
impl Debug for HttpClient {
@@ -65,6 +67,7 @@ impl HttpClient {
credential: cfg.credential(),
extra_headers,
extra_oauth_params: cfg.extra_oauth_params(),
+ disable_header_redaction: cfg.disable_header_redaction(),
})
}
@@ -92,6 +95,7 @@ impl HttpClient {
} else {
self.extra_oauth_params
},
+ disable_header_redaction: cfg.disable_header_redaction(),
})
}
@@ -258,6 +262,11 @@ impl HttpClient {
self.authenticate(&mut request).await?;
self.execute(request).await
}
+
+ /// Returns whether header redaction is disabled for this client.
+ pub(crate) fn disable_header_redaction(&self) -> bool {
+ self.disable_header_redaction
+ }
}
/// Deserializes a catalog response into the given [`DeserializedOwned`] type.
@@ -278,14 +287,64 @@ pub(crate) async fn deserialize_catalog_response<R:
DeserializeOwned>(
})
}
+/// Headers that contain sensitive information and should be excluded from
logs.
+const SENSITIVE_HEADERS: &[&str] = &[
+ "authorization",
+ "proxy-authorization",
+ "set-cookie",
+ "cookie",
+ "x-api-key",
+ "x-auth-token",
+];
+
+/// Returns true if the header name is considered sensitive.
+fn is_sensitive_header(name: &str) -> bool {
+ let name_lower = name.to_lowercase();
+ SENSITIVE_HEADERS.iter().any(|h| name_lower == *h)
+}
+
+/// Redacts sensitive headers and returns a debug-formatted string.
+///
+/// If `disable_redaction` is true, returns all headers without redaction.
+/// Otherwise, replaces sensitive header values with "[REDACTED]".
+fn format_headers_redacted(headers: &HeaderMap, disable_redaction: bool) ->
String {
+ if disable_redaction {
+ // Return all headers as-is without redaction
+ let all: HashMap<&str, &str> = headers
+ .iter()
+ .filter_map(|(name, value)| value.to_str().ok().map(|v|
(name.as_str(), v)))
+ .collect();
+ return format!("{all:?}");
+ }
+
+ // Redact sensitive headers by replacing their values with "[REDACTED]"
+ let redacted: HashMap<&str, &str> = headers
+ .iter()
+ .filter_map(|(name, value)| {
+ if is_sensitive_header(name.as_str()) {
+ Some((name.as_str(), "[REDACTED]"))
+ } else {
+ value.to_str().ok().map(|v| (name.as_str(), v))
+ }
+ })
+ .collect();
+ format!("{redacted:?}")
+}
+
/// Deserializes a unexpected catalog response into an error.
-pub(crate) async fn deserialize_unexpected_catalog_error(response: Response)
-> Error {
+pub(crate) async fn deserialize_unexpected_catalog_error(
+ response: Response,
+ disable_header_redaction: bool,
+) -> Error {
let err = Error::new(
ErrorKind::Unexpected,
"Received response with unexpected status code",
)
.with_context("status", response.status().to_string())
- .with_context("headers", format!("{:?}", response.headers()));
+ .with_context(
+ "headers",
+ format_headers_redacted(response.headers(), disable_header_redaction),
+ );
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
@@ -297,3 +356,124 @@ pub(crate) async fn
deserialize_unexpected_catalog_error(response: Response) ->
}
err.with_context("json", String::from_utf8_lossy(&bytes))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_format_headers_redacted_empty() {
+ let headers = HeaderMap::new();
+ let result = format_headers_redacted(&headers, false);
+ assert_eq!(result, "{}");
+ }
+
+ #[test]
+ fn test_format_headers_redacted_non_sensitive() {
+ let mut headers = HeaderMap::new();
+ headers.insert("content-type", "application/json".parse().unwrap());
+ headers.insert("x-request-id", "abc123".parse().unwrap());
+
+ let result = format_headers_redacted(&headers, false);
+
+ assert!(result.contains("content-type"));
+ assert!(result.contains("application/json"));
+ assert!(result.contains("x-request-id"));
+ assert!(result.contains("abc123"));
+ }
+
+ #[test]
+ fn test_format_headers_redacted_filters_sensitive() {
+ let mut headers = HeaderMap::new();
+ headers.insert("authorization", "Bearer
secret-token".parse().unwrap());
+ headers.insert("content-type", "application/json".parse().unwrap());
+
+ let result = format_headers_redacted(&headers, false);
+
+ // Sensitive header should be present but with redacted value
+ assert!(result.contains("authorization"));
+ assert!(result.contains("[REDACTED]"));
+ // Sensitive value should NOT be present
+ assert!(!result.contains("secret-token"));
+ // Non-sensitive header should be present with actual value
+ assert!(result.contains("content-type"));
+ assert!(result.contains("application/json"));
+ }
+
+ #[test]
+ fn test_format_headers_redacted_filters_set_cookie() {
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ "set-cookie",
+ "CF_Authorization=sensitive-session-token; Path=/; Secure;"
+ .parse()
+ .unwrap(),
+ );
+ headers.insert("server", "cloudflare".parse().unwrap());
+
+ let result = format_headers_redacted(&headers, false);
+
+ // Sensitive header should be present but with redacted value
+ assert!(result.contains("set-cookie"));
+ assert!(result.contains("[REDACTED]"));
+ // Sensitive value should NOT be present
+ assert!(!result.contains("sensitive-session-token"));
+ // Non-sensitive header should be present with actual value
+ assert!(result.contains("server"));
+ assert!(result.contains("cloudflare"));
+ }
+
+ #[test]
+ fn test_format_headers_redacted_filters_all_sensitive() {
+ let mut headers = HeaderMap::new();
+ headers.insert("authorization", "Bearer token".parse().unwrap());
+ headers.insert("proxy-authorization", "Basic creds".parse().unwrap());
+ headers.insert("set-cookie", "session=abc".parse().unwrap());
+ headers.insert("cookie", "session=abc".parse().unwrap());
+ headers.insert("x-api-key", "api-key-123".parse().unwrap());
+ headers.insert("x-auth-token", "auth-token-456".parse().unwrap());
+ headers.insert("x-request-id", "req-123".parse().unwrap());
+
+ let result = format_headers_redacted(&headers, false);
+
+ // All sensitive headers should be present but with redacted values
+ assert!(result.contains("authorization"));
+ assert!(result.contains("proxy-authorization"));
+ assert!(result.contains("set-cookie"));
+ assert!(result.contains("cookie"));
+ assert!(result.contains("x-api-key"));
+ assert!(result.contains("x-auth-token"));
+ assert!(result.contains("[REDACTED]"));
+
+ // Ensure no sensitive values leaked
+ assert!(!result.contains("Bearer token"));
+ assert!(!result.contains("Basic creds"));
+ assert!(!result.contains("session=abc"));
+ assert!(!result.contains("api-key-123"));
+ assert!(!result.contains("auth-token-456"));
+
+ // Non-sensitive header should be present with actual value
+ assert!(result.contains("x-request-id"));
+ assert!(result.contains("req-123"));
+ }
+
+ #[test]
+ fn test_format_headers_with_redaction_disabled() {
+ let mut headers = HeaderMap::new();
+ headers.insert("authorization", "Bearer
secret-token".parse().unwrap());
+ headers.insert("x-api-key", "api-key-123".parse().unwrap());
+ headers.insert("content-type", "application/json".parse().unwrap());
+
+ let result = format_headers_redacted(&headers, true);
+
+ // When redaction is disabled, all headers and values should be present
+ assert!(result.contains("authorization"));
+ assert!(result.contains("Bearer secret-token"));
+ assert!(result.contains("x-api-key"));
+ assert!(result.contains("api-key-123"));
+ assert!(result.contains("content-type"));
+ assert!(result.contains("application/json"));
+ // [REDACTED] should NOT be present when redaction is disabled
+ assert!(!result.contains("[REDACTED]"));
+ }
+}