Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions apis/src/http_hop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Praxis Contributors

//! Shared outbound hop-by-hop HTTP header policy.
//!
//! RFC 9110 hop-by-hop field names plus the obsolete `Proxy-Connection`
//! name. Outbound paths that copy headers onto a newly constructed request
//! must consult [`is_hop_by_hop`] and strip every field named by a
//! [`Connection`](http::header::CONNECTION) token list before dropping
//! `Connection` itself.
//!
//! Callers keep path-specific denylists (`Host`, `Content-Length`, cookies,
//! internal `x-praxis-*` prefixes) on top of this predicate. Do not copy
//! those extras into this module: they are not hop-by-hop.

use http::{HeaderMap, HeaderName};

/// Whether `name` is a hop-by-hop field that must not be copied onto a
/// newly constructed outbound request.
///
/// `name` must be a lowercase HTTP field name, as produced by
/// [`HeaderName::as_str`](http::HeaderName::as_str).
///
/// ```
/// assert!(praxis_ai_apis::http_hop::is_hop_by_hop("keep-alive"));
/// assert!(praxis_ai_apis::http_hop::is_hop_by_hop("proxy-connection"));
/// assert!(!praxis_ai_apis::http_hop::is_hop_by_hop("authorization"));
/// assert!(!praxis_ai_apis::http_hop::is_hop_by_hop("host"));
/// ```
#[must_use]
pub fn is_hop_by_hop(name: &str) -> bool {
matches!(
name,
"connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "proxy-connection"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
)
}

/// Iterate hop-by-hop field names listed in one `Connection` header value.
///
/// Splits on commas, trims ASCII whitespace, and drops empty tokens.
/// Token comparison is the caller's responsibility; see
/// [`connection_nominates`].
pub fn connection_tokens(value: &str) -> impl Iterator<Item = &str> {
value.split(',').map(str::trim).filter(|token| !token.is_empty())
}

/// Whether one `Connection` header value nominates `name` as hop-by-hop.
#[must_use]
pub fn connection_nominates(value: &str, name: &str) -> bool {
connection_tokens(value).any(|token| token.eq_ignore_ascii_case(name))
}

/// Whether any `Connection` value on `headers` nominates `name`.
///
/// Invalid (non-text) `Connection` values are ignored rather than failing
/// open: a header that cannot be parsed as tokens cannot nominate fields.
#[must_use]
pub fn connection_nominates_header(headers: &HeaderMap, name: &HeaderName) -> bool {
headers
.get_all(http::header::CONNECTION)
.iter()
.filter_map(|value| value.to_str().ok())
.any(|value| connection_nominates(value, name.as_str()))
}

#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use http::{HeaderMap, HeaderName, HeaderValue};

use super::{connection_nominates, connection_nominates_header, connection_tokens, is_hop_by_hop};

#[test]
fn hop_by_hop_covers_rfc_names_and_proxy_connection() {
for name in [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
] {
assert!(is_hop_by_hop(name), "{name} is hop-by-hop");
}
for name in ["authorization", "cookie", "content-length", "host", "x-custom"] {
assert!(!is_hop_by_hop(name), "{name} is not hop-by-hop");
}
}

#[test]
fn connection_tokens_split_trim_and_drop_empty() {
let tokens: Vec<&str> = connection_tokens(" x-smuggle , , Keep-Alive ").collect();
assert_eq!(
tokens,
["x-smuggle", "Keep-Alive"],
"Connection tokens should be comma-split and trimmed"
);
}

#[test]
fn connection_nominates_is_case_insensitive() {
assert!(
connection_nominates("X-Smuggle, close", "x-smuggle"),
"Connection tokens are case-insensitive"
);
assert!(
!connection_nominates("close", "x-custom"),
"unrelated names must not be treated as nominated"
);
}

#[test]
fn connection_nominates_header_reads_every_connection_value() {
let mut headers = HeaderMap::new();
headers.append(http::header::CONNECTION, HeaderValue::from_static("X-Hop-One"));
headers.append(
http::header::CONNECTION,
HeaderValue::from_static("x-hop-two, keep-alive"),
);
let hop_one = HeaderName::from_static("x-hop-one");
let hop_two = HeaderName::from_static("x-hop-two");
let custom = HeaderName::from_static("x-custom");
assert!(
connection_nominates_header(&headers, &hop_one),
"first Connection value should nominate x-hop-one"
);
assert!(
connection_nominates_header(&headers, &hop_two),
"second Connection value should nominate x-hop-two"
);
assert!(
!connection_nominates_header(&headers, &custom),
"x-custom is not listed in Connection"
);
}
}
5 changes: 3 additions & 2 deletions apis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
//! AI provider API types and persistence for Praxis.
//!
//! Contains provider-specific protocol types (OpenAI, Anthropic),
//! request classification, shared JSON body-mutation helpers, and
//! response storage backends.
//! request classification, shared hop-by-hop header sanitization,
//! JSON body-mutation helpers, and response storage backends.

pub mod anthropic;
pub mod callout_policy;
pub mod classifier;
pub mod http_hop;
pub mod json_body;
pub(crate) mod mcp_client;
pub mod openai;
Expand Down
37 changes: 30 additions & 7 deletions apis/src/mcp_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
mod tests;

