Add AWS SigV4 signing to network proxy MITM hooks (#56182)

## Why

Allow workloads to make AWS requests with placeholder credentials while keeping real AWS credentials in the trusted proxy process.

## What changed

- Add an `aws_sigv4` hook action with a placeholder access key ID, region, service, HTTPS port, and credential sources from proxy environment variables or files.
- Re-sign matching requests after network and hook authorization and header transformations, using the configured credentials and optional session token.
- Support empty payloads and explicitly unsigned payloads, including streaming unsigned S3 uploads. Reject selected requests with incompatible credential scopes, presigned URLs, unsupported streaming signatures, or ambiguous framing through the hook denial path.
- Mark generated credential headers as sensitive and suppress signing SDK trace events that could expose credentials.

## Testing

Add tests for the published AWS IAM signature vector, unsigned S3 uploads with a session token, and rejection of invalid transport, credential scopes, signing inputs, and unsupported bodies.

GitOrigin-RevId: ab4fbbc00ba40a5bb309add521fea6ee2ff6e79e
This commit is contained in:
viyatb-oai
2026-10-22 14:01:19 +00:00
committed by copyberry
parent 2f3fb111be
commit 9a8a85bc53
9 changed files with 787 additions and 29 deletions
+2
View File
@@ -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",
+2
View File
@@ -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 }
+506
View File
@@ -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<String>,
file: &impl Fn(&AbsolutePathBuf) -> Result<String>,
) -> Result<String> {
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<ProxySecretSource>,
#[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<String>,
file: &impl Fn(&AbsolutePathBuf) -> Result<String>,
) -> Result<AwsSigV4Signer> {
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<String>);
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::<Connection>()
.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<Item = &str> {
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<HeaderName>,
unsigned_payload: bool,
}
impl AwsSigV4Signer {
pub(crate) fn plan(&self, req: &Request) -> Result<Option<GuestSignature>> {
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::<UnsupportedSigningKeys>()
.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::<Result<Vec<_>>>()?;
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<Request> {
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::<std::result::Result<Vec<_>, _>>()
.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::<Result<Vec<_>>>()?;
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, &params.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;
@@ -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<Request> {
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());
}
}
+45
View File
@@ -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::<usize>() == Ok(size)
})
}
}
}
+4
View File
@@ -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;
+66 -29
View File
@@ -78,6 +78,28 @@ struct MitmPolicyContext {
}
impl MitmPolicyContext {
async fn deny_hook(&self, method: &str, client: Option<String>) -> 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<MitmRequestContext>,
) -> Result<Response, std::convert::Infallible> {
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::<SocketInfo>()
.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,
};
+19
View File
@@ -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<AwsSigV4Config>,
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<InjectedHeaderConfig>,
}
@@ -100,6 +104,7 @@ pub struct HeaderConstraint {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MitmHookActions {
pub(crate) aws_sigv4: Option<Arc<AwsSigV4Signer>>,
pub strip_request_headers: Vec<HeaderName>,
pub inject_request_headers: Vec<ResolvedInjectedHeader>,
}
@@ -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,
+2
View File
@@ -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),