fix(usage): preserve original captured HTTP headers

This commit is contained in:
elky
2026-09-08 08:49:35 +08:00
parent 099b810a2f
commit 7113d04f8a
9 changed files with 210 additions and 152 deletions
+29 -43
View File
@@ -756,13 +756,8 @@ fn runtime_miss_client_error_body(api_format: Option<&str>, message: &str) -> Va
}
fn runtime_miss_original_headers_json(headers: &HeaderMap) -> Value {
let mut headers = crate::headers::collect_control_headers(headers);
for (name, value) in headers.iter_mut() {
if runtime_miss_sensitive_header(name) {
*value = runtime_miss_mask_header_value(value);
}
}
serde_json::to_value(headers).unwrap_or_else(|_| json!({}))
serde_json::to_value(crate::headers::collect_control_headers(headers))
.unwrap_or_else(|_| json!({}))
}
fn runtime_miss_original_request_body_json(
@@ -784,40 +779,6 @@ fn runtime_miss_original_request_body_json(
})
}
fn runtime_miss_sensitive_header(name: &str) -> bool {
const SENSITIVE_HEADERS: &[&str] = &[
"authorization",
"x-api-key",
"api-key",
"x-goog-api-key",
"cookie",
"proxy-authorization",
];
SENSITIVE_HEADERS
.iter()
.any(|candidate| name.eq_ignore_ascii_case(candidate))
}
fn runtime_miss_mask_header_value(value: &str) -> String {
let value = value.trim();
let char_count = value.chars().count();
if char_count <= 8 {
return "****".to_string();
}
let prefix: String = value.chars().take(4).collect();
let suffix: String = value
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}****{suffix}")
}
async fn load_runtime_miss_candidate_contexts(
state: &AppState,
request_id: &str,
@@ -1233,8 +1194,9 @@ mod tests {
apply_runtime_miss_usage_routing, beautify_local_execution_client_error_message,
insert_runtime_miss_candidate_usage_metadata,
request_candidate_represents_provider_execution, runtime_miss_client_error_body,
select_last_runtime_miss_executed_candidate, select_last_runtime_miss_routing_candidate,
LocalExecutionRuntimeMissContext, RuntimeMissCandidateContext,
runtime_miss_original_headers_json, select_last_runtime_miss_executed_candidate,
select_last_runtime_miss_routing_candidate, LocalExecutionRuntimeMissContext,
RuntimeMissCandidateContext,
};
use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS;
use crate::state::LocalExecutionRuntimeMissDiagnostic;
@@ -1266,6 +1228,30 @@ mod tests {
);
}
#[test]
fn runtime_miss_usage_preserves_original_request_headers() {
let expected = json!({
"authorization": "Bearer original-client-token",
"x-api-key": "short",
"api-key": "original-api-key",
"x-goog-api-key": "original-google-key",
"cookie": "session=original-client",
"proxy-authorization": "Basic original-proxy-token",
"originator": "codex-cli",
"session-id": "original-session",
"x-codex-turn-metadata": "{\"turn_id\":\"original-turn\"}"
});
let mut headers = http::HeaderMap::new();
for (name, value) in expected.as_object().unwrap() {
headers.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
http::HeaderValue::from_str(value.as_str().unwrap()).unwrap(),
);
}
assert_eq!(runtime_miss_original_headers_json(&headers), expected);
}
#[test]
fn runtime_miss_usage_body_matches_claude_client_envelope() {
let claude = runtime_miss_client_error_body(Some("claude:messages"), "busy");
+2 -2
View File
@@ -475,7 +475,7 @@ async fn gateway_truncates_deep_request_echo_for_local_openai_chat_sync_usage_im
);
assert_eq!(
stored_usage.request_headers.as_ref().unwrap()["authorization"],
"[redacted]"
"Bearer sk-client-openai-local-report-sync-deep"
);
gateway_handle.abort();
@@ -2094,7 +2094,7 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
assert!(stored_usage.error_message.is_none());
assert_eq!(
stored_usage.request_headers.as_ref().unwrap()["authorization"],
"[redacted]"
"Bearer sk-client-claude-cli-usage-local-miss"
);
assert!(stored_usage.request_body.is_none());
assert!(stored_usage.request_body_ref.is_none());
@@ -3398,6 +3398,60 @@ mod tests {
));
}
#[test]
fn detail_payload_preserves_original_headers_with_or_without_bodies() {
let item = StoredRequestUsageAudit {
request_headers: Some(json!({
"authorization": "Bearer original-client-token",
"originator": "codex-cli",
"session-id": "original-session",
"thread-id": "original-thread",
"x-codex-turn-metadata": "{\"turn_id\":\"original-turn\"}",
"x-openai-subagent": "reviewer"
})),
provider_request_headers: Some(json!({
"authorization": "Bearer original-provider-token",
"x-api-key": "original-provider-key"
})),
response_headers: Some(json!({
"set-cookie": ["session=original-upstream", "preference=original"],
"x-upstream-custom": "original-upstream-value"
})),
client_response_headers: Some(json!({
"set-cookie": "session=original-client",
"x-client-custom": "original-client-value"
})),
..sample_usage("completed", Some(200), None)
};
for include_bodies in [false, true] {
let payload = build_admin_usage_detail_payload(
&item,
&BTreeMap::new(),
&BTreeMap::new(),
false,
false,
None,
include_bodies,
None,
&BTreeMap::new(),
);
for (field, expected) in [
("request_headers", &item.request_headers),
("provider_request_headers", &item.provider_request_headers),
("response_headers", &item.response_headers),
("client_response_headers", &item.client_response_headers),
] {
assert_eq!(
&payload[field],
expected.as_ref().unwrap(),
"{field} should retain original values when include_bodies={include_bodies}"
);
}
}
}
#[test]
fn detail_payload_marks_reference_backed_bodies_as_available() {
let item = StoredRequestUsageAudit {
@@ -283,11 +283,11 @@ async fn live_full_http_capture_round_trips_for_direct_and_batch_writes() {
.unwrap();
assert_eq!(
stored.request_headers,
Some(json!({"content-type": "application/json", "authorization": "[redacted]"}))
Some(json!({"content-type": "application/json", "authorization": "Bearer private"}))
);
assert_eq!(
stored.response_headers,
Some(json!({"content-type": "text/event-stream", "set-cookie": "[redacted]"}))
Some(json!({"content-type": "text/event-stream", "set-cookie": "private"}))
);
for (field, expected) in [
(UsageBodyField::RequestBody, pending.request_body.as_ref()),
@@ -704,7 +704,7 @@ async fn live_pending_batch_persists_auxiliary_state_and_preserves_terminal_conf
.unwrap();
assert_eq!(
captured.request_headers,
Some(json!({"x-request": "[redacted]"}))
Some(json!({"x-request": "request-value"}))
);
assert_eq!(
repository
@@ -367,31 +367,8 @@ pub fn sanitize_usage_capture_controls_for_persistence(
usage
}
pub fn usage_header_value_is_sensitive(name: &str) -> bool {
![
"accept",
"accept-encoding",
"content-encoding",
"content-length",
"content-type",
"transfer-encoding",
"x-request-id",
"x-trace-id",
]
.iter()
.any(|candidate| name.trim().eq_ignore_ascii_case(candidate))
}
pub fn sanitize_usage_headers_for_persistence(value: Option<Value>) -> Option<Value> {
let Value::Object(mut headers) = value? else {
return None;
};
for (name, value) in &mut headers {
if usage_header_value_is_sensitive(name) && !value.is_null() {
*value = Value::String("[redacted]".to_string());
}
}
Some(Value::Object(headers))
value.filter(Value::is_object)
}
fn sanitize_usage_routing_fields(
@@ -833,6 +810,35 @@ mod tests {
);
}
#[test]
fn header_capture_preserves_original_values() {
let headers = json!({
"Authorization": "Bearer original-token",
"originator": "codex-cli",
"user-agent": "codex-tui/test",
"session-id": "session-original",
"thread-id": "thread-original",
"x-client-request-id": "client-request-original",
"x-codex-beta-features": "feature-original",
"x-codex-parent-thread-id": "parent-thread-original",
"x-codex-turn-metadata": "{\"turn_id\":\"turn-original\"}",
"x-codex-turn-state": "turn-state-original",
"x-codex-window-id": "window-original",
"x-openai-internal-codex-responses-lite": "true",
"x-openai-subagent": "reviewer",
"set-cookie": ["session=original", "preference=original"],
"x-custom-header": "original-value",
"x-null": null
});
assert_eq!(
super::sanitize_usage_headers_for_persistence(Some(headers.clone())),
Some(headers)
);
for invalid in [json!(null), json!("headers"), json!(["headers"])] {
assert!(super::sanitize_usage_headers_for_persistence(Some(invalid)).is_none());
}
}
#[test]
fn auxiliary_capture_projection_preserves_captures_and_honors_disabled_states() {
let mut input = usage_with_http_capture();
@@ -843,7 +849,7 @@ mod tests {
assert_eq!(
usage.request_headers,
Some(json!({"authorization": "[redacted]"}))
Some(json!({"authorization": "Bearer secret"}))
);
assert!(usage.request_body.is_none());
assert!(usage.request_body_ref.is_none());
@@ -896,15 +902,19 @@ mod tests {
assert!(captured.client_response_body_ref.is_none());
assert_eq!(
captured.request_headers,
Some(json!({"Content-Type": "application/json", "Authorization": "[redacted]"}))
Some(json!({"Content-Type": "application/json", "Authorization": "Bearer secret"}))
);
assert_eq!(
captured.provider_request_headers,
Some(json!({"x-api-key": "[redacted]"}))
Some(json!({"x-api-key": "secret"}))
);
assert_eq!(
captured.response_headers,
Some(json!({"set-cookie": "[redacted]"}))
Some(json!({"set-cookie": "secret"}))
);
assert_eq!(
captured.client_response_headers,
input.client_response_headers
);
assert!(captured.error_message.is_none());
}
@@ -155,7 +155,7 @@ async fn upsert_preserves_full_http_captures_across_lifecycle_updates() {
);
assert_eq!(
stored_pending.request_headers,
Some(json!({"content-type": "application/json", "authorization": "[redacted]"}))
Some(json!({"content-type": "application/json", "authorization": "Bearer private"}))
);
let mut streaming = sample_upsert_usage_record(&pending.request_id);
@@ -190,7 +190,7 @@ async fn upsert_preserves_full_http_captures_across_lifecycle_updates() {
);
assert_eq!(
stored_terminal.response_headers,
Some(json!({"content-type": "text/event-stream", "set-cookie": "[redacted]"}))
Some(json!({"content-type": "text/event-stream", "set-cookie": "private"}))
);
let found = repository
+31 -74
View File
@@ -865,12 +865,12 @@ pub fn build_terminal_usage_context_seed(
request_type,
is_stream: plan.stream,
routing: build_runtime_routing_seed(plan, context),
request_headers: mask_sensitive_headers_in_json_value(context_value(
request_headers: validate_usage_headers_in_json_value(context_value(
context,
"original_headers",
)),
request_body: request_capture.request_body,
provider_request_headers: mask_sensitive_headers_in_json_value(
provider_request_headers: validate_usage_headers_in_json_value(
context_usage_value(context, "provider_request_headers")
.or_else(|| headers_to_json(&plan.headers)),
),
@@ -2497,7 +2497,7 @@ fn sanitize_usage_event_data(mut data: UsageEventData) -> UsageEventData {
}
fn sanitize_usage_header_capture(value: Option<Value>) -> Option<Value> {
mask_sensitive_headers_in_json_value(value).map(capture_usage_storage_value)
validate_usage_headers_in_json_value(value).map(capture_usage_storage_value)
}
fn trim_owned_non_empty_string(value: String) -> Option<String> {
@@ -2720,30 +2720,19 @@ fn headers_to_json(headers: &BTreeMap<String, String>) -> Option<Value> {
return None;
}
Some(Value::Object(Map::from_iter(headers.iter().map(
|(key, value)| (key.clone(), Value::String(mask_header_value(key, value))),
|(key, value)| (key.clone(), Value::String(value.clone())),
))))
}
const REDACTED_USAGE_VALUE: &str = "[redacted]";
fn is_sensitive_header(name: &str) -> bool {
aether_data_contracts::repository::usage::usage_header_value_is_sensitive(name)
}
fn mask_header_value(name: &str, value: &str) -> String {
if !is_sensitive_header(name) {
return value.to_string();
}
mask_sensitive_header_value(value)
}
fn mask_sensitive_header_value(_value: &str) -> String {
REDACTED_USAGE_VALUE.to_string()
}
/// Non-object values cannot be established as a valid header map and are
/// discarded instead of being persisted verbatim.
fn mask_sensitive_headers_in_json_value(value: Option<Value>) -> Option<Value> {
fn validate_usage_headers_in_json_value(value: Option<Value>) -> Option<Value> {
aether_data_contracts::repository::usage::sanitize_usage_headers_for_persistence(value)
}
@@ -3509,11 +3498,10 @@ mod tests {
build_terminal_usage_event_from_seed, build_usage_event_data_seed,
build_usage_event_data_seed_describing_request_bodies, decode_body_for_storage,
extract_token_counts_from_json, extract_token_counts_from_value, headers_to_json,
mask_header_value, mask_sensitive_body_fields, mask_sensitive_headers_in_json_value,
parse_sse_body_for_storage, resolve_error_message, trim_owned_non_empty_string,
LifecycleUsageSeed, TerminalUsageSeed, UsageBodyRefsSeed, UsageBodyStatesSeed,
UsageRoutingSeed, UsageTerminalState, MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
REDACTED_USAGE_VALUE,
mask_sensitive_body_fields, parse_sse_body_for_storage, resolve_error_message,
trim_owned_non_empty_string, validate_usage_headers_in_json_value, LifecycleUsageSeed,
TerminalUsageSeed, UsageBodyRefsSeed, UsageBodyStatesSeed, UsageRoutingSeed,
UsageTerminalState, MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
};
use crate::{
build_upsert_usage_record_from_event, GatewayStreamReportRequest, GatewaySyncReportRequest,
@@ -5660,14 +5648,14 @@ mod tests {
assert_eq!(
event.data.response_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE,
"authorization": "Bearer very-secret-token",
"content-type": "application/json"
}))
);
assert_eq!(
event.data.client_response_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE,
"authorization": "Bearer very-secret-token",
"content-type": "application/json"
}))
);
@@ -6560,7 +6548,7 @@ mod tests {
}
#[test]
fn manual_terminal_seed_event_builder_sanitizes_headers_and_metadata_but_preserves_bodies() {
fn manual_terminal_seed_event_builder_sanitizes_metadata_but_preserves_headers_and_bodies() {
let event = build_terminal_usage_event_from_seed(TerminalUsageSeed {
terminal_state: UsageTerminalState::Completed,
client_contract: "openai:chat".to_string(),
@@ -6631,26 +6619,26 @@ mod tests {
assert_eq!(
event.data.request_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE,
"authorization": "Bearer very-secret-token",
"accept": "application/json"
}))
);
assert_eq!(
event.data.provider_request_headers,
Some(json!({
"x-api-key": REDACTED_USAGE_VALUE
"x-api-key": "sk-proj-super-secret"
}))
);
assert_eq!(
event.data.response_headers,
Some(json!({
"set-cookie": REDACTED_USAGE_VALUE
"set-cookie": "session=extremely-secret-cookie"
}))
);
assert_eq!(
event.data.client_response_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE
"authorization": "Bearer client-secret"
}))
);
assert_eq!(
@@ -6727,7 +6715,7 @@ mod tests {
}
#[test]
fn usage_event_data_seed_masks_headers_before_outcome_paths_use_it() {
fn usage_event_data_seed_preserves_headers_before_outcome_paths_use_it() {
let plan = ExecutionPlan {
request_id: "req-seed-sanitize-1".to_string(),
candidate_id: Some("cand-seed-sanitize-1".to_string()),
@@ -6766,7 +6754,7 @@ mod tests {
assert_eq!(
data.request_headers,
Some(json!({
"authorization": REDACTED_USAGE_VALUE,
"authorization": "Bearer outcome-secret",
"accept": "application/json"
}))
);
@@ -6945,38 +6933,7 @@ mod tests {
}
#[test]
fn masks_known_sensitive_header_values() {
let token = "Bearer eyJhbGciOiJSUzI1NiJ9.payload-here.signature-tail";
let masked = mask_header_value("authorization", token);
assert_eq!(masked, REDACTED_USAGE_VALUE);
// 大小写不敏感
assert_eq!(
mask_header_value("Authorization", "12345678"),
REDACTED_USAGE_VALUE,
);
assert_eq!(
mask_header_value("X-Api-Key", "abcdefghij"),
REDACTED_USAGE_VALUE,
);
// Unknown custom headers are redacted by default.
assert_eq!(
mask_header_value("user-agent", "codex-tui/0.1"),
REDACTED_USAGE_VALUE,
);
assert_eq!(
mask_header_value("x-custom-auth", "tenant-secret"),
REDACTED_USAGE_VALUE,
);
assert_eq!(
mask_header_value("content-type", "application/json"),
"application/json",
);
}
#[test]
fn headers_to_json_masks_sensitive_headers_at_source() {
fn headers_to_json_preserves_original_headers_at_source() {
let mut headers = BTreeMap::new();
headers.insert(
"authorization".to_string(),
@@ -6995,17 +6952,17 @@ mod tests {
.get("authorization")
.and_then(|v| v.as_str())
.expect("authorization should be string");
assert_eq!(auth, REDACTED_USAGE_VALUE);
assert_eq!(auth, "Bearer eyJhbGciOiJSUzI1NiJ9.body.signature");
let api_key = object
.get("x-api-key")
.and_then(|v| v.as_str())
.expect("x-api-key should be string");
assert_eq!(api_key, REDACTED_USAGE_VALUE);
assert_eq!(api_key, "sk-proj-1234567890abcdef");
assert_eq!(
object.get("user-agent").and_then(|v| v.as_str()),
Some(REDACTED_USAGE_VALUE),
Some("codex-tui/0.1"),
);
}
@@ -7015,27 +6972,27 @@ mod tests {
}
#[test]
fn mask_sensitive_headers_in_json_value_handles_object_form() {
fn validate_usage_headers_in_json_value_handles_object_form() {
let value = json!({
"Authorization": "Bearer eyJhbGciOiJSUzI1NiJ9.body.signature",
"Cookie": "session=verylongcookievalue1234",
"Accept": "application/json",
});
let masked =
mask_sensitive_headers_in_json_value(Some(value)).expect("masked value should exist");
let object = masked.as_object().expect("expected object");
let captured =
validate_usage_headers_in_json_value(Some(value)).expect("captured value should exist");
let object = captured.as_object().expect("expected object");
let auth = object
.get("Authorization")
.and_then(|v| v.as_str())
.expect("Authorization should be string");
assert_eq!(auth, REDACTED_USAGE_VALUE);
assert_eq!(auth, "Bearer eyJhbGciOiJSUzI1NiJ9.body.signature");
let cookie = object
.get("Cookie")
.and_then(|v| v.as_str())
.expect("Cookie should be string");
assert_eq!(cookie, REDACTED_USAGE_VALUE);
assert_eq!(cookie, "session=verylongcookievalue1234");
assert_eq!(
object.get("Accept").and_then(|v| v.as_str()),
@@ -7044,11 +7001,11 @@ mod tests {
}
#[test]
fn mask_sensitive_headers_discards_non_object() {
fn validate_usage_headers_discards_non_object() {
// None 输入返回 None
assert!(mask_sensitive_headers_in_json_value(None).is_none());
assert!(validate_usage_headers_in_json_value(None).is_none());
// 非 object 不是可验证的 header map,直接丢弃。
let masked = mask_sensitive_headers_in_json_value(Some(json!("not an object")));
let masked = validate_usage_headers_in_json_value(Some(json!("not an object")));
assert_eq!(masked, None);
}
+17
View File
@@ -0,0 +1,17 @@
# Usage header capture
Usage HTTP captures preserve original header values for client requests,
provider requests, provider responses, and client responses. Header maps are
validated as JSON objects but their values are not redacted. Capture settings
and existing access controls still apply.
These records can contain credentials such as Authorization, API keys, and
cookies, as well as session identifiers and client metadata. Restrict access
to usage details, database records, exports, and backups accordingly.
Previously stored `[redacted]` values cannot be recovered. Original values
are available only for new captures after deploying this change.
The request detail drawer reads these values directly from the administrator
usage-detail API, including when bodies are not loaded. No frontend setting
can recover header values that were already replaced during capture.
@@ -88,6 +88,40 @@ async function expectBody(text: string) {
function lastSignal() { return mocks.getRequestBody.mock.calls[mocks.getRequestBody.mock.calls.length - 1][2] as AbortSignal }
describe('RequestDetailDrawer body capture', () => {
it('displays original values for all four captured header directions and copies the selected headers', async () => {
const detail = {
...buildDetail(false),
request_headers: {
authorization: 'Bearer original-client-token',
originator: 'codex-cli',
'session-id': 'original-session',
'x-codex-turn-metadata': '{"turn_id":"original-turn"}',
},
provider_request_headers: { authorization: 'Bearer original-provider-token', 'x-api-key': 'original-provider-key' },
response_headers: { 'set-cookie': 'session=original-upstream', 'x-upstream-custom': 'original-upstream-value' },
client_response_headers: { 'set-cookie': 'session=original-client', 'x-client-custom': 'original-client-value' },
}
mocks.getRequestDetail.mockResolvedValue(detail)
await openDrawer()
for (const [tab, dataSource, expected] of [
['请求头', '客户端', detail.request_headers],
['请求头', '提供商', detail.provider_request_headers],
['响应头', '提供商', detail.response_headers],
['响应头', '客户端', detail.client_response_headers],
] as const) {
findButton(tab)!.click()
await source(dataSource)
await vi.waitFor(() => {
const text = document.body.querySelector('[data-testid="captured-body"]')?.textContent
expect(JSON.parse(text ?? 'null')).toEqual(expected)
})
}
document.body.querySelector<HTMLButtonElement>('button[title="复制"]')!.click()
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith(JSON.stringify(detail.client_response_headers, null, 2), false))
expect(mocks.getRequestBody).not.toHaveBeenCalled()
})
it('uses only shallow details and loads a single binary body on demand', async () => {
await openDrawer()
expect(mocks.getRequestBody).not.toHaveBeenCalled()