Redact OTEL exporter headers in app-server config responses (#57654)

## Why

OTEL exporter headers can contain credentials. Configuration responses exposed these values in effective config, source layers, managed requirements, and write-override metadata.

## What changed

Replace header values with `[redacted]` across these responses for log, trace, and metrics exporters while preserving header names. Keep the original values available to runtime exporters, and redact them in debug output. Update protocol schemas and generated client documentation to describe the redacted values.

## Testing

Add coverage for effective config and source-layer redaction, managed requirements, and single and batch write-override metadata, including header names containing dots. Extend telemetry integration coverage to verify that runtime requests still carry the configured header value.

GitOrigin-RevId: ad24e8b91e615bd36c03c4c0ca205ac64bbd8cbf
This commit is contained in:
arnavpatidar-OAI
2026-10-28 05:46:04 +00:00
committed by copyberry
parent 727283cea5
commit bea47c06df
17 changed files with 379 additions and 30 deletions
@@ -1,6 +1,7 @@
use crate::config_layer::config_layer_metadata_to_api;
use crate::config_layer::config_layer_to_api;
use crate::config_manager::ConfigManager;
use crate::otel_config::redact_otel_exporter_headers;
use codex_app_server_protocol::Config as ApiConfig;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit as ApiConfigEdit;
@@ -168,7 +169,7 @@ impl ConfigManager {
!matches!(&metadata.name, ConfigLayerSource::PackagedDefaults { .. })
});
Ok(ConfigReadResponse {
let mut response = ConfigReadResponse {
config,
origins: origins
.into_iter()
@@ -183,7 +184,20 @@ impl ConfigManager {
.map(|layer| config_layer_to_api(layer.as_layer()))
.collect()
}),
})
};
// Redact exporter credentials from both the effective config and any
// included config layers before returning the public API response.
if let Some(otel) = response.config.additional.get_mut("otel") {
redact_otel_exporter_headers(otel);
}
for layer in response.layers.iter_mut().flatten() {
if let Some(otel) = layer.config.get_mut("otel") {
redact_otel_exporter_headers(otel);
}
}
Ok(response)
}
pub(crate) async fn read_requirements(
@@ -952,12 +966,28 @@ fn compute_override_metadata(
}
let message = override_message(&overriding_layer.name);
// Redact the complete OTEL config before extracting the overridden path,
// which may point to an entire exporter or an individual credential.
let effective_value = if segments.first().is_some_and(|segment| segment == "otel") {
effective
.get("otel")
.and_then(|otel| serde_json::to_value(otel).ok())
.and_then(|mut otel| {
redact_otel_exporter_headers(&mut otel);
segments[1..]
.iter()
.try_fold(&otel, |value, segment| value.get(segment.as_str()))
.cloned()
})
} else {
effective_value.and_then(|value| serde_json::to_value(value).ok())
}
.unwrap_or(JsonValue::Null);
Some(OverriddenMetadata {
message,
overriding_layer: config_layer_metadata_to_api(overriding_layer),
effective_value: effective_value
.and_then(|value| serde_json::to_value(value).ok())
.unwrap_or(JsonValue::Null),
effective_value,
})
}
@@ -9,6 +9,7 @@ use codex_app_server_protocol::ConfigLayerSource as ApiConfigLayerSource;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_config::types::OtelExporterKind;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -2101,6 +2102,178 @@ async fn write_value_reports_managed_override() {
assert_eq!(overridden.effective_value, serde_json::json!("never"));
}
#[tokio::test]
async fn write_overrides_redact_managed_otel_exporter_headers() {
let tmp = tempdir().expect("tempdir");
let user_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&user_path, "").expect("write user config");
let managed_path = tmp.path().join("managed_config.toml");
std::fs::write(
&managed_path,
r#"[otel]
environment = "managed"
exporter = { otlp-http = { endpoint = "https://collector.example", protocol = "json", headers = { Authorization = "admin-secret", "x.api.key" = "dotted-secret" } } }
trace_exporter = { otlp-grpc = { endpoint = "https://trace.example", headers = { Authorization = "trace-secret" } } }
metrics_exporter = { otlp-http = { endpoint = "https://metrics.example", protocol = "json", headers = { Authorization = "metrics-secret" } } }
span_attributes = { "service.name" = "managed-service" }"#,
)
.expect("write managed config");
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path),
CloudConfigBundleLoader::default(),
);
let exporter = serde_json::json!({
"otlp-http": {
"endpoint": "https://collector.example",
"headers": { "Authorization": "[redacted]", "x.api.key": "[redacted]" },
"protocol": "json"
}
});
let otel = serde_json::json!({
"environment": "managed",
"exporter": exporter.clone(),
"trace_exporter": {
"otlp-grpc": {
"endpoint": "https://trace.example",
"headers": { "Authorization": "[redacted]" }
}
},
"metrics_exporter": {
"otlp-http": {
"endpoint": "https://metrics.example",
"protocol": "json",
"headers": { "Authorization": "[redacted]" }
}
},
"span_attributes": { "service.name": "managed-service" }
});
for (key_path, expected) in [
("otel", otel.clone()),
("otel.exporter", exporter.clone()),
("otel.exporter.otlp-http", exporter["otlp-http"].clone()),
(
"otel.exporter.otlp-http.headers",
exporter["otlp-http"]["headers"].clone(),
),
(
"otel.exporter.otlp-http.headers.Authorization",
serde_json::json!("[redacted]"),
),
(
"otel.exporter.otlp-http.headers.\"x.api.key\"",
serde_json::json!("[redacted]"),
),
(
"otel.exporter.otlp-http.endpoint",
serde_json::json!("https://collector.example"),
),
(
"otel.exporter.otlp-http.protocol",
serde_json::json!("json"),
),
("otel.trace_exporter", otel["trace_exporter"].clone()),
("otel.metrics_exporter", otel["metrics_exporter"].clone()),
(
"otel.metrics_exporter.otlp-http.headers.Authorization",
serde_json::json!("[redacted]"),
),
("otel.environment", serde_json::json!("managed")),
(
"otel.span_attributes.\"service.name\"",
serde_json::json!("managed-service"),
),
] {
let response = service
.write_value(ConfigValueWriteParams {
file_path: None,
key_path: key_path.to_string(),
value: serde_json::Value::Null,
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("no-op config clear should succeed");
assert_eq!(response.status, WriteStatus::OkOverridden, "{key_path}");
assert_eq!(
response
.overridden_metadata
.expect("managed override")
.effective_value,
expected,
"{key_path}"
);
assert_eq!(std::fs::read_to_string(&user_path).unwrap(), "");
}
let response = service
.batch_write(ConfigBatchWriteParams {
edits: vec![codex_app_server_protocol::ConfigEdit {
key_path: "otel.exporter.otlp-http.headers.Authorization".to_string(),
value: serde_json::Value::Null,
merge_strategy: MergeStrategy::Replace,
}],
file_path: None,
expected_version: None,
reload_user_config: false,
})
.await
.expect("no-op batch clear should succeed");
assert_eq!(response.status, WriteStatus::OkOverridden);
assert_eq!(
response
.overridden_metadata
.expect("managed batch override")
.effective_value,
serde_json::json!("[redacted]")
);
assert_eq!(std::fs::read_to_string(&user_path).unwrap(), "");
let read = service
.read(ConfigReadParams {
include_layers: true,
cwd: None,
})
.await
.expect("read public config");
let mut effective_exporter = exporter.clone();
effective_exporter["otlp-http"]["tls"] = serde_json::Value::Null;
assert_eq!(
read.config.additional["otel"]["exporter"],
effective_exporter
);
let managed_layer = read
.layers
.expect("config layers")
.into_iter()
.find(|layer| {
matches!(
layer.name,
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
)
})
.expect("legacy managed layer");
assert_eq!(managed_layer.config["otel"]["exporter"], exporter);
let runtime = service
.load_latest_config(/*fallback_cwd*/ None)
.await
.expect("load runtime config");
let OtelExporterKind::OtlpHttp { headers, .. } = runtime.otel.exporter else {
panic!("unexpected runtime exporter");
};
assert_eq!(
headers.get("Authorization").map(|value| value.as_str()),
Some("admin-secret")
);
assert_eq!(
headers.get("x.api.key").map(|value| value.as_str()),
Some("dotted-secret")
);
}
/// Legacy managed feature toggles own their normalized enabled origin and override metadata.
#[tokio::test]
async fn multi_agent_v2_boolean_layer_owns_enabled_origin_and_overrides() {
+31 -3
View File
@@ -1,14 +1,17 @@
//! Maps OTEL requirements into app-server responses.
//! Maps OTEL requirements and masks exporter credentials in app-server responses.
use codex_app_server_protocol::OtelExporter;
use codex_app_server_protocol::OtelHttpProtocol;
use codex_app_server_protocol::OtelRequirements;
use codex_app_server_protocol::OtelTlsConfig;
use codex_app_server_protocol::OtelToolResultLogConfig;
use codex_app_server_protocol::SerdeRedactedString;
use codex_config::OtelRequirementsToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::OtelHttpProtocol as CoreOtelHttpProtocol;
use codex_config::types::OtelTlsConfig as CoreOtelTlsConfig;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
pub(crate) fn map_otel_requirements_to_api(otel: OtelRequirementsToml) -> OtelRequirements {
OtelRequirements {
@@ -26,6 +29,12 @@ pub(crate) fn map_otel_requirements_to_api(otel: OtelRequirementsToml) -> OtelRe
}
fn map_otel_exporter_to_api(exporter: OtelExporterKind) -> OtelExporter {
let response_headers = |headers: HashMap<String, _>| {
headers
.into_iter()
.map(|(name, value)| (name, SerdeRedactedString::from(value)))
.collect()
};
match exporter {
OtelExporterKind::None => OtelExporter::None,
OtelExporterKind::Statsig => OtelExporter::Statsig,
@@ -36,7 +45,7 @@ fn map_otel_exporter_to_api(exporter: OtelExporterKind) -> OtelExporter {
tls,
} => OtelExporter::OtlpHttp {
endpoint,
headers,
headers: response_headers(headers),
protocol: match protocol {
CoreOtelHttpProtocol::Binary => OtelHttpProtocol::Binary,
CoreOtelHttpProtocol::Json => OtelHttpProtocol::Json,
@@ -49,7 +58,7 @@ fn map_otel_exporter_to_api(exporter: OtelExporterKind) -> OtelExporter {
tls,
} => OtelExporter::OtlpGrpc {
endpoint,
headers,
headers: response_headers(headers),
tls: tls.map(map_otel_tls_to_api),
},
}
@@ -62,3 +71,22 @@ fn map_otel_tls_to_api(tls: CoreOtelTlsConfig) -> OtelTlsConfig {
client_private_key: tls.client_private_key.map(Into::into),
}
}
/// Masks exporter header values before OTEL configuration crosses the API boundary.
pub(crate) fn redact_otel_exporter_headers(otel: &mut JsonValue) {
for field in ["exporter", "trace_exporter", "metrics_exporter"] {
let Some(exporter) = otel.get_mut(field).and_then(JsonValue::as_object_mut) else {
continue;
};
for config in exporter.values_mut() {
let Some(headers) = config.get_mut("headers").and_then(JsonValue::as_object_mut) else {
continue;
};
for value in headers.values_mut() {
*value = SerdeRedactedString::PLACEHOLDER.into();
}
}
}
}