mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
fix(network-proxy): reject mismatched host headers (#13275)
## Summary - reject plain HTTP absolute-form requests whose Host header does not match the request target authority - add host/port-aware Host header validation for non-default ports - add regression coverage for mismatched Host forwarding and validator edge cases
This commit is contained in:
@@ -48,6 +48,8 @@ use rama_http::Request;
|
||||
use rama_http::Response;
|
||||
use rama_http::StatusCode;
|
||||
use rama_http::header;
|
||||
use rama_http::headers::HeaderMapExt;
|
||||
use rama_http::headers::Host;
|
||||
use rama_http::layer::remove_header::RemoveResponseHeaderLayer;
|
||||
use rama_http::matcher::MethodMatcher;
|
||||
use rama_http_backend::client::proxy::layer::HttpProxyConnector;
|
||||
@@ -55,7 +57,6 @@ use rama_http_backend::server::HttpServer;
|
||||
use rama_http_backend::server::layer::upgrade::UpgradeLayer;
|
||||
use rama_http_backend::server::layer::upgrade::Upgraded;
|
||||
use rama_net::Protocol;
|
||||
use rama_net::address::HostWithOptPort;
|
||||
use rama_net::address::ProxyAddress;
|
||||
use rama_net::client::ConnectorService;
|
||||
use rama_net::client::EstablishedClientConnection;
|
||||
@@ -562,18 +563,27 @@ async fn http_plain_proxy(
|
||||
};
|
||||
}
|
||||
|
||||
let authority = match RequestContext::try_from(&req).map(|ctx| ctx.host_with_port()) {
|
||||
Ok(authority) => authority,
|
||||
let request_ctx = match RequestContext::try_from(&req) {
|
||||
Ok(request_ctx) => request_ctx,
|
||||
Err(err) => {
|
||||
warn!("missing host: {err}");
|
||||
return Ok(text_response(StatusCode::BAD_REQUEST, "missing host"));
|
||||
}
|
||||
};
|
||||
let authority = request_ctx.host_with_port();
|
||||
let host = normalize_host(&authority.host.to_string());
|
||||
let port = authority.port;
|
||||
if let Err(err) = validate_plain_http_host_header(&req, &authority) {
|
||||
warn!("HTTP request host mismatch: {err}");
|
||||
return Ok(text_response(StatusCode::BAD_REQUEST, "host mismatch"));
|
||||
if let Err(reason) = validate_absolute_form_host_header(&req, &request_ctx) {
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
let host_header = req
|
||||
.headers()
|
||||
.get(header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("<missing>");
|
||||
warn!(
|
||||
"request rejected due to mismatched Host header (client={client}, target={host}:{port}, host_header={host_header}, reason={reason})"
|
||||
);
|
||||
return Ok(text_response(StatusCode::BAD_REQUEST, reason));
|
||||
}
|
||||
let enabled = match app_state
|
||||
.enabled()
|
||||
@@ -757,6 +767,39 @@ fn client_addr<T: ExtensionsRef>(input: &T) -> Option<String> {
|
||||
.map(|info| info.peer_addr().to_string())
|
||||
}
|
||||
|
||||
fn validate_absolute_form_host_header(
|
||||
req: &Request,
|
||||
request_ctx: &RequestContext,
|
||||
) -> Result<(), &'static str> {
|
||||
if req.uri().scheme_str().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(host_header) = req
|
||||
.headers()
|
||||
.typed_try_get::<Host>()
|
||||
.map_err(|_| "invalid Host header")?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if host_header.0.host != request_ctx.authority.host {
|
||||
return Err("Host header does not match request target");
|
||||
}
|
||||
|
||||
if let Some(host_port) = host_header.0.port {
|
||||
if Some(host_port) != request_ctx.authority.port {
|
||||
return Err("Host header does not match request target");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !request_ctx.authority_has_default_port() {
|
||||
return Err("Host header does not match request target");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
fn remove_hop_by_hop_request_headers(headers: &mut HeaderMap) {
|
||||
while let Some(raw_connection) = headers.get(header::CONNECTION).cloned() {
|
||||
headers.remove(header::CONNECTION);
|
||||
@@ -792,45 +835,6 @@ fn remove_hop_by_hop_request_headers(headers: &mut HeaderMap) {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_plain_http_host_header(
|
||||
req: &Request,
|
||||
target: &rama_net::address::HostWithPort,
|
||||
) -> std::result::Result<(), &'static str> {
|
||||
// Only enforce this in absolute-form requests. Origin-form requests use the Host header as the
|
||||
// routing authority, so there is no separate target authority to compare against.
|
||||
if req.uri().authority().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(raw_host) = req.headers().get(header::HOST) else {
|
||||
return Ok(());
|
||||
};
|
||||
let raw_host = raw_host.to_str().map_err(|_| "invalid Host header")?;
|
||||
let parsed = HostWithOptPort::try_from(raw_host).map_err(|_| "invalid Host header")?;
|
||||
|
||||
let target_host = normalize_host(&target.host.to_string());
|
||||
let request_host = normalize_host(&parsed.host.to_string());
|
||||
if request_host.is_empty() || request_host != target_host {
|
||||
return Err("request Host header host does not match target authority");
|
||||
}
|
||||
|
||||
let expected_port = target.port;
|
||||
let request_port = match parsed.port {
|
||||
Some(port) => port,
|
||||
None => match req.uri().scheme_str() {
|
||||
Some("http") => 80,
|
||||
Some("https") => 443,
|
||||
Some(_) | None => expected_port,
|
||||
},
|
||||
};
|
||||
|
||||
if request_port != expected_port {
|
||||
return Err("request Host header port does not match target authority");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_blocked(host: &str, reason: &str, details: Option<&PolicyDecisionDetails<'_>>) -> Response {
|
||||
let (message, decision, source, protocol, port) = details
|
||||
.map(|details| {
|
||||
@@ -1136,6 +1140,51 @@ mod tests {
|
||||
assert_eq!(response.unwrap().status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_absolute_form_host_header_allows_matching_default_port() {
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("http://example.com/")
|
||||
.header("host", "example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
validate_absolute_form_host_header(&req, &RequestContext::try_from(&req).unwrap(),),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_absolute_form_host_header_rejects_mismatched_host() {
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("http://raw.githubusercontent.com/")
|
||||
.header("host", "api.github.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
validate_absolute_form_host_header(&req, &RequestContext::try_from(&req).unwrap(),),
|
||||
Err("Host header does not match request target")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_absolute_form_host_header_rejects_missing_non_default_port() {
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("http://example.com:8080/")
|
||||
.header("host", "example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
validate_absolute_form_host_header(&req, &RequestContext::try_from(&req).unwrap(),),
|
||||
Err("Host header does not match request target")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_hop_by_hop_request_headers_keeps_forwarding_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
Reference in New Issue
Block a user