diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index 81ee7d6ae..d8afd87e8 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -867,23 +867,30 @@ fn client_error(error: &LlmClientError) -> Response { "upstream_error", "upstream_error", ), - LlmClientError::Transport { source } | LlmClientError::InvalidResponse { source } => { - error_response( - StatusCode::BAD_GATEWAY, - source.to_string(), - "upstream_error", - "upstream_error", - ) - } + // Transport sources may include the full request URL, including configured + // query credentials. Keep transport details out of client-visible errors. + LlmClientError::Transport { .. } => error_response( + StatusCode::BAD_GATEWAY, + "upstream transport error", + "upstream_error", + "upstream_error", + ), + LlmClientError::InvalidResponse { source } => error_response( + StatusCode::BAD_GATEWAY, + source.to_string(), + "upstream_error", + "upstream_error", + ), LlmClientError::ResponseTranslation(message) => error_response( StatusCode::BAD_GATEWAY, message, "upstream_error", "upstream_error", ), - LlmClientError::Timeout { source } => error_response( + // Timeout sources can carry the same request context as transport errors. + LlmClientError::Timeout { .. } => error_response( StatusCode::GATEWAY_TIMEOUT, - source.to_string(), + "upstream request timed out", "upstream_error", "upstream_timeout", ), @@ -1459,6 +1466,48 @@ mod tests { ); } + // Timeout sources may contain configured upstream URLs, so only a fixed message crosses the + // client-facing boundary. + #[test] + fn client_timeout_error_hides_source_details() { + const UPSTREAM_URL: &str = + "https://upstream.invalid/v1?key=CANARY_ADMIN_QUERY_KEY/responses"; + let error = LlmClientError::Timeout { + source: Box::new(std::io::Error::other(format!( + "request timed out for {UPSTREAM_URL}" + ))), + }; + + let response = client_error(&error); + let api_error = response + .extensions() + .get::() + .expect("client error metadata"); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!(api_error.message, "upstream request timed out"); + assert_eq!(api_error.error_type, "upstream_error"); + assert_eq!(api_error.code, "upstream_timeout"); + assert!(!api_error.message.contains(UPSTREAM_URL)); + } + + // Response-decoding details remain client-visible. + #[test] + fn client_invalid_response_error_preserves_source_detail() { + let error = LlmClientError::InvalidResponse { + source: Box::new(std::io::Error::other("response body was truncated")), + }; + + let response = client_error(&error); + let api_error = response + .extensions() + .get::() + .expect("client error metadata"); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!(api_error.message, "response body was truncated"); + assert_eq!(api_error.error_type, "upstream_error"); + assert_eq!(api_error.code, "upstream_error"); + } + // Canonical error text remains available without consuming the response body. #[test] fn error_response_carries_request_log_error() { diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index 10f58b65c..7aa94b2d4 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -2021,6 +2021,48 @@ async fn anthropic_stream_error_does_not_emit_success_terminal_events() -> TestR Ok(()) } +// A transport error must not expose credentials from the configured upstream URL. +#[tokio::test] +async fn transport_error_hides_credential_bearing_upstream_url() -> TestResult { + const CANARY: &str = "CANARY_ADMIN_QUERY_KEY"; + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + drop(listener); + let base_url = format!("http://{addr}/v1?key={CANARY}"); + let upstream_request_url = format!("{base_url}/chat/completions"); + let app = build_switchyard_router(random_state(&base_url, &[(ROUTE_MODEL, &["model/a"])])?); + + let response = send( + &app, + "POST", + "/v1/chat/completions", + Some(json!({ + "model": ROUTE_MODEL, + "messages": [{"role": "user", "content": "hello"}] + })), + ) + .await?; + + assert_eq!(response.status, StatusCode::BAD_GATEWAY); + let body = response.json()?; + assert_eq!(body["error"]["type"], "upstream_error"); + assert_eq!(body["error"]["code"], "upstream_error"); + let message = body["error"]["message"] + .as_str() + .ok_or("transport error message was not text")?; + assert_eq!(message, "upstream transport error"); + assert!( + !message.contains(CANARY), + "credential leaked in {message:?}" + ); + assert!( + !message.contains(&upstream_request_url), + "upstream URL leaked in {message:?}" + ); + Ok(()) +} + #[tokio::test] async fn request_and_upstream_errors_use_the_inbound_wire_format() -> TestResult { let (_upstream, app) = test_app(&[(ROUTE_MODEL, &["model/a"])]).await?;