diff --git a/Cargo.lock b/Cargo.lock index a069180c7d..bc595fe491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5140,6 +5140,9 @@ name = "openshell-supervisor-middleware-builtins" version = "0.0.0" dependencies = [ "async-trait", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-runtime-api", "miette", "openshell-core", "prost-types", @@ -5157,9 +5160,6 @@ version = "0.0.0" dependencies = [ "apollo-parser", "async-trait", - "aws-credential-types", - "aws-sigv4", - "aws-smithy-runtime-api", "base64 0.22.1", "bytes", "flate2", diff --git a/crates/openshell-supervisor-middleware-builtins/Cargo.toml b/crates/openshell-supervisor-middleware-builtins/Cargo.toml index 8e38a05ad2..d9abc8bc88 100644 --- a/crates/openshell-supervisor-middleware-builtins/Cargo.toml +++ b/crates/openshell-supervisor-middleware-builtins/Cargo.toml @@ -11,6 +11,9 @@ repository.workspace = true rust-version.workspace = true [dependencies] +aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } +aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } +aws-smithy-runtime-api = { version = "1", features = ["client"] } openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" diff --git a/crates/openshell-supervisor-middleware-builtins/src/lib.rs b/crates/openshell-supervisor-middleware-builtins/src/lib.rs index cf2e28de0e..6ef6bbace5 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/lib.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/lib.rs @@ -4,6 +4,7 @@ //! First-party in-process supervisor middleware implementations. mod regex; +pub mod sigv4; use std::sync::Arc; diff --git a/crates/openshell-supervisor-middleware-builtins/src/sigv4.rs b/crates/openshell-supervisor-middleware-builtins/src/sigv4.rs new file mode 100644 index 0000000000..5889d731fe --- /dev/null +++ b/crates/openshell-supervisor-middleware-builtins/src/sigv4.rs @@ -0,0 +1,746 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use aws_credential_types::Credentials; +use aws_sigv4::http_request::{ + PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings, sign, +}; +use aws_sigv4::sign::v4; +use aws_smithy_runtime_api::client::identity::Identity; +use miette::{Result, miette}; +use std::fmt; +use std::time::SystemTime; + +/// Built-in name used for the trusted post-credential signing stage. +pub const NAME: &str = "openshell/sigv4"; + +/// Maximum body retained by the built-in when the AWS signature covers the +/// complete payload. +pub const MAX_BODY_BYTES: usize = 10 * 1024 * 1024; + +/// Endpoint-selected signing behavior. This mirrors the existing policy +/// values without exposing network-policy types to the built-in crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestedPayloadMode { + /// Preserve the payload mode selected by the AWS client when possible. + Auto, + /// Hash and sign the complete request body. + SignBody, + /// Sign only the request head with `UNSIGNED-PAYLOAD`. + UnsignedPayload, +} + +/// Normalized request framing relevant to payload-mode selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyFraming { + None, + ContentLength, + Chunked, +} + +/// Payload representation covered by the generated signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PayloadMode { + /// Hash the complete request body. + SignBody, + /// Sign headers with the AWS `UNSIGNED-PAYLOAD` sentinel. + UnsignedPayload, + /// Sign an `aws-chunked` stream that carries an unsigned trailer. + StreamingUnsignedTrailer, +} + +impl fmt::Display for PayloadMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SignBody => formatter.write_str("sign_body"), + Self::UnsignedPayload => formatter.write_str("unsigned_payload"), + Self::StreamingUnsignedTrailer => formatter.write_str("streaming_unsigned_trailer"), + } + } +} + +/// Resolve the concrete AWS payload mode from endpoint policy and the +/// caller's original request head. +pub fn resolve_payload_mode( + requested: RequestedPayloadMode, + original_headers: &str, + framing: BodyFraming, +) -> Result { + match requested { + RequestedPayloadMode::SignBody => return Ok(PayloadMode::SignBody), + RequestedPayloadMode::UnsignedPayload => return Ok(PayloadMode::UnsignedPayload), + RequestedPayloadMode::Auto => {} + } + + for line in original_headers.lines().skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + if !name.eq_ignore_ascii_case("x-amz-content-sha256") { + continue; + } + let value = value.trim().to_ascii_lowercase(); + return match value.as_str() { + "streaming-unsigned-payload-trailer" => Ok(PayloadMode::StreamingUnsignedTrailer), + "unsigned-payload" => Ok(PayloadMode::UnsignedPayload), + value if value.starts_with("streaming-") => Err(miette!( + "SigV4 auto-detect does not support chunk-signed streaming mode \ + '{value}'; use credential_signing: sigv4:no_body to stream \ + with UNSIGNED-PAYLOAD instead" + )), + _ => Ok(PayloadMode::SignBody), + }; + } + + Ok(if framing == BodyFraming::ContentLength { + PayloadMode::SignBody + } else { + PayloadMode::UnsignedPayload + }) +} + +/// Credential values kept inside the trusted built-in boundary. +#[derive(Clone, Copy)] +pub struct SigningCredentials<'a> { + pub access_key: &'a str, + pub secret_key: &'a str, + pub session_token: Option<&'a str>, +} + +/// Non-secret signing target selected from the admitted endpoint policy. +#[derive(Debug, Clone, Copy)] +pub struct SigningTarget<'a> { + pub host: &'a str, + pub region: &'a str, + pub service: &'a str, +} + +/// Restricted in-process signer invoked only after credential resolution. +#[derive(Debug, Default)] +pub struct SigV4Middleware; + +impl SigV4Middleware { + /// Remove caller-provided AWS authorization fields before credential + /// placeholder rewriting and trusted re-signing. + pub fn strip_existing_auth(raw: &[u8]) -> Result> { + strip_aws_headers(raw) + } + + /// Sign a complete request, including its body hash. + pub fn sign_body( + raw: &[u8], + target: SigningTarget<'_>, + credentials: SigningCredentials<'_>, + ) -> Result> { + apply_sigv4_to_request( + raw, + target.host, + target.region, + target.service, + credentials.access_key, + credentials.secret_key, + credentials.session_token, + ) + } + + /// Sign a request head without retaining the request body. + pub fn sign_headers( + raw_headers: &[u8], + target: SigningTarget<'_>, + credentials: SigningCredentials<'_>, + payload_mode: PayloadMode, + ) -> Result> { + apply_sigv4_headers_only_with_body( + raw_headers, + target.host, + target.region, + target.service, + credentials.access_key, + credentials.secret_key, + credentials.session_token, + match payload_mode { + PayloadMode::SignBody => { + return Err(miette!("body signing requires a complete request")); + } + PayloadMode::UnsignedPayload => SignableBody::UnsignedPayload, + PayloadMode::StreamingUnsignedTrailer => { + SignableBody::StreamingUnsignedPayloadTrailer + } + }, + ) + } +} + +/// AWS regions contain a hyphen followed by a digit (e.g., `us-east-1`). +/// Service names like `s3` or `bedrock-runtime` do not. +fn looks_like_region(s: &str) -> bool { + let bytes = s.as_bytes(); + for i in 0..bytes.len().saturating_sub(1) { + if bytes[i] == b'-' && bytes[i + 1].is_ascii_digit() { + return true; + } + } + false +} + +/// Extract the AWS region from an AWS hostname. +/// +/// Supports standard, dualstack, FIPS, virtual-hosted, and China partition +/// hostnames. The region is the label immediately before `amazonaws.com` +/// (or `amazonaws.com.cn`). +pub fn extract_aws_region(host: &str) -> Option { + // The global S3 endpoint and its virtual-hosted bucket form always sign + // in us-east-1. This keeps policy metadata consistent across overlapping + // S3 hostname patterns while preserving correct global endpoint signing. + if host == "s3.amazonaws.com" || host.ends_with(".s3.amazonaws.com") { + return Some("us-east-1".to_string()); + } + + let parts: Vec<&str> = host.split('.').collect(); + // China partition: *.amazonaws.com.cn + if parts.len() >= 5 + && parts[parts.len() - 3] == "amazonaws" + && parts[parts.len() - 2] == "com" + && parts[parts.len() - 1] == "cn" + { + let candidate = parts[parts.len() - 4]; + if looks_like_region(candidate) { + return Some(candidate.to_string()); + } + return None; + } + // Standard/dualstack/FIPS/virtual-hosted: *.amazonaws.com + // Scan right-to-left from "amazonaws", skipping non-region labels + // like "dualstack". Handles: s3.us-east-1.amazonaws.com, + // s3.dualstack.us-west-2.amazonaws.com, + // s3-fips.dualstack.us-west-2.amazonaws.com, etc. + if parts.len() >= 4 && parts[parts.len() - 2] == "amazonaws" && parts[parts.len() - 1] == "com" + { + let mut idx = parts.len() - 3; + while idx > 0 && parts[idx] == "dualstack" { + idx -= 1; + } + if idx > 0 && looks_like_region(parts[idx]) { + return Some(parts[idx].to_string()); + } + } + None +} + +/// Strip AWS auth headers from raw HTTP request bytes. +/// +/// Removes `Authorization`, `X-Amz-Date`, `X-Amz-Security-Token`, and +/// `X-Amz-Content-Sha256` headers so the request can pass through the +/// proxy's fail-closed placeholder scan before re-signing. +/// +/// Returns `Err` if the header block is not valid UTF-8. Failing closed +/// prevents non-UTF-8 requests from passing through with their original +/// AWS credentials intact. +pub fn strip_aws_headers(raw: &[u8]) -> Result> { + let header_end = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(raw.len(), |p| p + 4); + + // The caller (rest.rs) validates UTF-8 strictly before reaching this + // point, so `from_utf8` should never fail here. Fail closed so that + // crafted non-UTF-8 bytes cannot smuggle AWS credentials through. + let header_str = std::str::from_utf8(&raw[..header_end]) + .map_err(|e| miette!("strip_aws_headers: header block is not valid UTF-8: {e}"))?; + let lines: Vec<&str> = header_str.split("\r\n").collect(); + + let mut output = Vec::with_capacity(raw.len()); + + for (i, line) in lines.iter().enumerate() { + if i == 0 { + output.extend_from_slice(line.as_bytes()); + output.extend_from_slice(b"\r\n"); + continue; + } + if line.is_empty() { + break; + } + let lower = line.to_ascii_lowercase(); + if lower.starts_with("authorization:") + || lower.starts_with("x-amz-date:") + || lower.starts_with("x-amz-security-token:") + || lower.starts_with("x-amz-content-sha256:") + { + continue; + } + output.extend_from_slice(line.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + + output.extend_from_slice(b"\r\n"); + + if header_end < raw.len() { + output.extend_from_slice(&raw[header_end..]); + } + + Ok(output) +} + +struct RequestParts<'a> { + method: &'a str, + path: &'a str, + request_line: &'a str, + headers_to_sign: Vec<(String, String)>, + all_headers: Vec<(String, String)>, +} + +/// Parse raw HTTP headers into components needed for `SigV4` signing. +/// +/// Only host, content-type, and content-length are included in the `SigV4` +/// signature. Signing all headers causes failures when the proxy or +/// transport modifies unsigned-by-convention headers (Connection, +/// Accept-Encoding, etc.) between signing and delivery. +/// +/// Header names are lowercased for comparison and stored in lowered form +/// in `all_headers`. This is intentional — AWS services accept +/// case-insensitive header names, and this function is only used on the +/// `SigV4` signing path. +fn parse_request_parts(header_str: &str) -> RequestParts<'_> { + // Headers stripped entirely — the SDK re-generates auth headers, and + // `Expect` is handled by the proxy before forwarding. + const STRIP_HEADERS: &[&str] = &[ + "authorization", + "x-amz-date", + "x-amz-security-token", + "x-amz-content-sha256", + "expect", + ]; + // Headers forwarded but NOT signed — the proxy or transport may modify + // them between signing and delivery, which would invalidate the signature. + const UNSIGNED_HEADERS: &[&str] = &[ + "connection", + "accept-encoding", + "transfer-encoding", + "user-agent", + "amz-sdk-invocation-id", + "amz-sdk-request", + ]; + + let lines: Vec<&str> = header_str.split("\r\n").collect(); + + let (method, path, request_line) = + lines + .first() + .map_or(("GET", "/", "GET / HTTP/1.1"), |first_line| { + let parts: Vec<&str> = first_line.splitn(3, ' ').collect(); + if parts.len() >= 2 { + (parts[0], parts[1], *first_line) + } else { + ("GET", "/", *first_line) + } + }); + + let mut headers_to_sign: Vec<(String, String)> = Vec::new(); + let mut all_headers: Vec<(String, String)> = Vec::new(); + for line in lines.iter().skip(1) { + if line.is_empty() { + break; + } + if let Some((k, v)) = line.split_once(':') { + let lower = k.trim().to_ascii_lowercase(); + if STRIP_HEADERS.iter().any(|s| lower.starts_with(s)) { + continue; + } + all_headers.push((lower.clone(), v.trim().to_string())); + if !UNSIGNED_HEADERS.iter().any(|s| lower.starts_with(s)) { + headers_to_sign.push((lower, v.trim().to_string())); + } + } + } + + RequestParts { + method, + path, + request_line, + headers_to_sign, + all_headers, + } +} + +fn build_signing_params<'a>( + identity: &'a Identity, + region: &'a str, + service: &'a str, +) -> Result> { + let mut settings = SigningSettings::default(); + settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; + + Ok(v4::SigningParams::builder() + .identity(identity) + .region(region) + .name(service) + .time(SystemTime::now()) + .settings(settings) + .build() + .map_err(|e| miette!("SigV4 signing params: {e}"))? + .into()) +} + +fn build_identity(access_key: &str, secret_key: &str, session_token: Option<&str>) -> Identity { + Credentials::new( + access_key, + secret_key, + session_token.map(ToString::to_string), + None, + "openshell", + ) + .into() +} + +fn rebuild_request( + parts: &RequestParts<'_>, + instructions: &aws_sigv4::http_request::SigningInstructions, + body: &[u8], +) -> Vec { + let mut output = Vec::with_capacity(256 + body.len()); + + output.extend_from_slice(parts.request_line.as_bytes()); + output.extend_from_slice(b"\r\n"); + + for (k, v) in &parts.all_headers { + output.extend_from_slice(format!("{k}: {v}\r\n").as_bytes()); + } + + for (name, value) in instructions.headers() { + output.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); + } + + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(body); + + output +} + +/// Apply AWS Signature Version 4 signing to a raw HTTP request buffer. +/// +/// Strips existing AWS auth headers, computes a new signature using the +/// `aws-sigv4` crate, and returns the rewritten request bytes including body. +pub fn apply_sigv4_to_request( + raw: &[u8], + host: &str, + region: &str, + service: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, +) -> Result> { + let header_end = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(raw.len(), |p| p + 4); + + let body = if header_end < raw.len() { + &raw[header_end..] + } else { + &[] + }; + + let header_str = std::str::from_utf8(&raw[..header_end]) + .map_err(|e| miette!("SigV4 signing: request headers are not valid UTF-8: {e}"))?; + let parts = parse_request_parts(header_str); + let uri = format!("https://{host}{}", parts.path); + let identity = build_identity(access_key, secret_key, session_token); + let signing_params = build_signing_params(&identity, region, service)?; + + let signable_request = SignableRequest::new( + parts.method, + &uri, + parts + .headers_to_sign + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())), + SignableBody::Bytes(body), + ) + .map_err(|e| miette!("SigV4 signable request: {e}"))?; + + let (instructions, _signature) = sign(signable_request, &signing_params) + .map_err(|e| miette!("SigV4 signing failed: {e}"))? + .into_parts(); + + Ok(rebuild_request(&parts, &instructions, body)) +} + +/// Apply AWS `SigV4` signing to HTTP headers only, using UNSIGNED-PAYLOAD. +/// +/// Returns signed headers ending with `\r\n\r\n`. The caller is responsible +/// for streaming the body separately. Use when the body is chunked or when +/// the service accepts unsigned payloads (e.g. S3 over HTTPS). +pub fn apply_sigv4_headers_only( + raw_headers: &[u8], + host: &str, + region: &str, + service: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, +) -> Result> { + apply_sigv4_headers_only_with_body( + raw_headers, + host, + region, + service, + access_key, + secret_key, + session_token, + SignableBody::UnsignedPayload, + ) +} + +/// Apply AWS `SigV4` signing to HTTP headers only with a caller-chosen +/// `SignableBody` mode (e.g. `UnsignedPayload` or +/// `StreamingUnsignedPayloadTrailer`). +/// +/// Returns signed headers ending with `\r\n\r\n`. +#[allow(clippy::too_many_arguments)] +pub fn apply_sigv4_headers_only_with_body( + raw_headers: &[u8], + host: &str, + region: &str, + service: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, + body: SignableBody<'_>, +) -> Result> { + let header_str = std::str::from_utf8(raw_headers) + .map_err(|e| miette!("SigV4 signing: request headers are not valid UTF-8: {e}"))?; + let parts = parse_request_parts(header_str); + let uri = format!("https://{host}{}", parts.path); + let identity = build_identity(access_key, secret_key, session_token); + let signing_params = build_signing_params(&identity, region, service)?; + + let signable_request = SignableRequest::new( + parts.method, + &uri, + parts + .headers_to_sign + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())), + body, + ) + .map_err(|e| miette!("SigV4 signable request: {e}"))?; + + let (instructions, _signature) = sign(signable_request, &signing_params) + .map_err(|e| miette!("SigV4 signing failed: {e}"))? + .into_parts(); + + Ok(rebuild_request(&parts, &instructions, &[])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_region_from_hostname() { + let region = extract_aws_region("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-2"); + } + + #[test] + fn extract_region_from_sts_hostname() { + let region = extract_aws_region("sts.us-east-1.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-1"); + } + + #[test] + fn non_aws_hostname_returns_none() { + assert!(extract_aws_region("api.anthropic.com").is_none()); + } + + #[test] + fn global_s3_endpoint_uses_us_east_1() { + assert_eq!( + extract_aws_region("s3.amazonaws.com").as_deref(), + Some("us-east-1") + ); + } + + #[test] + fn virtual_hosted_global_s3_endpoint_uses_us_east_1() { + assert_eq!( + extract_aws_region("my-bucket.s3.amazonaws.com").as_deref(), + Some("us-east-1") + ); + } + + #[test] + fn extract_region_dualstack() { + let region = extract_aws_region("s3.dualstack.us-west-2.amazonaws.com").unwrap(); + assert_eq!(region, "us-west-2"); + } + + #[test] + fn extract_region_fips() { + let region = extract_aws_region("bedrock-runtime-fips.us-east-1.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-1"); + } + + #[test] + fn extract_region_china() { + let region = extract_aws_region("s3.cn-north-1.amazonaws.com.cn").unwrap(); + assert_eq!(region, "cn-north-1"); + } + + #[test] + fn extract_region_fips_dualstack() { + let region = extract_aws_region("s3-fips.dualstack.us-west-2.amazonaws.com").unwrap(); + assert_eq!(region, "us-west-2"); + } + + #[test] + fn extract_region_govcloud() { + let region = extract_aws_region("s3.us-gov-west-1.amazonaws.com").unwrap(); + assert_eq!(region, "us-gov-west-1"); + } + + #[test] + fn extract_region_virtual_hosted_s3() { + let region = extract_aws_region("my-bucket.s3.us-east-2.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-2"); + } + + #[test] + fn sign_produces_valid_format() { + let raw = b"POST /model/us.anthropic.claude-sonnet-4-6/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\n\r\n{}"; + let result = apply_sigv4_to_request( + raw, + "bedrock-runtime.us-east-2.amazonaws.com", + "us-east-2", + "bedrock", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!( + result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/") + ); + assert!(result_str.contains("x-amz-content-sha256: ")); + assert!(result_str.contains("x-amz-date: ")); + assert!(!result_str.contains("x-amz-security-token")); + } + + #[test] + fn sign_with_session_token() { + let raw = b"POST /model/test/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\n\r\n{}"; + let result = apply_sigv4_to_request( + raw, + "bedrock-runtime.us-east-2.amazonaws.com", + "us-east-2", + "bedrock", + "ASIAEXAMPLE", + "secret", + Some("FwoGZXIvYXdzEBYaDH+session+token"), + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=ASIAEXAMPLE/")); + assert!(result_str.contains("x-amz-security-token: FwoGZXIvYXdzEBYaDH+session+token")); + } + + #[test] + fn non_signed_headers_preserved() { + let raw = b"POST /model/test/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\nAccept: application/json\r\nUser-Agent: my-agent/1.0\r\n\r\n{}"; + let result = apply_sigv4_to_request( + raw, + "bedrock-runtime.us-east-2.amazonaws.com", + "us-east-2", + "bedrock", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("accept: application/json\r\n")); + assert!(result_str.contains("user-agent: my-agent/1.0\r\n")); + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=")); + } + + #[test] + fn apply_sigv4_rewrites_request() { + let raw = b"POST /model/test/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\nAuthorization: AWS4-HMAC-SHA256 old-invalid-sig\r\nX-Amz-Date: old-date\r\n\r\n{}"; + let result = apply_sigv4_to_request( + raw, + "bedrock-runtime.us-east-2.amazonaws.com", + "us-east-2", + "bedrock", + "AKIATEST", + "secret", + None, + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); + assert!(!result_str.contains("old-invalid-sig")); + assert!(!result_str.contains("old-date")); + } + + #[test] + fn headers_only_produces_unsigned_payload() { + let raw = b"PUT /my-bucket/my-key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nContent-Type: application/octet-stream\r\nContent-Length: 1024\r\n\r\n"; + let result = apply_sigv4_headers_only( + raw, + "s3.us-east-1.amazonaws.com", + "us-east-1", + "s3", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!( + result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/") + ); + assert!(result_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); + assert!(result_str.contains("x-amz-date: ")); + assert!(result_str.ends_with("\r\n\r\n")); + } + + #[test] + fn headers_only_strips_old_auth() { + let raw = b"PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nAuthorization: AWS4-HMAC-SHA256 old-sig\r\nX-Amz-Date: old-date\r\nX-Amz-Content-Sha256: old-hash\r\nContent-Type: application/octet-stream\r\n\r\n"; + let result = apply_sigv4_headers_only( + raw, + "s3.us-east-1.amazonaws.com", + "us-east-1", + "s3", + "AKIATEST", + "secret", + None, + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); + assert!(!result_str.contains("old-sig")); + assert!(!result_str.contains("old-date")); + assert!(!result_str.contains("old-hash")); + assert!(result_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); + } + + #[test] + fn headers_only_with_session_token() { + let raw = b"PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nContent-Type: application/octet-stream\r\n\r\n"; + let result = apply_sigv4_headers_only( + raw, + "s3.us-east-1.amazonaws.com", + "us-east-1", + "s3", + "ASIAEXAMPLE", + "secret", + Some("FwoGZXIvYXdzEBYaDH+session+token"), + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("x-amz-security-token: FwoGZXIvYXdzEBYaDH+session+token")); + assert!(result_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); + } +} diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 6ac1239573..991666e059 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] +openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } sha2 = { workspace = true } openshell-binary-identity = { path = "../openshell-binary-identity" } @@ -25,9 +26,6 @@ openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" async-trait = "0.1" apollo-parser = { workspace = true } -aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } -aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } -aws-smithy-runtime-api = { version = "1", features = ["client"] } http = { workspace = true } base64 = { workspace = true } bytes = { workspace = true } @@ -70,7 +68,6 @@ openshell-sandbox-backend = { path = "../openshell-sandbox-backend", features = openshell-driver-vm = { path = "../openshell-driver-vm", default-features = false, features = ["allocation-tracking"] } openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } openshell-otel = { path = "../openshell-otel" } -openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } opentelemetry_sdk = { workspace = true, features = ["testing"] } temp-env = "0.3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/openshell-supervisor-network/README.md b/crates/openshell-supervisor-network/README.md index e445f8addc..cd86234cba 100644 --- a/crates/openshell-supervisor-network/README.md +++ b/crates/openshell-supervisor-network/README.md @@ -18,3 +18,9 @@ dependency only. ## Connections without inspection Before the supervisor forwards a connection it cannot inspect (`tls: skip`, transparent TCP, h2c, unsupported tunnels, SQL passthrough), `uninspectable_traffic_decision` evaluates the matching middleware chain. Entries whose service binds `UNINSPECTABLE_NETWORK/PRE_FORWARD` decide through `UninspectableNetworkMiddleware.Evaluate`, and any failure denies. Other entries follow `on_error`: `fail_closed` denies, and `fail_open` relays the connection with a bypass finding. That `fail_open` exception is deprecated and removed in 0.2.0. For `tls: skip`, the decision runs before `200 Connection Established`, so a denial is a `403`. + +## Request signing + +The relay invokes `openshell-supervisor-middleware-builtins::sigv4::SigV4Middleware` after request middleware completes and the supervisor resolves credentials. The built-in signs the final headers and body. AWS secrets remain in the supervisor and never enter the external middleware protocol. Endpoint `credential_signing`, `signing_service`, and `signing_region` select signing; the signer is not an externally registered service or a `network_middlewares` binding. + +HTTP session STREAM stages can defer output until they consume the complete input. SigV4 routes withhold that output before upstream delivery, with the existing 10 MiB signing limit. The network crate retains its `sigv4` exports for existing callers; the implementation and AWS signing dependencies belong to the built-ins crate. diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 401c61c146..ee8b718526 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -12241,6 +12241,8 @@ network_policies: #[tokio::test] async fn sigv4_route_withholds_stream_output_up_to_the_signing_limit() { + use sha2::{Digest as _, Sha256}; + let data = r#" network_middlewares: echo: @@ -12270,14 +12272,15 @@ network_policies: // A pass-through STREAM stage keeps the route's 10 MiB signing limit // instead of the 4 MiB default: a 6 MiB body is signed and forwarded, // and one byte over the limit fails before upstream contact. - for body_len in [6 * 1024 * 1024, limit + 1] { + for (body_len, drop_body) in [(6 * 1024 * 1024, false), (limit + 1, false), (1024, true)] { + let output_len = if drop_body { 0 } else { body_len }; let forwards = body_len <= limit; let engine = OpaEngine::from_strings(TEST_POLICY, data).expect("test policy"); let preflights = Arc::default(); engine.set_middleware_runner_for_tests( openshell_supervisor_middleware::ChainRunner::new(Arc::new( SessionEchoRequestStage { - drop_body: false, + drop_body, reject: false, preflights: Arc::clone(&preflights), }, @@ -12364,7 +12367,7 @@ network_policies: while forwarded .windows(4) .position(|window| window == b"\r\n\r\n") - .is_none_or(|end| forwarded.len() < end + 4 + body_len) + .is_none_or(|end| forwarded.len() < end + 4 + output_len) { let count = upstream.read(&mut buffer).await.unwrap(); assert!(count > 0, "upstream closed early"); @@ -12404,6 +12407,9 @@ network_policies: Some(limit as u64), "{body_len}" ); + let visible = format!("{:?}", preflights[0]); + assert!(!visible.contains("AKIATESTKEY")); + assert!(!visible.contains("test-secret-key")); let response = String::from_utf8_lossy(&response); if forwards { let end = forwarded @@ -12413,10 +12419,16 @@ network_policies: let head = String::from_utf8_lossy(&forwarded[..end]).to_ascii_lowercase(); assert!(head.contains("authorization: aws4-hmac-sha256"), "{head}"); assert!( - head.contains(&format!("content-length: {body_len}")), + head.contains(&format!("content-length: {output_len}")), + "{head}" + ); + assert_eq!(forwarded.len() - end - 4, output_len); + let digest = hex::encode(Sha256::digest(&forwarded[end + 4..])); + assert!( + head.contains(&format!("x-amz-content-sha256: {digest}")), "{head}" ); - assert_eq!(forwarded.len() - end - 4, body_len); + assert!(head.contains("credential=akiatestkey/"), "{head}"); assert!(response.starts_with("HTTP/1.1 200"), "{response}"); } else { assert!(forwarded.is_empty(), "upstream saw an over-limit request"); diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 4fbc31f8fe..b60c1e9ed6 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -30,7 +30,6 @@ use crate::l7::EndpointObserver; use crate::l7::http::{parse_chunk_size_line, validate_chunk_trailer_line}; use crate::l7::provider::{BodyLength, L7Provider, L7Request, RelayOutcome}; use crate::opa::PolicyGenerationGuard; -use aws_sigv4::http_request::SignableBody; use base64::Engine as _; use miette::{IntoDiagnostic, Result, miette}; use openshell_core::endpoint_status::EndpointResult; @@ -43,6 +42,10 @@ use openshell_core::secrets::{ rewrite_http_header_block, }; use openshell_ocsf::ctx::ctx as ocsf_ctx; +use openshell_supervisor_middleware_builtins::sigv4::{ + self, BodyFraming, PayloadMode as SigV4PayloadMode, RequestedPayloadMode, SigV4Middleware, + SigningCredentials, SigningTarget, +}; use sha1::{Digest, Sha1}; use std::collections::{HashMap, HashSet}; use std::fmt::{self, Write as _}; @@ -53,7 +56,7 @@ const MAX_HEADER_BYTES: usize = 16384; // 16 KiB for HTTP headers pub(crate) const MAX_REWRITE_BODY_BYTES: usize = 256 * 1024; /// Maximum body bytes for `SigV4` body-signing mode. Larger than the credential /// rewrite limit because Bedrock payloads can be several megabytes. -pub(crate) const MAX_SIGV4_BODY_BYTES: usize = 10 * 1024 * 1024; +pub(crate) const MAX_SIGV4_BODY_BYTES: usize = sigv4::MAX_BODY_BYTES; #[cfg(test)] async fn max_middleware_body_bytes() -> usize { let chain = openshell_supervisor_middleware::ChainRunner::new( @@ -1000,7 +1003,7 @@ where // Authorization header (which embeds placeholder strings). let raw_for_rewrite; let header_source = if options.credential_signing.is_sigv4() { - raw_for_rewrite = crate::sigv4::strip_aws_headers(&req.raw_header[..header_end])?; + raw_for_rewrite = SigV4Middleware::strip_existing_auth(&req.raw_header[..header_end])?; &raw_for_rewrite[..] } else { &req.raw_header[..header_end] @@ -1095,7 +1098,7 @@ where // Use explicit signing_region from policy if set, // otherwise extract from hostname. let region = if options.signing_region.is_empty() { - match crate::sigv4::extract_aws_region(options.host) { + match sigv4::extract_aws_region(options.host) { Some(r) => r, None => { return Err(miette!( @@ -1116,14 +1119,26 @@ where )); } - let payload_mode = match options.credential_signing { - crate::l7::CredentialSigning::SigV4Body => SigV4PayloadMode::SignBody, + let requested_payload = match options.credential_signing { + crate::l7::CredentialSigning::SigV4Body => RequestedPayloadMode::SignBody, crate::l7::CredentialSigning::SigV4NoBody => { - SigV4PayloadMode::UnsignedPayload + RequestedPayloadMode::UnsignedPayload } - crate::l7::CredentialSigning::SigV4 => detect_payload_mode(header_str)?, + crate::l7::CredentialSigning::SigV4 => RequestedPayloadMode::Auto, crate::l7::CredentialSigning::None => unreachable!(), }; + let payload_mode = resolve_payload_mode(requested_payload, header_str)?; + + let target = SigningTarget { + host: options.host, + region: ®ion, + service, + }; + let credentials = SigningCredentials { + access_key, + secret_key, + session_token, + }; if payload_mode == SigV4PayloadMode::SignBody { // Buffer body and include its hash in the signature. @@ -1179,34 +1194,17 @@ where guard.ensure_current()?; } - let signed = crate::sigv4::apply_sigv4_to_request( - &full_request, - options.host, - ®ion, - service, - access_key, - secret_key, - session_token, - )?; + let signed = + SigV4Middleware::sign_body(&full_request, target, credentials)?; ensure_credential_generation_current(options)?; upstream.write_all(&signed).await.into_diagnostic()?; } else { // Sign headers only, stream body through. - let signable_body = match payload_mode { - SigV4PayloadMode::StreamingUnsignedTrailer => { - SignableBody::StreamingUnsignedPayloadTrailer - } - _ => SignableBody::UnsignedPayload, - }; - let signed_headers = crate::sigv4::apply_sigv4_headers_only_with_body( + let signed_headers = SigV4Middleware::sign_headers( &rewrite_result.rewritten, - options.host, - ®ion, - service, - access_key, - secret_key, - session_token, - signable_body, + target, + credentials, + payload_mode, )?; ensure_credential_generation_current(options)?; upstream @@ -3204,63 +3202,21 @@ fn has_expect_continue(headers: &str) -> bool { }) } -/// Resolved payload signing mode for a `SigV4` request. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SigV4PayloadMode { - /// Buffer body and include its SHA-256 hash in the signature. - SignBody, - /// Use literal `UNSIGNED-PAYLOAD` — no body buffering needed. - UnsignedPayload, - /// Use `STREAMING-UNSIGNED-PAYLOAD-TRAILER` for `aws-chunked` streams. - StreamingUnsignedTrailer, -} - -impl fmt::Display for SigV4PayloadMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::SignBody => write!(f, "sign_body"), - Self::UnsignedPayload => write!(f, "unsigned_payload"), - Self::StreamingUnsignedTrailer => write!(f, "streaming_unsigned_trailer"), - } - } +#[cfg(test)] +fn detect_payload_mode(headers: &str) -> Result { + resolve_payload_mode(RequestedPayloadMode::Auto, headers) } -/// Auto-detect the payload signing mode from the client's original headers. -/// -/// Mirrors the mode the client SDK chose by inspecting `x-amz-content-sha256`: -/// - `STREAMING-UNSIGNED-PAYLOAD-TRAILER` → `StreamingUnsignedTrailer` -/// - `UNSIGNED-PAYLOAD` → `UnsignedPayload` -/// - Hex hash → `SignBody` (buffer + hash, requires `Content-Length`) -/// - `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` → **rejected** (the proxy cannot -/// reproduce per-chunk signatures; use `sigv4:no_body` instead) -/// - Other `STREAMING-*` values → **rejected** (unsupported streaming mode) -/// - Absent → `SignBody` if `Content-Length` present, else `UnsignedPayload` -fn detect_payload_mode(headers: &str) -> Result { - for line in headers.lines().skip(1) { - let lower = line.to_ascii_lowercase(); - if lower.starts_with("x-amz-content-sha256:") { - let val = lower.split_once(':').map_or("", |(_, v)| v.trim()); - return match val { - "streaming-unsigned-payload-trailer" => { - Ok(SigV4PayloadMode::StreamingUnsignedTrailer) - } - "unsigned-payload" => Ok(SigV4PayloadMode::UnsignedPayload), - v if v.starts_with("streaming-") => Err(miette!( - "SigV4 auto-detect does not support chunk-signed streaming mode \ - '{v}'; use credential_signing: sigv4:no_body to stream \ - with UNSIGNED-PAYLOAD instead" - )), - _ => Ok(SigV4PayloadMode::SignBody), - }; - } - } - Ok( - if matches!(parse_body_length(headers)?, BodyLength::ContentLength(_)) { - SigV4PayloadMode::SignBody - } else { - SigV4PayloadMode::UnsignedPayload - }, - ) +fn resolve_payload_mode( + requested: RequestedPayloadMode, + headers: &str, +) -> Result { + let framing = match parse_body_length(headers)? { + BodyLength::None => BodyFraming::None, + BodyLength::ContentLength(_) => BodyFraming::ContentLength, + BodyLength::Chunked => BodyFraming::Chunked, + }; + sigv4::resolve_payload_mode(requested, headers, framing) } /// Parse Content-Length or Transfer-Encoding from HTTP headers. diff --git a/crates/openshell-supervisor-network/src/sigv4.rs b/crates/openshell-supervisor-network/src/sigv4.rs index 79e4c32e18..b44b63093c 100644 --- a/crates/openshell-supervisor-network/src/sigv4.rs +++ b/crates/openshell-supervisor-network/src/sigv4.rs @@ -1,585 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use aws_credential_types::Credentials; -use aws_sigv4::http_request::{ - PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings, sign, -}; -use aws_sigv4::sign::v4; -use aws_smithy_runtime_api::client::identity::Identity; -use miette::{Result, miette}; -use std::time::SystemTime; +//! Compatibility exports for the trusted built-in `SigV4` signer. -/// AWS regions contain a hyphen followed by a digit (e.g., `us-east-1`). -/// Service names like `s3` or `bedrock-runtime` do not. -fn looks_like_region(s: &str) -> bool { - let bytes = s.as_bytes(); - for i in 0..bytes.len().saturating_sub(1) { - if bytes[i] == b'-' && bytes[i + 1].is_ascii_digit() { - return true; - } - } - false -} - -/// Extract the AWS region from an AWS hostname. -/// -/// Supports standard, dualstack, FIPS, virtual-hosted, and China partition -/// hostnames. The region is the label immediately before `amazonaws.com` -/// (or `amazonaws.com.cn`). -pub fn extract_aws_region(host: &str) -> Option { - // The global S3 endpoint and its virtual-hosted bucket form always sign - // in us-east-1. This keeps policy metadata consistent across overlapping - // S3 hostname patterns while preserving correct global endpoint signing. - if host == "s3.amazonaws.com" || host.ends_with(".s3.amazonaws.com") { - return Some("us-east-1".to_string()); - } - - let parts: Vec<&str> = host.split('.').collect(); - // China partition: *.amazonaws.com.cn - if parts.len() >= 5 - && parts[parts.len() - 3] == "amazonaws" - && parts[parts.len() - 2] == "com" - && parts[parts.len() - 1] == "cn" - { - let candidate = parts[parts.len() - 4]; - if looks_like_region(candidate) { - return Some(candidate.to_string()); - } - return None; - } - // Standard/dualstack/FIPS/virtual-hosted: *.amazonaws.com - // Scan right-to-left from "amazonaws", skipping non-region labels - // like "dualstack". Handles: s3.us-east-1.amazonaws.com, - // s3.dualstack.us-west-2.amazonaws.com, - // s3-fips.dualstack.us-west-2.amazonaws.com, etc. - if parts.len() >= 4 && parts[parts.len() - 2] == "amazonaws" && parts[parts.len() - 1] == "com" - { - let mut idx = parts.len() - 3; - while idx > 0 && parts[idx] == "dualstack" { - idx -= 1; - } - if idx > 0 && looks_like_region(parts[idx]) { - return Some(parts[idx].to_string()); - } - } - None -} - -/// Strip AWS auth headers from raw HTTP request bytes. -/// -/// Removes `Authorization`, `X-Amz-Date`, `X-Amz-Security-Token`, and -/// `X-Amz-Content-Sha256` headers so the request can pass through the -/// proxy's fail-closed placeholder scan before re-signing. -/// -/// Returns `Err` if the header block is not valid UTF-8. Failing closed -/// prevents non-UTF-8 requests from passing through with their original -/// AWS credentials intact. -pub fn strip_aws_headers(raw: &[u8]) -> Result> { - let header_end = raw - .windows(4) - .position(|w| w == b"\r\n\r\n") - .map_or(raw.len(), |p| p + 4); - - // The caller (rest.rs) validates UTF-8 strictly before reaching this - // point, so `from_utf8` should never fail here. Fail closed so that - // crafted non-UTF-8 bytes cannot smuggle AWS credentials through. - let header_str = std::str::from_utf8(&raw[..header_end]) - .map_err(|e| miette!("strip_aws_headers: header block is not valid UTF-8: {e}"))?; - let lines: Vec<&str> = header_str.split("\r\n").collect(); - - let mut output = Vec::with_capacity(raw.len()); - - for (i, line) in lines.iter().enumerate() { - if i == 0 { - output.extend_from_slice(line.as_bytes()); - output.extend_from_slice(b"\r\n"); - continue; - } - if line.is_empty() { - break; - } - let lower = line.to_ascii_lowercase(); - if lower.starts_with("authorization:") - || lower.starts_with("x-amz-date:") - || lower.starts_with("x-amz-security-token:") - || lower.starts_with("x-amz-content-sha256:") - { - continue; - } - output.extend_from_slice(line.as_bytes()); - output.extend_from_slice(b"\r\n"); - } - - output.extend_from_slice(b"\r\n"); - - if header_end < raw.len() { - output.extend_from_slice(&raw[header_end..]); - } - - Ok(output) -} - -struct RequestParts<'a> { - method: &'a str, - path: &'a str, - request_line: &'a str, - headers_to_sign: Vec<(String, String)>, - all_headers: Vec<(String, String)>, -} - -/// Parse raw HTTP headers into components needed for `SigV4` signing. -/// -/// Only host, content-type, and content-length are included in the `SigV4` -/// signature. Signing all headers causes failures when the proxy or -/// transport modifies unsigned-by-convention headers (Connection, -/// Accept-Encoding, etc.) between signing and delivery. -/// -/// Header names are lowercased for comparison and stored in lowered form -/// in `all_headers`. This is intentional — AWS services accept -/// case-insensitive header names, and this function is only used on the -/// `SigV4` signing path. -fn parse_request_parts(header_str: &str) -> RequestParts<'_> { - // Headers stripped entirely — the SDK re-generates auth headers, and - // `Expect` is handled by the proxy before forwarding. - const STRIP_HEADERS: &[&str] = &[ - "authorization", - "x-amz-date", - "x-amz-security-token", - "x-amz-content-sha256", - "expect", - ]; - // Headers forwarded but NOT signed — the proxy or transport may modify - // them between signing and delivery, which would invalidate the signature. - const UNSIGNED_HEADERS: &[&str] = &[ - "connection", - "accept-encoding", - "transfer-encoding", - "user-agent", - "amz-sdk-invocation-id", - "amz-sdk-request", - ]; - - let lines: Vec<&str> = header_str.split("\r\n").collect(); - - let (method, path, request_line) = - lines - .first() - .map_or(("GET", "/", "GET / HTTP/1.1"), |first_line| { - let parts: Vec<&str> = first_line.splitn(3, ' ').collect(); - if parts.len() >= 2 { - (parts[0], parts[1], *first_line) - } else { - ("GET", "/", *first_line) - } - }); - - let mut headers_to_sign: Vec<(String, String)> = Vec::new(); - let mut all_headers: Vec<(String, String)> = Vec::new(); - for line in lines.iter().skip(1) { - if line.is_empty() { - break; - } - if let Some((k, v)) = line.split_once(':') { - let lower = k.trim().to_ascii_lowercase(); - if STRIP_HEADERS.iter().any(|s| lower.starts_with(s)) { - continue; - } - all_headers.push((lower.clone(), v.trim().to_string())); - if !UNSIGNED_HEADERS.iter().any(|s| lower.starts_with(s)) { - headers_to_sign.push((lower, v.trim().to_string())); - } - } - } - - RequestParts { - method, - path, - request_line, - headers_to_sign, - all_headers, - } -} - -fn build_signing_params<'a>( - identity: &'a Identity, - region: &'a str, - service: &'a str, -) -> Result> { - let mut settings = SigningSettings::default(); - settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; - - Ok(v4::SigningParams::builder() - .identity(identity) - .region(region) - .name(service) - .time(SystemTime::now()) - .settings(settings) - .build() - .map_err(|e| miette!("SigV4 signing params: {e}"))? - .into()) -} - -fn build_identity(access_key: &str, secret_key: &str, session_token: Option<&str>) -> Identity { - Credentials::new( - access_key, - secret_key, - session_token.map(ToString::to_string), - None, - "openshell", - ) - .into() -} - -fn rebuild_request( - parts: &RequestParts<'_>, - instructions: &aws_sigv4::http_request::SigningInstructions, - body: &[u8], -) -> Vec { - let mut output = Vec::with_capacity(256 + body.len()); - - output.extend_from_slice(parts.request_line.as_bytes()); - output.extend_from_slice(b"\r\n"); - - for (k, v) in &parts.all_headers { - output.extend_from_slice(format!("{k}: {v}\r\n").as_bytes()); - } - - for (name, value) in instructions.headers() { - output.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); - } - - output.extend_from_slice(b"\r\n"); - output.extend_from_slice(body); - - output -} - -/// Apply AWS Signature Version 4 signing to a raw HTTP request buffer. -/// -/// Strips existing AWS auth headers, computes a new signature using the -/// `aws-sigv4` crate, and returns the rewritten request bytes including body. -pub fn apply_sigv4_to_request( - raw: &[u8], - host: &str, - region: &str, - service: &str, - access_key: &str, - secret_key: &str, - session_token: Option<&str>, -) -> Result> { - let header_end = raw - .windows(4) - .position(|w| w == b"\r\n\r\n") - .map_or(raw.len(), |p| p + 4); - - let body = if header_end < raw.len() { - &raw[header_end..] - } else { - &[] - }; - - let header_str = std::str::from_utf8(&raw[..header_end]) - .map_err(|e| miette!("SigV4 signing: request headers are not valid UTF-8: {e}"))?; - let parts = parse_request_parts(header_str); - let uri = format!("https://{host}{}", parts.path); - let identity = build_identity(access_key, secret_key, session_token); - let signing_params = build_signing_params(&identity, region, service)?; - - let signable_request = SignableRequest::new( - parts.method, - &uri, - parts - .headers_to_sign - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())), - SignableBody::Bytes(body), - ) - .map_err(|e| miette!("SigV4 signable request: {e}"))?; - - let (instructions, _signature) = sign(signable_request, &signing_params) - .map_err(|e| miette!("SigV4 signing failed: {e}"))? - .into_parts(); - - Ok(rebuild_request(&parts, &instructions, body)) -} - -/// Apply AWS `SigV4` signing to HTTP headers only, using UNSIGNED-PAYLOAD. -/// -/// Returns signed headers ending with `\r\n\r\n`. The caller is responsible -/// for streaming the body separately. Use when the body is chunked or when -/// the service accepts unsigned payloads (e.g. S3 over HTTPS). -pub fn apply_sigv4_headers_only( - raw_headers: &[u8], - host: &str, - region: &str, - service: &str, - access_key: &str, - secret_key: &str, - session_token: Option<&str>, -) -> Result> { - apply_sigv4_headers_only_with_body( - raw_headers, - host, - region, - service, - access_key, - secret_key, - session_token, - SignableBody::UnsignedPayload, - ) -} - -/// Apply AWS `SigV4` signing to HTTP headers only with a caller-chosen -/// `SignableBody` mode (e.g. `UnsignedPayload` or -/// `StreamingUnsignedPayloadTrailer`). -/// -/// Returns signed headers ending with `\r\n\r\n`. -#[allow(clippy::too_many_arguments)] -pub fn apply_sigv4_headers_only_with_body( - raw_headers: &[u8], - host: &str, - region: &str, - service: &str, - access_key: &str, - secret_key: &str, - session_token: Option<&str>, - body: SignableBody<'_>, -) -> Result> { - let header_str = std::str::from_utf8(raw_headers) - .map_err(|e| miette!("SigV4 signing: request headers are not valid UTF-8: {e}"))?; - let parts = parse_request_parts(header_str); - let uri = format!("https://{host}{}", parts.path); - let identity = build_identity(access_key, secret_key, session_token); - let signing_params = build_signing_params(&identity, region, service)?; - - let signable_request = SignableRequest::new( - parts.method, - &uri, - parts - .headers_to_sign - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())), - body, - ) - .map_err(|e| miette!("SigV4 signable request: {e}"))?; - - let (instructions, _signature) = sign(signable_request, &signing_params) - .map_err(|e| miette!("SigV4 signing failed: {e}"))? - .into_parts(); - - Ok(rebuild_request(&parts, &instructions, &[])) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_region_from_hostname() { - let region = extract_aws_region("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); - assert_eq!(region, "us-east-2"); - } - - #[test] - fn extract_region_from_sts_hostname() { - let region = extract_aws_region("sts.us-east-1.amazonaws.com").unwrap(); - assert_eq!(region, "us-east-1"); - } - - #[test] - fn non_aws_hostname_returns_none() { - assert!(extract_aws_region("api.anthropic.com").is_none()); - } - - #[test] - fn global_s3_endpoint_uses_us_east_1() { - assert_eq!( - extract_aws_region("s3.amazonaws.com").as_deref(), - Some("us-east-1") - ); - } - - #[test] - fn virtual_hosted_global_s3_endpoint_uses_us_east_1() { - assert_eq!( - extract_aws_region("my-bucket.s3.amazonaws.com").as_deref(), - Some("us-east-1") - ); - } - - #[test] - fn extract_region_dualstack() { - let region = extract_aws_region("s3.dualstack.us-west-2.amazonaws.com").unwrap(); - assert_eq!(region, "us-west-2"); - } - - #[test] - fn extract_region_fips() { - let region = extract_aws_region("bedrock-runtime-fips.us-east-1.amazonaws.com").unwrap(); - assert_eq!(region, "us-east-1"); - } - - #[test] - fn extract_region_china() { - let region = extract_aws_region("s3.cn-north-1.amazonaws.com.cn").unwrap(); - assert_eq!(region, "cn-north-1"); - } - - #[test] - fn extract_region_fips_dualstack() { - let region = extract_aws_region("s3-fips.dualstack.us-west-2.amazonaws.com").unwrap(); - assert_eq!(region, "us-west-2"); - } - - #[test] - fn extract_region_govcloud() { - let region = extract_aws_region("s3.us-gov-west-1.amazonaws.com").unwrap(); - assert_eq!(region, "us-gov-west-1"); - } - - #[test] - fn extract_region_virtual_hosted_s3() { - let region = extract_aws_region("my-bucket.s3.us-east-2.amazonaws.com").unwrap(); - assert_eq!(region, "us-east-2"); - } - - #[test] - fn sign_produces_valid_format() { - let raw = b"POST /model/us.anthropic.claude-sonnet-4-6/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\n\r\n{}"; - let result = apply_sigv4_to_request( - raw, - "bedrock-runtime.us-east-2.amazonaws.com", - "us-east-2", - "bedrock", - "AKIAIOSFODNN7EXAMPLE", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - None, - ) - .unwrap(); - let result_str = String::from_utf8_lossy(&result); - assert!( - result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/") - ); - assert!(result_str.contains("x-amz-content-sha256: ")); - assert!(result_str.contains("x-amz-date: ")); - assert!(!result_str.contains("x-amz-security-token")); - } - - #[test] - fn sign_with_session_token() { - let raw = b"POST /model/test/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\n\r\n{}"; - let result = apply_sigv4_to_request( - raw, - "bedrock-runtime.us-east-2.amazonaws.com", - "us-east-2", - "bedrock", - "ASIAEXAMPLE", - "secret", - Some("FwoGZXIvYXdzEBYaDH+session+token"), - ) - .unwrap(); - let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=ASIAEXAMPLE/")); - assert!(result_str.contains("x-amz-security-token: FwoGZXIvYXdzEBYaDH+session+token")); - } - - #[test] - fn non_signed_headers_preserved() { - let raw = b"POST /model/test/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\nAccept: application/json\r\nUser-Agent: my-agent/1.0\r\n\r\n{}"; - let result = apply_sigv4_to_request( - raw, - "bedrock-runtime.us-east-2.amazonaws.com", - "us-east-2", - "bedrock", - "AKIAIOSFODNN7EXAMPLE", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - None, - ) - .unwrap(); - let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("accept: application/json\r\n")); - assert!(result_str.contains("user-agent: my-agent/1.0\r\n")); - assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=")); - } - - #[test] - fn apply_sigv4_rewrites_request() { - let raw = b"POST /model/test/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\nAuthorization: AWS4-HMAC-SHA256 old-invalid-sig\r\nX-Amz-Date: old-date\r\n\r\n{}"; - let result = apply_sigv4_to_request( - raw, - "bedrock-runtime.us-east-2.amazonaws.com", - "us-east-2", - "bedrock", - "AKIATEST", - "secret", - None, - ) - .unwrap(); - let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); - assert!(!result_str.contains("old-invalid-sig")); - assert!(!result_str.contains("old-date")); - } - - #[test] - fn headers_only_produces_unsigned_payload() { - let raw = b"PUT /my-bucket/my-key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nContent-Type: application/octet-stream\r\nContent-Length: 1024\r\n\r\n"; - let result = apply_sigv4_headers_only( - raw, - "s3.us-east-1.amazonaws.com", - "us-east-1", - "s3", - "AKIAIOSFODNN7EXAMPLE", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - None, - ) - .unwrap(); - let result_str = String::from_utf8_lossy(&result); - assert!( - result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/") - ); - assert!(result_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); - assert!(result_str.contains("x-amz-date: ")); - assert!(result_str.ends_with("\r\n\r\n")); - } - - #[test] - fn headers_only_strips_old_auth() { - let raw = b"PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nAuthorization: AWS4-HMAC-SHA256 old-sig\r\nX-Amz-Date: old-date\r\nX-Amz-Content-Sha256: old-hash\r\nContent-Type: application/octet-stream\r\n\r\n"; - let result = apply_sigv4_headers_only( - raw, - "s3.us-east-1.amazonaws.com", - "us-east-1", - "s3", - "AKIATEST", - "secret", - None, - ) - .unwrap(); - let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); - assert!(!result_str.contains("old-sig")); - assert!(!result_str.contains("old-date")); - assert!(!result_str.contains("old-hash")); - assert!(result_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); - } - - #[test] - fn headers_only_with_session_token() { - let raw = b"PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nContent-Type: application/octet-stream\r\n\r\n"; - let result = apply_sigv4_headers_only( - raw, - "s3.us-east-1.amazonaws.com", - "us-east-1", - "s3", - "ASIAEXAMPLE", - "secret", - Some("FwoGZXIvYXdzEBYaDH+session+token"), - ) - .unwrap(); - let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("x-amz-security-token: FwoGZXIvYXdzEBYaDH+session+token")); - assert!(result_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); - } -} +pub use openshell_supervisor_middleware_builtins::sigv4::*; diff --git a/docs/extensibility/supervisor-middleware/operations.mdx b/docs/extensibility/supervisor-middleware/operations.mdx index efb3649ef1..37141e8485 100644 --- a/docs/extensibility/supervisor-middleware/operations.mdx +++ b/docs/extensibility/supervisor-middleware/operations.mdx @@ -61,6 +61,8 @@ OpenShell applies middleware changes before it injects provider credentials. Mid Request output streams to the external service while the sandbox uploads. OpenShell instead buffers output when a stage selects BUFFERED, the route needs SigV4 signing or body credential rewriting, or the request uses the forward proxy, HTTP/1.0, or `Upgrade`. JSON-RPC, MCP, and GraphQL endpoints also buffer output so OpenShell can check the replaced body against policy. Buffered output can be up to 10 MiB for SigV4 signing, 256 KiB for body credential rewriting, and 4 MiB otherwise. The preflight reports this limit to STREAM stages as `limits.max_output_body_bytes`. Larger output fails with `body_over_limit`. +AWS request signing runs afterward in the trusted built-in `openshell/sigv4`, using supervisor-resolved credentials. It signs the final request after all external request stages complete. Configure it through [AWS endpoint signing fields](/how-it-works/providers/aws), rather than a middleware binding. + ### Responses Your service receives the response status, headers, and request context. It can return `continue_without_body` or choose a body mode with `inspect`. diff --git a/docs/how-it-works/providers/aws.mdx b/docs/how-it-works/providers/aws.mdx index 087161faa9..cfddcaa0dc 100644 --- a/docs/how-it-works/providers/aws.mdx +++ b/docs/how-it-works/providers/aws.mdx @@ -9,6 +9,8 @@ keywords: "Generative AI, Cybersecurity, AI Agents, AWS, SigV4, Bedrock, S3, Cre AWS SigV4 credential signing lets sandbox agents call AWS services (Bedrock, S3, STS, and others) through the proxy's CONNECT tunnel. The proxy intercepts outbound requests, strips the sandbox client's placeholder `Authorization` header, and re-signs the request with real AWS credentials from the provider. The sandbox never sees the real credentials. +The trusted built-in `openshell/sigv4` signs requests inside the supervisor after request middleware finishes and credentials are resolved. Keep using the endpoint fields below to select signing; do not add `openshell/sigv4` to `network_middlewares`. External middleware receives no AWS secrets. When HTTP session middleware changes a body, the signer hashes the final output. These routes hold middleware output before forwarding and cap it at 10 MiB, including `sigv4:no_body` routes with request body middleware. + ## Prerequisites - A provider with `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` credentials configured. Optionally include `AWS_SESSION_TOKEN` for STS temporary credentials.