use std::{
collections::HashMap,
collections::{HashMap, HashSet},
fmt,
net::{IpAddr, Ipv4Addr, SocketAddr},
time::Duration,
Expand Down Expand Up @@ -368,10 +368,12 @@ fn build_transport_config(
let mut header_map = HashMap::new();

if let Some(headers_obj) = headers.and_then(serde_json::Value::as_object) {
let nominated = connection_nominated_from_json(headers_obj);
for (key, value) in headers_obj {
if let Some(value_str) = value.as_str()
&& let Ok(name) = key.parse::<http::HeaderName>()
&& !is_blocked_mcp_header(&name)
&& !nominated.contains(&name)
&& let Ok(val) = http::HeaderValue::from_str(value_str)
{
header_map.insert(name, val);
Expand Down Expand Up @@ -548,23 +550,44 @@ fn build_pinned_client(resolved: &ResolvedMcpUrl) -> Result<reqwest::Client, Mcp
})
}

/// Field names listed by any `Connection` value in MCP tool-config headers.
fn connection_nominated_from_json(
headers_obj: &serde_json::Map<String, serde_json::Value>,
) -> HashSet<http::HeaderName> {
let mut nominated = HashSet::new();
for (key, value) in headers_obj {
let Ok(name) = key.parse::<http::HeaderName>() else {
continue;
};
if name != http::header::CONNECTION {
continue;
}
let Some(value) = value.as_str() else {
continue;
};
for token in crate::http_hop::connection_tokens(value) {
if let Ok(nominated_name) = token.parse::<http::HeaderName>() {
nominated.insert(nominated_name);
}
}
}
nominated
}

/// Headers that must not pass through from client-supplied MCP
/// tool config into the proxy's outbound MCP transport.
fn is_blocked_mcp_header(name: &http::HeaderName) -> bool {
if crate::http_hop::is_hop_by_hop(name.as_str()) {
return true;
}
if matches!(
*name,
http::header::AUTHORIZATION
| http::header::CONNECTION
| http::header::CONTENT_LENGTH
| http::header::COOKIE
| http::header::FORWARDED
| http::header::HOST
| http::header::PROXY_AUTHORIZATION
| http::header::SET_COOKIE
| http::header::TE
| http::header::TRAILER
| http::header::TRANSFER_ENCODING
| http::header::UPGRADE
) {
return true;
}
Expand Down
89 changes: 89 additions & 0 deletions apis/src/mcp_client/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ fn hop_by_hop_headers_stripped_from_mcp_headers() {
"content-length": "999",
"transfer-encoding": "chunked",
"connection": "keep-alive",
"keep-alive": "timeout=5",
"proxy-connection": "keep-alive",
"te": "trailers",
"trailer": "Foo",
"upgrade": "websocket",
"proxy-authorization": "Basic creds",
"proxy-authenticate": "Basic realm=\"mcp\"",
"x-custom": "safe"
});
let config = build_transport_config("http://api.example.com/mcp", Some(&headers), None).unwrap();
Expand All @@ -98,6 +101,92 @@ fn hop_by_hop_headers_stripped_from_mcp_headers() {
);
}

#[test]
fn keep_alive_and_proxy_connection_headers_stripped_from_mcp_headers() {
let headers = serde_json::json!({
"keep-alive": "timeout=5",
"proxy-connection": "keep-alive",
"connection": "keep-alive",
"x-custom": "safe"
});
let config = build_transport_config("http://api.example.com/mcp", Some(&headers), None).unwrap();

assert_eq!(config.custom_headers.len(), 1, "only safe header should remain");
assert!(
config
.custom_headers
.contains_key(&http::HeaderName::from_static("x-custom")),
"x-custom should pass through"
);
assert!(
!config
.custom_headers
.contains_key(&http::HeaderName::from_static("keep-alive")),
"keep-alive must not reach outbound MCP transport"
);
assert!(
!config
.custom_headers
.contains_key(&http::HeaderName::from_static("proxy-connection")),
"proxy-connection must not reach outbound MCP transport"
);
assert!(
!config.custom_headers.contains_key(&http::header::CONNECTION),
"connection must stay blocked"
);
}

#[test]
fn connection_nominated_headers_stripped_from_mcp_headers() {
let headers = serde_json::json!({
"connection": "x-smuggle, Keep-Alive",
"x-smuggle": "secret",
"x-custom": "safe"
});
let config = build_transport_config("http://api.example.com/mcp", Some(&headers), None).unwrap();

assert_eq!(config.custom_headers.len(), 1, "only safe header should remain");
assert!(
config
.custom_headers
.contains_key(&http::HeaderName::from_static("x-custom")),
"x-custom is not listed in Connection and should pass through"
);
assert!(
!config
.custom_headers
.contains_key(&http::HeaderName::from_static("x-smuggle")),
"fields named by Connection must not reach outbound MCP transport"
);
assert!(
!config.custom_headers.contains_key(&http::header::CONNECTION),
"connection itself must stay blocked"
);
}

#[test]
fn proxy_authenticate_stripped_from_mcp_headers() {
let headers = serde_json::json!({
"proxy-authenticate": "Basic realm=\"mcp\"",
"x-custom": "safe"
});
let config = build_transport_config("http://api.example.com/mcp", Some(&headers), None).unwrap();

assert_eq!(config.custom_headers.len(), 1, "only safe header should remain");
assert!(
config
.custom_headers
.contains_key(&http::HeaderName::from_static("x-custom")),
"x-custom should pass through"
);
assert!(
!config
.custom_headers
.contains_key(&http::HeaderName::from_static("proxy-authenticate")),
"proxy-authenticate is hop-by-hop and must not reach outbound MCP transport"
);
}

#[test]
fn reserved_internal_headers_stripped_from_mcp_headers() {
let headers = serde_json::json!({
Expand Down
Loading
Loading