From 0e58b27166752a03b617e5f400e873ae580f312a Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:11:21 +0530 Subject: [PATCH] fix(client): strip api-key and OpenAI org/project headers before forwarding RESERVED_HEADERS dropped authorization and x-api-key from forwarded client metadata but left api-key (Azure OpenAI's credential header) and openai-organization / openai-project (OpenAI tenant selectors). A caller could set those on the upstream request next to the backend's own key: an invalid openai-organization makes OpenAI reject the call, a valid one bills a different org the key can reach, and on Azure deployments a client api-key is forwarded as the credential header. Add the three names to RESERVED_HEADERS so forward_metadata_headers drops the client copy. An operator that needs one of them still sets it through the backend's extra_headers, which are applied after forwarding. Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- crates/libsy-llm-client/src/client.rs | 70 +++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index 61a2bf0a..77dc3686 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -26,12 +26,11 @@ use crate::error::{LlmClientError, Result}; use crate::metrics; use crate::raw::RawResponse; -// TODO: Why is this here? What does it do? // Headers this client owns or that are hop-by-hop; never forwarded from the -// caller's metadata. Auth/version/content-type are set by the backend or the -// JSON body, so a forwarded copy would either be ignored or conflict. Compared -// case-insensitively. Aligns with `_SENSITIVE_HEADERS` in the Python -// `switchyard/lib/request_metadata.py` forwarding logic. +// caller's metadata. Auth, credential, and tenant-selector headers are set by +// the backend (via `apply_auth` or configured `extra_headers`), so a forwarded +// client copy would either be ignored or conflict with, and in some cases +// override, the backend's own value. Compared case-insensitively. const RESERVED_HEADERS: &[&str] = &[ "host", "content-length", @@ -42,6 +41,13 @@ const RESERVED_HEADERS: &[&str] = &[ "cookie", "set-cookie", "x-api-key", + // `api-key` is Azure OpenAI's credential header (the `x-`-less sibling of + // `x-api-key`); `openai-organization` and `openai-project` select the + // billing tenant for the backend's key. A caller must not be able to set + // any of these on the upstream request. + "api-key", + "openai-organization", + "openai-project", "anthropic-beta", "anthropic-version", "content-type", @@ -1813,6 +1819,60 @@ mod tests { Ok(()) } + #[tokio::test] + async fn strips_client_credential_and_tenant_headers() + -> std::result::Result<(), Box> { + let server = MockServer::start().await; + Mock::given(method("POST")) + // The backend bearer is preserved and none of the client's + // credential or tenant-selector headers reach the upstream. + .and(wiremock::matchers::header("authorization", "Bearer secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "1", "model": "gpt", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "usage": {} + }))) + .mount(&server) + .await; + + let mut headers = http::HeaderMap::new(); + headers.insert( + "api-key", + http::HeaderValue::from_static("client-azure-key"), + ); + headers.insert( + "openai-organization", + http::HeaderValue::from_static("org-client"), + ); + headers.insert( + "openai-project", + http::HeaderValue::from_static("proj-client"), + ); + let request = Request { + llm_request: LlmRequest { + model: Some("gpt".to_string()), + ..LlmRequest::default() + }, + raw_request: None, + metadata: Some(Metadata { + http_headers: Some(headers), + ..Default::default() + }), + }; + + let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?; + client.call_rewrite_model(request, None).await?; + let received = server + .received_requests() + .await + .ok_or("request recording should be enabled")?; + let received = received.first().ok_or("expected one upstream request")?; + assert!(!received.headers.contains_key("api-key")); + assert!(!received.headers.contains_key("openai-organization")); + assert!(!received.headers.contains_key("openai-project")); + Ok(()) + } + // Exercises the `RoutedLlmClient` impl: `call` uses the model already materialized in the // request and round-trips a buffered response. #[tokio::test]