fix: block redirect header leaks (#936)
This commit is contained in:
parent
64d22def02
commit
496902b9cf
1 changed files with 136 additions and 1 deletions
|
|
@ -299,9 +299,13 @@ impl StreamableHttpClientTransport<reqwest::Client> {
|
||||||
/// Disables idle connection pooling to avoid ~40 ms stalls caused by
|
/// Disables idle connection pooling to avoid ~40 ms stalls caused by
|
||||||
/// TCP Delayed ACK on Linux when the previous response body was not
|
/// TCP Delayed ACK on Linux when the previous response body was not
|
||||||
/// fully consumed before the pool attempts to reuse the connection.
|
/// fully consumed before the pool attempts to reuse the connection.
|
||||||
|
///
|
||||||
|
/// Automatic redirects are disabled so caller-supplied custom headers
|
||||||
|
/// cannot be replayed to a redirect target.
|
||||||
fn default_http_client() -> reqwest::Client {
|
fn default_http_client() -> reqwest::Client {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.pool_max_idle_per_host(0)
|
.pool_max_idle_per_host(0)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.build()
|
.build()
|
||||||
.expect("failed to build default reqwest client")
|
.expect("failed to build default reqwest client")
|
||||||
}
|
}
|
||||||
|
|
@ -313,7 +317,7 @@ mod tests {
|
||||||
|
|
||||||
use super::parse_json_rpc_error;
|
use super::parse_json_rpc_error;
|
||||||
use crate::{
|
use crate::{
|
||||||
model::JsonRpcMessage,
|
model::{ClientJsonRpcMessage, ClientRequest, JsonRpcMessage, PingRequest, RequestId},
|
||||||
transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError},
|
transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -359,4 +363,135 @@ mod tests {
|
||||||
fn parse_json_rpc_error_rejects_non_error_bodies(#[case] body: &str) {
|
fn parse_json_rpc_error_rejects_non_error_bodies(#[case] body: &str) {
|
||||||
assert!(parse_json_rpc_error(body).is_none());
|
assert!(parse_json_rpc_error(body).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_http_client_does_not_leak_custom_headers_to_redirect_target()
|
||||||
|
-> anyhow::Result<()> {
|
||||||
|
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router, extract::State, http::StatusCode, response::IntoResponse, routing::post,
|
||||||
|
};
|
||||||
|
use http::{HeaderMap, HeaderName, HeaderValue, header::LOCATION};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use super::StreamableHttpClientTransport;
|
||||||
|
use crate::transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError};
|
||||||
|
|
||||||
|
const API_KEY_HEADER: &str = "x-api-key";
|
||||||
|
const API_KEY_VALUE: &str = "secret";
|
||||||
|
|
||||||
|
type CapturedHeader = Arc<Mutex<Option<String>>>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RedirectState {
|
||||||
|
location: String,
|
||||||
|
captured_header: CapturedHeader,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn capture_api_key_header(headers: &HeaderMap, captured_header: &CapturedHeader) {
|
||||||
|
if let Some(value) = headers
|
||||||
|
.get(API_KEY_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
{
|
||||||
|
*captured_header.lock().await = Some(value.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn redirect_handler(
|
||||||
|
State(state): State<RedirectState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
capture_api_key_header(&headers, &state.captured_header).await;
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::TEMPORARY_REDIRECT,
|
||||||
|
[(LOCATION, state.location)],
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn redirected_handler(
|
||||||
|
State(captured_header): State<CapturedHeader>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
capture_api_key_header(&headers, &captured_header).await;
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(http::header::CONTENT_TYPE, "application/json")],
|
||||||
|
r#"{"jsonrpc":"2.0","id":1,"result":{}}"#,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirected_header = Arc::new(Mutex::new(None));
|
||||||
|
let redirected_listener =
|
||||||
|
tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?;
|
||||||
|
let redirected_addr = redirected_listener.local_addr()?;
|
||||||
|
let redirected_server = tokio::spawn({
|
||||||
|
let redirected_header = redirected_header.clone();
|
||||||
|
async move {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/capture", post(redirected_handler))
|
||||||
|
.with_state(redirected_header);
|
||||||
|
axum::serve(redirected_listener, app).await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let original_header = Arc::new(Mutex::new(None));
|
||||||
|
let redirect_listener =
|
||||||
|
tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?;
|
||||||
|
let redirect_addr = redirect_listener.local_addr()?;
|
||||||
|
let redirect_server = tokio::spawn({
|
||||||
|
let state = RedirectState {
|
||||||
|
location: format!("http://{redirected_addr}/capture"),
|
||||||
|
captured_header: original_header.clone(),
|
||||||
|
};
|
||||||
|
async move {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/mcp", post(redirect_handler))
|
||||||
|
.with_state(state);
|
||||||
|
axum::serve(redirect_listener, app).await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut custom_headers = HashMap::new();
|
||||||
|
custom_headers.insert(
|
||||||
|
HeaderName::from_static(API_KEY_HEADER),
|
||||||
|
HeaderValue::from_static(API_KEY_VALUE),
|
||||||
|
);
|
||||||
|
let message = ClientJsonRpcMessage::request(
|
||||||
|
ClientRequest::PingRequest(PingRequest::default()),
|
||||||
|
RequestId::Number(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = StreamableHttpClientTransport::<reqwest::Client>::default_http_client();
|
||||||
|
let result = client
|
||||||
|
.post_message(
|
||||||
|
Arc::<str>::from(format!("http://{redirect_addr}/mcp")),
|
||||||
|
message,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
custom_headers,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
result,
|
||||||
|
Err(StreamableHttpError::UnexpectedServerResponse(_))
|
||||||
|
),
|
||||||
|
"redirect response should be returned to the transport, got {result:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(original_header.lock().await.as_deref(), Some(API_KEY_VALUE));
|
||||||
|
assert!(
|
||||||
|
redirected_header.lock().await.is_none(),
|
||||||
|
"custom headers should not be sent to redirect targets"
|
||||||
|
);
|
||||||
|
|
||||||
|
redirect_server.abort();
|
||||||
|
redirected_server.abort();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue