diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1eb38f3286..51855f9993 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4229,6 +4229,8 @@ name = "codex-network-proxy" version = "0.0.0" dependencies = [ "anyhow", + "aws-credential-types", + "aws-sigv4", "base64 0.22.1", "chrono", "clap", diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index 1fc4a89b6b..df3c80240b 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -18,6 +18,8 @@ workspace = true [dependencies] anyhow = { workspace = true } +aws-credential-types = { workspace = true } +aws-sigv4 = { workspace = true } base64 = { workspace = true } clap = { workspace = true, features = ["derive"] } chrono = { workspace = true } diff --git a/codex-rs/network-proxy/src/aws_sigv4.rs b/codex-rs/network-proxy/src/aws_sigv4.rs new file mode 100644 index 0000000000..4fc2cd656b --- /dev/null +++ b/codex-rs/network-proxy/src/aws_sigv4.rs @@ -0,0 +1,506 @@ +//! Trusted, destination-scoped AWS signing after network and HTTP hook authorization. +//! Credentials never enter the workload; unsupported streaming signatures fail closed. + +use anyhow::Result; +use anyhow::anyhow; +use anyhow::ensure; +use aws_credential_types::Credentials; +use aws_sigv4::http_request::PayloadChecksumKind; +use aws_sigv4::http_request::PercentEncodingMode; +use aws_sigv4::http_request::SignableBody; +use aws_sigv4::http_request::SignableRequest; +use aws_sigv4::http_request::SigningSettings; +use aws_sigv4::http_request::UriPathNormalizationMode; +use aws_sigv4::http_request::sign; +use aws_sigv4::sign::v4; +use codex_utils_absolute_path::AbsolutePathBuf; +use rama_core::extensions::ExtensionsMut; +use rama_core::extensions::ExtensionsRef; +use rama_http::Body; +use rama_http::HeaderMap; +use rama_http::HeaderValue; +use rama_http::Request; +use rama_http::body::StreamingBody as _; +use rama_http::header::HeaderName; +use rama_http::headers::Connection; +use rama_http::headers::HeaderMapExt as _; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use sha2::Digest as _; +use std::time::SystemTime; + +/// A credential source in the trusted proxy process, never in the executor environment. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProxySecretSource { + EnvVar(String), + File(String), +} + +impl ProxySecretSource { + pub(crate) fn validate(&self) -> Result<()> { + match self { + Self::EnvVar(name) if !name.is_empty() && !name.contains(['=', '\0']) => Ok(()), + Self::File(path) if std::path::Path::new(path).is_absolute() => Ok(()), + Self::EnvVar(_) | Self::File(_) => Err(anyhow!("invalid proxy secret source")), + } + } + + pub(crate) fn resolve( + &self, + env: &impl Fn(&str) -> Option, + file: &impl Fn(&AbsolutePathBuf) -> Result, + ) -> Result { + self.validate()?; + let value = match self { + Self::EnvVar(name) => env(name).ok_or_else(|| anyhow!("missing proxy credential"))?, + Self::File(path) => file(&AbsolutePathBuf::from_absolute_path(path)?)?, + }; + if value.is_empty() || value.len() > 65_536 { + return Err(anyhow!("proxy credential is empty or exceeds 64 KiB")); + } + Ok(value) + } +} + +/// AWS SigV4 action selected by an exact-host, method, and path MITM hook. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AwsSigV4Config { + /// Nonsecret access key ID installed in the guest AWS profile. + pub placeholder_access_key_id: String, + pub region: String, + pub service: String, + pub access_key_id: ProxySecretSource, + pub secret_access_key: ProxySecretSource, + pub session_token: Option, + #[serde(default = "https_port")] + pub port: u16, +} + +pub(crate) fn https_port() -> u16 { + 443 +} + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct AwsSigV4Signer { + config: AwsSigV4Config, + credentials: Credentials, +} + +impl std::fmt::Debug for AwsSigV4Signer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AwsSigV4Signer") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl AwsSigV4Config { + pub(crate) fn validate(&self) -> Result<()> { + ensure!( + !self.placeholder_access_key_id.is_empty() + && self.placeholder_access_key_id.len() <= 128 + && self + .placeholder_access_key_id + .bytes() + .all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') }), + "invalid AWS placeholder access key ID" + ); + for value in [&self.region, &self.service] { + if value.is_empty() + || !value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-') + { + return Err(anyhow!("invalid AWS signing region or service")); + } + } + ensure!(self.port != 0, "invalid AWS signing port"); + self.access_key_id.validate()?; + self.secret_access_key.validate()?; + if let Some(token) = &self.session_token { + token.validate()?; + } + Ok(()) + } + + pub(crate) fn compile( + &self, + env: &impl Fn(&str) -> Option, + file: &impl Fn(&AbsolutePathBuf) -> Result, + ) -> Result { + self.validate()?; + let credentials = Credentials::new( + self.access_key_id.resolve(env, file)?, + self.secret_access_key.resolve(env, file)?, + self.session_token + .as_ref() + .map(|source| source.resolve(env, file)) + .transpose()?, + /*expires_after*/ None, + "network-proxy", + ); + ensure!( + [ + Some(credentials.access_key_id()), + Some(credentials.secret_access_key()), + credentials.session_token(), + ] + .into_iter() + .flatten() + .all(|value| !self.placeholder_access_key_id.contains(value)), + "AWS guest placeholders overlap a real credential" + ); + Ok(AwsSigV4Signer { + config: self.clone(), + credentials, + }) + } +} + +#[derive(Clone)] +struct UnsupportedSigningKeys(Vec); + +impl std::fmt::Debug for UnsupportedSigningKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("UnsupportedSigningKeys") + } +} + +// Keep only the guest key IDs needed to deny a selected request after hop-header removal. +pub(crate) fn preserve_signing_headers(req: &mut Request) { + let headers = req.headers(); + if headers.contains_key("authorization") + && (unsupported_signing_headers(headers) + || headers + .typed_get::() + .is_some_and(|connection| connection.contains_header("authorization"))) + { + let keys = headers + .get_all("authorization") + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(authorization_keys) + .map(str::to_owned) + .collect(); + req.extensions_mut().insert(UnsupportedSigningKeys(keys)); + } +} + +fn authorization_keys(auth: &str) -> impl Iterator { + let fields = match auth.split_once(' ') { + Some(("AWS4-HMAC-SHA256" | "AWS4-ECDSA-P256-SHA256", fields)) => fields, + _ => "", + }; + fields + .split(',') + .filter_map(|field| field.trim().strip_prefix("Credential=")?.split('/').next()) +} + +fn unsupported_signing_headers(headers: &HeaderMap) -> bool { + !crate::body_budget::supported_transfer_framing(headers) + || crate::body_budget::is_amazon_event_stream(headers) + || headers.contains_key("trailer") + || headers.contains_key("x-amz-trailer") + || headers.contains_key("x-amz-decoded-content-length") + || headers.get_all("content-encoding").iter().any(|value| { + value.to_str().map_or(true, |value| { + value + .split(',') + .any(|coding| coding.trim().eq_ignore_ascii_case("aws-chunked")) + }) + }) + || headers + .get_all("x-amz-content-sha256") + .iter() + .any(|value| value.as_bytes().starts_with(b"STREAMING-")) +} + +/// Client signing inputs captured before any hook changes the request headers. +pub(crate) struct GuestSignature { + time: SystemTime, + signed_headers: Vec, + unsigned_payload: bool, +} + +impl AwsSigV4Signer { + pub(crate) fn plan(&self, req: &Request) -> Result> { + let placeholder = &self.config.placeholder_access_key_id; + let selects_pair = |scope: &str| scope.split('/').next() == Some(placeholder.as_str()); + let mut query = + url::form_urlencoded::parse(req.uri().query().unwrap_or_default().as_bytes()); + ensure!( + !query.clone().any(|(key, value)| { + key.eq_ignore_ascii_case("x-amz-credential") && selects_pair(&value) + }), + "cannot re-sign a presigned AWS URL" + ); + ensure!( + req.extensions() + .get::() + .is_none_or(|keys| !keys.0.contains(placeholder)), + "unsupported AWS signing headers" + ); + let auths = req.headers().get_all("authorization"); + let selected = auths + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|auth| authorization_keys(auth).any(|key| key == placeholder)); + let Some(auth) = selected else { + return Ok(None); + }; + ensure!( + auths.iter().count() == 1 + && !query.any(|(key, _)| key.eq_ignore_ascii_case("x-amz-signature")) + && !unsupported_signing_headers(req.headers()), + "ambiguous or unsupported AWS signing request" + ); + let fields = auth + .strip_prefix("AWS4-HMAC-SHA256 ") + .ok_or_else(|| anyhow!("unsupported AWS signature algorithm"))?; + let mut scope = None; + let mut signed = None; + let mut signature = None; + for field in fields.split(',') { + match field.trim().split_once('=') { + Some(("Credential", value)) if scope.is_none() => scope = Some(value), + Some(("SignedHeaders", value)) if signed.is_none() => signed = Some(value), + Some(("Signature", value)) if signature.is_none() => signature = Some(value), + _ => return Err(anyhow!("invalid AWS authorization field")), + } + } + ensure!( + signature.is_some_and(|v| v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit())), + "invalid AWS signature" + ); + let dates = req.headers().get_all("x-amz-date"); + let mut dates = dates.iter(); + let date = dates + .next() + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow!("missing AWS signing date"))?; + let time = chrono::NaiveDateTime::parse_from_str(date, "%Y%m%dT%H%M%SZ") + .map_err(|_| anyhow!("invalid AWS signing date"))?; + ensure!( + dates.next().is_none() + && date.len() == 16 + && date.is_ascii() + && scope.is_some_and(|s| s.split('/').eq([ + placeholder.as_str(), + &date[..8], + &self.config.region, + &self.config.service, + "aws4_request", + ])), + "AWS credential scope does not match the configured destination" + ); + let signed_headers = signed + .ok_or_else(|| anyhow!("missing AWS signed headers"))? + .split(';') + .map(|name| { + HeaderName::from_lowercase(name.as_bytes()) + .map_err(|_| anyhow!("invalid AWS signed header")) + }) + .collect::>>()?; + ensure!( + signed_headers + .windows(2) + .all(|w| w[0].as_str() < w[1].as_str()) + && signed_headers.contains(&rama_http::header::HOST) + && signed_headers.iter().any(|name| name == "x-amz-date") + && signed_headers.iter().all(|name| { + name != "authorization" + && (name == "host" + || (name == "x-amz-security-token" + && self.credentials.session_token().is_some()) + || req.headers().contains_key(name)) + }), + "invalid AWS signed header list" + ); + let hashes = req.headers().get_all("x-amz-content-sha256"); + ensure!(hashes.iter().count() <= 1, "ambiguous AWS payload hash"); + let unsigned_payload = hashes.iter().any(|v| v == "UNSIGNED-PAYLOAD"); + ensure!( + !unsigned_payload || self.config.service != "aoss", + "OpenSearch Serverless requires a signed payload" + ); + Ok(Some(GuestSignature { + time: time.and_utc().into(), + signed_headers, + unsigned_payload, + })) + } + + pub(crate) fn sign(&self, req: Request, guest: &GuestSignature) -> Result { + ensure!( + req.uri().scheme_str() == Some("https") + && req.uri().port_u16().unwrap_or(443) == self.config.port + && !unsupported_signing_headers(req.headers()), + "AWS signing requires the configured HTTPS origin and supported framing" + ); + let had_payload_hash = req.headers().contains_key("x-amz-content-sha256"); + let (mut parts, body, payload_hash) = if guest.unsigned_payload { + let (parts, body) = req.into_parts(); + ensure!( + !parts.headers.contains_key("content-length") + || body + .size_hint() + .exact() + .and_then(|n| usize::try_from(n).ok()) + .is_some_and(|n| crate::body_budget::content_length_matches( + &parts.headers, + n + )), + "ambiguous AWS unsigned payload length" + ); + (parts, body, "UNSIGNED-PAYLOAD".to_string()) + } else { + let (parts, body) = req.into_parts(); + ensure!( + body.is_end_stream() + && crate::body_budget::content_length_matches(&parts.headers, /*size*/ 0), + "AWS signing requires an empty or explicitly unsigned payload" + ); + let digest = format!("{:x}", sha2::Sha256::digest([])); + (parts, Body::empty(), digest) + }; + if ["connection", "proxy-connection", "upgrade", "te"] + .iter() + .any(|name| parts.headers.contains_key(*name)) + { + return Err(anyhow!( + "AWS signing cannot forward connection-specific headers" + )); + } + // Rama removes these HTTP/1 fields when the upstream negotiates HTTP/2. + // They have no meaning for an ordinary signed request. + for name in ["keep-alive", "sec-websocket-key", "transfer-encoding"] { + parts.headers.remove(name); + } + // Recreate signing headers from the selected pair and the captured guest signature. + for name in [ + "authorization", + "x-amz-date", + "x-amz-security-token", + "x-amz-content-sha256", + ] { + parts.headers.remove(name); + } + // HTTP/2-to-HTTP/1.1 forwarding joins Cookie fields with "; ". Sign that + // representation now so upstream protocol negotiation cannot change it. + let cookies = parts + .headers + .get_all("cookie") + .iter() + .map(HeaderValue::to_str) + .collect::, _>>() + .map_err(|_| anyhow!("AWS signing requires textual headers"))?; + if cookies.len() > 1 { + let mut cookie = HeaderValue::from_str(&cookies.join("; ")) + .map_err(|_| anyhow!("invalid AWS signing cookie header"))?; + cookie.set_sensitive( + parts + .headers + .get_all("cookie") + .iter() + .any(HeaderValue::is_sensitive), + ); + parts.headers.insert("cookie", cookie); + } + let mut settings = SigningSettings::default(); + settings.excluded_headers = None; + if matches!( + self.config.service.as_str(), + "s3" | "s3-object-lambda" | "s3-outposts" + ) { + settings.percent_encoding_mode = PercentEncodingMode::Single; + settings.uri_path_normalization_mode = UriPathNormalizationMode::Disabled; + } + // S3 and AOSS need the hash on the wire even when the client did not sign that field. + if had_payload_hash + || matches!( + self.config.service.as_str(), + "s3" | "s3-object-lambda" | "s3-outposts" | "aoss" + ) + { + parts.headers.insert( + "x-amz-content-sha256", + HeaderValue::from_str(&payload_hash)?, + ); + } + if guest + .signed_headers + .iter() + .any(|h| h == "x-amz-content-sha256") + { + settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; + } + // Error messages are deliberately static: SDK errors can include request or credential data. + for name in &guest.signed_headers { + match name.as_str() { + "host" | "x-amz-date" | "x-amz-content-sha256" => {} + "x-amz-security-token" if self.credentials.session_token().is_some() => {} + _ => ensure!( + parts.headers.contains_key(name), + "AWS signed header was removed" + ), + } + } + let headers = parts + .headers + .iter() + .filter(|(name, _)| guest.signed_headers.contains(name)) + .map(|(name, value)| { + value + .to_str() + .map(|value| (name.as_str(), value)) + .map_err(|_| anyhow!("AWS signing requires textual headers")) + }) + .collect::>>()?; + let uri = parts.uri.to_string(); + let request = SignableRequest::new( + parts.method.as_str(), + &uri, + headers.into_iter(), + if guest.unsigned_payload { + SignableBody::UnsignedPayload + } else { + SignableBody::Precomputed(payload_hash) + }, + ) + .map_err(|_| anyhow!("invalid AWS signing request"))?; + let identity = self.credentials.clone().into(); + let params = v4::SigningParams::builder() + .identity(&identity) + .region(&self.config.region) + .name(&self.config.service) + .time(guest.time) + .settings(settings) + .build() + .map_err(|_| anyhow!("invalid AWS signing parameters"))?; + // The SDK's TRACE events include canonical headers (including session tokens). + // Signing is synchronous: suppress only those SDK events, without changing proxy audit logs. + let (instructions, _) = + tracing::dispatcher::with_default(&tracing::Dispatch::none(), || { + sign(request, ¶ms.into()) + }) + .map_err(|_| anyhow!("AWS signing failed"))? + .into_parts(); + for header in instructions.into_parts().0 { + let mut value = HeaderValue::from_str(header.value()) + .map_err(|_| anyhow!("invalid AWS signed header"))?; + value.set_sensitive( + header.sensitive() + || matches!(header.name(), "authorization" | "x-amz-security-token"), + ); + parts.headers.insert(header.name(), value); + } + Ok(Request::from_parts(parts, body)) + } +} + +#[cfg(test)] +#[path = "aws_sigv4_tests.rs"] +pub(crate) mod tests; diff --git a/codex-rs/network-proxy/src/aws_sigv4_tests.rs b/codex-rs/network-proxy/src/aws_sigv4_tests.rs new file mode 100644 index 0000000000..94fe412ead --- /dev/null +++ b/codex-rs/network-proxy/src/aws_sigv4_tests.rs @@ -0,0 +1,141 @@ +//! SigV4 interoperability vectors and rejection boundaries for the actual signing action. + +use super::*; +use pretty_assertions::assert_eq; +use rama_http::HeaderMap; + +fn config(service: &str) -> AwsSigV4Config { + AwsSigV4Config { + placeholder_access_key_id: "MASKEDACCESSKEY".into(), + region: "us-east-1".into(), + service: service.into(), + access_key_id: ProxySecretSource::EnvVar("ACCESS_KEY".into()), + secret_access_key: ProxySecretSource::EnvVar("SECRET_KEY".into()), + session_token: None, + port: 443, + } +} + +fn signer(config: AwsSigV4Config) -> AwsSigV4Signer { + config + .compile( + &|name| { + Some( + match name { + "ACCESS_KEY" => "AKIDEXAMPLE", + "SECRET_KEY" => "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "SESSION_TOKEN" => "secret-session-token", + _ => panic!("unexpected credential source"), + } + .into(), + ) + }, + &|_| panic!("unexpected file lookup"), + ) + .unwrap() +} + +pub(crate) fn guest_authorization(service: &str, signed_headers: &str) -> String { + format!( + "AWS4-HMAC-SHA256 Credential=MASKEDACCESSKEY/20150830/us-east-1/{service}/aws4_request, SignedHeaders={signed_headers}, Signature={:064x}", + 1 + ) +} + +fn resign(signer: &AwsSigV4Signer, mut req: Request, headers: &str) -> Result { + let auth = guest_authorization(&signer.config.service, headers); + req.headers_mut() + .insert("authorization", HeaderValue::from_str(&auth)?); + req.headers_mut() + .insert("x-amz-date", HeaderValue::from_static("20150830T123600Z")); + let guest = signer.plan(&req)?.expect("selected guest credential"); + signer.sign(req, &guest) +} + +#[test] +fn matches_aws_published_iam_signature_vector() { + // AWS Signature Version 4 ListUsers example, 2015-08-30. + let request = Request::builder() + .method("GET") + .uri("https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08") + .header("host", "iam.amazonaws.com") + .header( + "content-type", + "application/x-www-form-urlencoded; charset=utf-8", + ) + .body(Body::empty()) + .unwrap(); + let signed = resign( + &signer(config("iam")), + request, + "content-type;host;x-amz-date", + ) + .unwrap(); + assert_eq!( + signed.headers()["authorization"], + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7" + ); +} + +#[tokio::test] +async fn streams_explicitly_unsigned_s3_uploads() { + let mut config = config("s3"); + config.session_token = Some(ProxySecretSource::EnvVar("SESSION_TOKEN".into())); + let signer = signer(config); + let upload = Request::builder() + .uri("https://bucket.s3.amazonaws.com/upload") + .header("x-amz-content-sha256", "UNSIGNED-PAYLOAD") + .header("x-unsigned-metadata", "forward only") + .body(Body::from(vec![b'x'; 1025])) + .unwrap(); + let upload = resign(&signer, upload, "host;x-amz-date").unwrap(); + assert_eq!(upload.headers()["x-amz-content-sha256"], "UNSIGNED-PAYLOAD"); + let authorization = upload.headers()["authorization"].to_str().unwrap(); + assert!(authorization.contains("SignedHeaders=host;x-amz-date;x-amz-security-token,")); + use rama_http::body::util::BodyExt as _; + assert_eq!( + upload.into_body().collect().await.unwrap().to_bytes().len(), + 1025 + ); +} + +#[test] +fn rejects_wrong_transport_scope_streaming_and_nonempty_bodies() { + let signer = signer(config("s3")); + let valid = "https://bucket.s3.amazonaws.com/key"; + for uri in [ + "http://bucket.s3.amazonaws.com/key", + "https://bucket.s3.amazonaws.com:8443/key", + "https://bucket.s3.amazonaws.com/key?X-Amz-Signature=guest", + ] { + let req = Request::builder().uri(uri).body(Body::empty()).unwrap(); + assert!(resign(&signer, req, "host;x-amz-date").is_err(), "{uri}"); + } + let auth = guest_authorization("s3", "host;x-amz-date"); + for value in [ + auth.replace("us-east-1", "eu-west-1"), + auth.replace("/s3/", "/sts/"), + auth.replace("20150830/", "20150831/"), + auth.replace("AWS4-HMAC-SHA256", "AWS4-ECDSA-P256-SHA256"), + auth.replace("x-amz-date,", "x-amz-date;x-missing,"), + ] { + let req = Request::builder() + .uri(valid) + .header("authorization", value) + .header("x-amz-date", "20150830T123600Z") + .body(Body::empty()) + .unwrap(); + assert!(signer.plan(&req).is_err()); + } + let presigned = Request::builder() + .uri("https://bucket.s3.amazonaws.com/key?X-Amz-Credential=MASKEDACCESSKEY%2F20150830&X-Amz-Signature=guest") + .body(Body::empty()).unwrap(); + assert!(signer.plan(&presigned).is_err()); + for body in [ + Body::from(vec![b'x'; 1025]), + Body::empty().with_trailer_headers(HeaderMap::new()), + ] { + let req = Request::builder().uri(valid).body(body).unwrap(); + assert!(resign(&signer, req, "host;x-amz-date").is_err()); + } +} diff --git a/codex-rs/network-proxy/src/body_budget.rs b/codex-rs/network-proxy/src/body_budget.rs new file mode 100644 index 0000000000..fb90b1fafa --- /dev/null +++ b/codex-rs/network-proxy/src/body_budget.rs @@ -0,0 +1,45 @@ +//! Validate original HTTP framing and the declared length for AWS signing. + +use rama_http::HeaderMap; + +pub(crate) fn is_amazon_event_stream(headers: &HeaderMap) -> bool { + headers.get_all("content-type").iter().any(|value| { + value.to_str().is_ok_and(|value| { + value.split(';').next().is_some_and(|mime| { + mime.trim() + .eq_ignore_ascii_case("application/vnd.amazon.eventstream") + }) + }) + }) +} + +pub(crate) fn supported_transfer_framing(headers: &HeaderMap) -> bool { + let codings = headers.get_all("transfer-encoding"); + let mut codings = codings.iter(); + match codings.next() { + None => true, + Some(coding) => { + !headers.contains_key("content-length") + && codings.next().is_none() + && coding + .to_str() + .is_ok_and(|value| value.trim().eq_ignore_ascii_case("chunked")) + } + } +} + +pub(crate) fn content_length_matches(headers: &HeaderMap, size: usize) -> bool { + let lengths = headers.get_all("content-length"); + let mut lengths = lengths.iter(); + match lengths.next() { + None => true, + Some(length) => { + lengths.next().is_none() + && length.to_str().is_ok_and(|value| { + !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && value.parse::() == Ok(size) + }) + } + } +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index dfabfda63d..62e3bff2e8 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -2,6 +2,8 @@ mod attribution; mod authorization_path; +mod aws_sigv4; +mod body_budget; mod brokered_tunnel; mod certs; mod config; @@ -37,6 +39,8 @@ mod windows_tcp_attribution; pub use attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; pub use attribution::write_attribution_frame; +pub use aws_sigv4::AwsSigV4Config; +pub use aws_sigv4::ProxySecretSource; pub use certs::CUSTOM_CA_ENV_KEYS; pub use certs::is_managed_mitm_ca_trust_bundle_path; pub use codex_utils_path_uri::Platform; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 2b1892b943..9d0a475187 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -78,6 +78,28 @@ struct MitmPolicyContext { } impl MitmPolicyContext { + async fn deny_hook(&self, method: &str, client: Option) -> Response { + let _ = self + .app_state + .record_blocked(BlockedRequest::new(BlockedRequestArgs { + host: self.target_host.clone(), + reason: REASON_MITM_HOOK_DENIED.to_string(), + client, + method: Some(method.to_string()), + mode: Some(self.mode), + protocol: self.scheme.to_string(), + decision: None, + source: None, + port: Some(self.target_port), + })) + .await; + warn!( + "MITM blocked by hook policy (host={}, method={method}, mode={:?})", + self.target_host, self.mode + ); + blocked_text_response(REASON_MITM_HOOK_DENIED) + } + fn placeholders_match(&self, req: &Request, actions: &MitmHookActions) -> bool { actions.inject_request_headers.iter().all(|header| { header.placeholder.as_ref().is_none_or(|placeholder| { @@ -229,11 +251,8 @@ where .unwrap_or_default(); let http_service = HttpServer::auto(executor).service( - ( - (request_ctx.policy.scheme == Scheme::HTTPS) - .then(RemoveResponseHeaderLayer::hop_by_hop), - (request_ctx.policy.scheme == Scheme::HTTPS).then(RemoveRequestHeaderLayer::hop_by_hop), - ) + (request_ctx.policy.scheme == Scheme::HTTPS) + .then(RemoveResponseHeaderLayer::hop_by_hop) .into_layer(service_fn({ let request_ctx = request_ctx.clone(); move |req| { @@ -258,10 +277,19 @@ where } async fn handle_mitm_request( - req: Request, + mut req: Request, request_ctx: Arc, ) -> Result { - let response = match forward_request(req, &request_ctx).await { + crate::aws_sigv4::preserve_signing_headers(&mut req); + let response = match (request_ctx.policy.scheme == Scheme::HTTPS) + .then(RemoveRequestHeaderLayer::hop_by_hop) + .into_layer(service_fn(move |req| { + let request_ctx = request_ctx.clone(); + async move { forward_request(req, &request_ctx).await } + })) + .serve(req) + .await + { Ok(resp) => resp, Err(err) => { warn!("MITM request handling failed: {err}"); @@ -278,6 +306,27 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu MitmPolicyDecision::Block(response) => return Ok(response), }; + // Select the credential from the guest's headers before injecting or stripping anything. + let mut signing = None; + let client = req + .extensions() + .get::() + .map(|info| info.peer_addr().to_string()); + if let Some(signer) = hook_actions + .as_ref() + .and_then(|actions| actions.aws_sigv4.as_ref()) + { + match signer.plan(&req) { + Ok(Some(guest)) => signing = Some((signer, guest)), + Ok(None) => {} + Err(_) => { + return Ok(request_ctx + .policy + .deny_hook(req.method().as_str(), client) + .await); + } + } + } let target_host = request_ctx.policy.target_host.clone(); let target_port = request_ctx.policy.target_port; let mitm = request_ctx.mitm.clone(); @@ -319,7 +368,13 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu body }; - let upstream_req = Request::from_parts(parts, body); + let mut upstream_req = Request::from_parts(parts, body); + if let Some((signer, guest)) = signing { + let Ok(signed) = signer.sign(upstream_req, &guest) else { + return Ok(request_ctx.policy.deny_hook(&method, client).await); + }; + upstream_req = signed; + } let upstream_resp = if request_ctx.policy.scheme == Scheme::HTTP { crate::brokered_tunnel::forward_http_request(upstream_req, &request_ctx.upstream).await? } else { @@ -459,27 +514,9 @@ async fn evaluate_mitm_policy( Some(actions) } HookEvaluation::Matched { .. } | HookEvaluation::HookedHostNoMatch => { - let _ = policy - .app_state - .record_blocked(BlockedRequest::new(BlockedRequestArgs { - host: policy.target_host.clone(), - reason: REASON_MITM_HOOK_DENIED.to_string(), - client: client.clone(), - method: Some(method.clone()), - mode: Some(policy.mode), - protocol: policy.scheme.to_string(), - decision: None, - source: None, - port: Some(policy.target_port), - })) - .await; - warn!( - "MITM blocked by hook policy (host={}, method={method}, mode={:?})", - policy.target_host, policy.mode - ); - return Ok(MitmPolicyDecision::Block(blocked_text_response( - REASON_MITM_HOOK_DENIED, - ))); + return Ok(MitmPolicyDecision::Block( + policy.deny_hook(&method, client).await, + )); } HookEvaluation::NoHooksForHost => None, }; diff --git a/codex-rs/network-proxy/src/mitm_hook.rs b/codex-rs/network-proxy/src/mitm_hook.rs index 8a4e53fdb3..49e87de77d 100644 --- a/codex-rs/network-proxy/src/mitm_hook.rs +++ b/codex-rs/network-proxy/src/mitm_hook.rs @@ -1,6 +1,8 @@ #![cfg_attr(not(test), allow(dead_code))] use crate::authorization_path::is_safe_for_authorization; +use crate::aws_sigv4::AwsSigV4Config; +use crate::aws_sigv4::AwsSigV4Signer; use crate::config::NetworkProxyConfig; use crate::policy::normalize_host; use anyhow::Context as _; @@ -19,6 +21,7 @@ use std::env; use std::fs; use std::io::Read as _; use std::path::Path; +use std::sync::Arc; use url::form_urlencoded; const PATTERN_PREFIX: &str = "pattern:"; @@ -49,6 +52,7 @@ pub struct MitmHookMatchConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(default)] pub struct MitmHookActionsConfig { + pub aws_sigv4: Option, pub strip_request_headers: Vec, pub inject_request_headers: Vec, } @@ -100,6 +104,7 @@ pub struct HeaderConstraint { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MitmHookActions { + pub(crate) aws_sigv4: Option>, pub strip_request_headers: Vec, pub inject_request_headers: Vec, } @@ -237,6 +242,9 @@ pub(crate) fn validate_mitm_hook_config(config: &NetworkProxyConfig) -> Result<( validate_strip_request_headers(&hook.actions.strip_request_headers).with_context(|| { format!("invalid network.mitm_hooks[{hook_index}].actions.strip_request_headers") })?; + if let Some(signing) = &hook.actions.aws_sigv4 { + signing.validate()?; + } validate_injected_headers(&hook.actions.inject_request_headers).with_context(|| { format!("invalid network.mitm_hooks[{hook_index}].actions.inject_request_headers") })?; @@ -365,6 +373,16 @@ where body: None, }, actions: MitmHookActions { + aws_sigv4: hook + .actions + .aws_sigv4 + .as_ref() + .map(|signing| { + signing + .compile(&resolve_env_var, &read_secret_file) + .map(Arc::new) + }) + .transpose()?, strip_request_headers, inject_request_headers, }, @@ -779,6 +797,7 @@ mod tests { ..MitmHookMatchConfig::default() }, actions: MitmHookActionsConfig { + aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: vec![InjectedHeaderConfig { placeholder: None, diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index 2c0740e637..f3e74e3a31 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -29,6 +29,7 @@ fn github_write_hook() -> crate::mitm_hook::MitmHookConfig { ..crate::mitm_hook::MitmHookMatchConfig::default() }, actions: crate::mitm_hook::MitmHookActionsConfig { + aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: vec![crate::mitm_hook::InjectedHeaderConfig { placeholder: None, @@ -571,6 +572,7 @@ fn request_credentials_apply_hook_after_brokerage() { let mut secret = HeaderValue::from_static("Bearer secret-token"); secret.set_sensitive(true); let actions = crate::mitm_hook::MitmHookActions { + aws_sigv4: None, strip_request_headers: vec![HeaderName::from_static("authorization")], inject_request_headers: vec![crate::mitm_hook::ResolvedInjectedHeader { placeholder: Some(placeholder),