diff --git a/Cargo.lock b/Cargo.lock index 4bc657be3..1e87e3871 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,6 +280,18 @@ dependencies = [ "cc", ] +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.16.3" @@ -303,6 +315,112 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-sigv4" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.10.9", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + [[package]] name = "axum" version = "0.7.9" @@ -313,8 +431,8 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "itoa", "matchit 0.7.3", @@ -341,8 +459,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -375,8 +493,8 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -394,8 +512,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -464,6 +582,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -566,7 +694,7 @@ dependencies = [ "futures-core", "futures-util", "hex", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-named-pipe", @@ -626,6 +754,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "bzip2" version = "0.6.1" @@ -1854,7 +1992,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.14.0", "slab", "tokio", @@ -2001,6 +2139,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -2020,6 +2169,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2027,7 +2187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -2038,8 +2198,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2075,8 +2235,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -2107,7 +2267,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "log", @@ -2142,8 +2302,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -2657,8 +2817,8 @@ dependencies = [ "either", "futures", "home", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -2690,7 +2850,7 @@ checksum = "7845bcc3e0f422df4d9049570baedd9bc1942f0504594e393e72fe24092559cf" dependencies = [ "chrono", "form_urlencoded", - "http", + "http 1.4.0", "json-patch", "k8s-openapi", "schemars", @@ -3282,7 +3442,7 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http", + "http 1.4.0", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -3311,7 +3471,7 @@ dependencies = [ "bytes", "chrono", "futures-util", - "http", + "http 1.4.0", "http-auth", "jsonwebtoken 10.3.0", "lazy_static", @@ -3644,6 +3804,9 @@ version = "0.0.0" dependencies = [ "anyhow", "apollo-parser", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-runtime-api", "base64 0.22.1", "bytes", "clap", @@ -3651,7 +3814,7 @@ dependencies = [ "futures", "glob", "hex", - "hmac", + "http 1.4.0", "ipnet", "landlock", "libc", @@ -3702,8 +3865,8 @@ dependencies = [ "futures-util", "hex", "hmac", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -3829,6 +3992,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "owo-colors" version = "4.3.0" @@ -4081,6 +4250,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkcs1" version = "0.7.5" @@ -4621,8 +4796,8 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -4661,8 +4836,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -6229,8 +6404,8 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-timeout", @@ -6309,8 +6484,8 @@ dependencies = [ "base64 0.21.7", "bitflags", "bytes", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -6328,8 +6503,8 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower 0.5.3", @@ -6453,7 +6628,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -6472,7 +6647,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -6660,6 +6835,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -7325,7 +7506,7 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 4bc6803eb..a29b432a0 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -74,6 +74,25 @@ Credential placeholders in proxied HTTP requests can be resolved by the proxy when policy allows the target endpoint. Secrets must not be logged in OCSF or plain tracing output. +For AWS endpoints that require request-level signing, the proxy supports SigV4 +re-signing. When `credential_signing: sigv4` is set on an L7 endpoint, the proxy +strips the client's placeholder-based AWS auth headers, re-signs with real +credentials from the provider, and forwards the request upstream. The signing +mode is auto-detected from the client SDK's `x-amz-content-sha256` header: + +- **Signed body** (hex hash): buffers the request body, computes its SHA-256, + and includes the hash in the signature. Used by Bedrock and most AWS services. +- **Streaming unsigned** (`STREAMING-UNSIGNED-PAYLOAD-TRAILER`): signs headers + only and streams the body through without buffering. Used by S3 uploads with + `aws-chunked` encoding. +- **Unsigned payload** (`UNSIGNED-PAYLOAD`): signs headers only with no body + hash. Used by S3 over HTTPS for non-chunked requests. + +Two explicit overrides are available: `credential_signing: sigv4:body` (always +buffer and hash) and `sigv4:no_body` (always unsigned). The `Expect: +100-continue` header is handled within the SigV4 path so clients like boto3 +transmit the body before the proxy forwards to upstream. + ## Connect and Logs The supervisor runs an SSH server on a Unix socket inside the sandbox. The diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 26c8fc9d3..aba0f9483 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -135,6 +135,10 @@ struct NetworkEndpointDef { graphql_persisted_queries: BTreeMap, #[serde(default, skip_serializing_if = "is_zero_u32")] graphql_max_body_bytes: u32, + #[serde(default, skip_serializing_if = "String::is_empty")] + credential_signing: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + signing_service: String, } // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. @@ -347,6 +351,8 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { }) .collect(), graphql_max_body_bytes: e.graphql_max_body_bytes, + credential_signing: e.credential_signing, + signing_service: e.signing_service, } }) .collect(), @@ -512,6 +518,8 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { }) .collect(), graphql_max_body_bytes: e.graphql_max_body_bytes, + credential_signing: e.credential_signing.clone(), + signing_service: e.signing_service.clone(), } }) .collect(), @@ -694,6 +702,14 @@ pub enum PolicyViolation { TooManyPaths { count: usize }, /// A network endpoint uses a TLD wildcard (e.g. `*.com`). TldWildcard { policy_name: String, host: String }, + /// `credential_signing` is set but `signing_service` is missing. + MissingSigningService { policy_name: String, host: String }, + /// `credential_signing` has an unrecognized value. + UnknownCredentialSigning { + policy_name: String, + host: String, + value: String, + }, } impl fmt::Display for PolicyViolation { @@ -730,6 +746,24 @@ impl fmt::Display for PolicyViolation { use subdomain wildcards like '*.example.com' instead" ) } + Self::MissingSigningService { policy_name, host } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' has credential_signing \ + set but signing_service is empty" + ) + } + Self::UnknownCredentialSigning { + policy_name, + host, + value, + } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' has unrecognized \ + credential_signing value '{value}' (expected sigv4, sigv4:body, or sigv4:no_body)" + ) + } } } } @@ -834,6 +868,24 @@ pub fn validate_sandbox_policy( }); } } + if !ep.credential_signing.is_empty() + && !matches!( + ep.credential_signing.as_str(), + "sigv4" | "sigv4:body" | "sigv4:no_body" + ) + { + violations.push(PolicyViolation::UnknownCredentialSigning { + policy_name: name.clone(), + host: ep.host.clone(), + value: ep.credential_signing.clone(), + }); + } + if !ep.credential_signing.is_empty() && ep.signing_service.is_empty() { + violations.push(PolicyViolation::MissingSigningService { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } } } @@ -1393,6 +1445,141 @@ network_policies: assert!(validate_sandbox_policy(&policy).is_ok()); } + #[test] + fn validate_rejects_credential_signing_without_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "bedrock".into(), + endpoints: vec![NetworkEndpoint { + host: "bedrock-runtime.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4".into(), + signing_service: String::new(), + ..Default::default() + }], + ..Default::default() + }, + ); + let violations = validate_sandbox_policy(&policy).unwrap_err(); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::MissingSigningService { .. })) + ); + } + + #[test] + fn validate_accepts_credential_signing_with_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "bedrock".into(), + endpoints: vec![NetworkEndpoint { + host: "bedrock-runtime.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4".into(), + signing_service: "bedrock".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + assert!(validate_sandbox_policy(&policy).is_ok()); + } + + #[test] + fn validate_accepts_sigv4_body_with_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "bedrock".into(), + endpoints: vec![NetworkEndpoint { + host: "bedrock-runtime.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4:body".into(), + signing_service: "bedrock".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + assert!(validate_sandbox_policy(&policy).is_ok()); + } + + #[test] + fn validate_accepts_sigv4_no_body_with_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "s3".into(), + endpoints: vec![NetworkEndpoint { + host: "s3.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4:no_body".into(), + signing_service: "s3".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + assert!(validate_sandbox_policy(&policy).is_ok()); + } + + #[test] + fn validate_rejects_sigv4_no_body_without_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "s3".into(), + endpoints: vec![NetworkEndpoint { + host: "s3.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4:no_body".into(), + signing_service: String::new(), + ..Default::default() + }], + ..Default::default() + }, + ); + let violations = validate_sandbox_policy(&policy).unwrap_err(); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::MissingSigningService { .. })) + ); + } + + #[test] + fn validate_rejects_unknown_credential_signing() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "test".into(), + endpoints: vec![NetworkEndpoint { + host: "example.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4_typo".into(), + signing_service: "bedrock".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + let violations = validate_sandbox_policy(&policy).unwrap_err(); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::UnknownCredentialSigning { .. })) + ); + } + #[test] fn normalize_path_collapses_separators() { assert_eq!(normalize_path("/usr//lib"), "/usr/lib"); diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 68cc06260..87d811ec1 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -160,6 +160,10 @@ pub struct EndpointProfile { pub graphql_max_body_bytes: u32, #[serde(default, skip_serializing_if = "String::is_empty")] pub path: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub credential_signing: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub signing_service: String, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -596,6 +600,8 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { .collect(), graphql_max_body_bytes: endpoint.graphql_max_body_bytes, path: endpoint.path.clone(), + credential_signing: endpoint.credential_signing.clone(), + signing_service: endpoint.signing_service.clone(), } } @@ -626,6 +632,8 @@ fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile { .collect(), graphql_max_body_bytes: endpoint.graphql_max_body_bytes, path: endpoint.path.clone(), + credential_signing: endpoint.credential_signing.clone(), + signing_service: endpoint.signing_service.clone(), } } diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 6d527bc53..f74378a4a 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -34,9 +34,14 @@ clap = { workspace = true } miette = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } -hmac = "0.12" sha2 = { workspace = true } hex = "0.4" +http = { workspace = true } + +# AWS SigV4 request signing +aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } +aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } +aws-smithy-runtime-api = { version = "1", features = ["client"] } russh = "0.57" rand_core = "0.6" diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 703aafae4..5aec0523c 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -50,6 +50,26 @@ pub enum TlsMode { Skip, } +/// Credential signing mode for proxy-side request signing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CredentialSigning { + #[default] + None, + /// Auto-detect: include body in signature when Content-Length is present, + /// skip body when Transfer-Encoding is chunked or body is absent. + SigV4, + /// Always include body in signature (buffer body, compute SHA-256 hash). + SigV4Body, + /// Never include body in signature (use UNSIGNED-PAYLOAD, stream through). + SigV4NoBody, +} + +impl CredentialSigning { + pub fn is_sigv4(&self) -> bool { + matches!(self, Self::SigV4 | Self::SigV4Body | Self::SigV4NoBody) + } +} + /// Enforcement mode for L7 policy decisions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum EnforcementMode { @@ -88,6 +108,11 @@ pub struct L7EndpointConfig { /// When true, client-to-server GraphQL-over-WebSocket operation messages /// are classified with the same operation policy used by GraphQL-over-HTTP. pub websocket_graphql_policy: bool, + /// Proxy-side credential signing mode for this endpoint. + pub credential_signing: CredentialSigning, + /// AWS signing service name (e.g. `"bedrock"`). Required when + /// `credential_signing` is `SigV4`. + pub signing_service: String, } /// Result of an L7 policy decision for a single request. @@ -165,6 +190,36 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { .filter(|v| *v > 0) .unwrap_or(graphql::DEFAULT_MAX_BODY_BYTES); + let credential_signing = match get_object_str(val, "credential_signing").as_deref() { + Some("sigv4") => CredentialSigning::SigV4, + Some("sigv4:body") => CredentialSigning::SigV4Body, + Some("sigv4:no_body") => CredentialSigning::SigV4NoBody, + Some(other) if !other.is_empty() => { + let event = openshell_ocsf::NetworkActivityBuilder::new(crate::ocsf_ctx()) + .activity(openshell_ocsf::ActivityId::Other) + .severity(openshell_ocsf::SeverityId::High) + .message(format!( + "rejecting endpoint: unrecognized credential_signing value {other:?}" + )) + .build(); + openshell_ocsf::ocsf_emit!(event); + return None; + } + _ => CredentialSigning::None, + }; + + let signing_service = get_object_str(val, "signing_service").unwrap_or_default(); + + if credential_signing.is_sigv4() && signing_service.is_empty() { + let event = openshell_ocsf::NetworkActivityBuilder::new(crate::ocsf_ctx()) + .activity(openshell_ocsf::ActivityId::Other) + .severity(openshell_ocsf::SeverityId::High) + .message("rejecting endpoint: credential_signing requires signing_service".to_string()) + .build(); + openshell_ocsf::ocsf_emit!(event); + return None; + } + Some(L7EndpointConfig { protocol, path: get_object_str(val, "path").unwrap_or_default(), @@ -175,6 +230,8 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { websocket_credential_rewrite, request_body_credential_rewrite, websocket_graphql_policy, + credential_signing, + signing_service, }) } @@ -1196,6 +1253,41 @@ mod tests { assert_eq!(config.enforcement, EnforcementMode::Audit); } + #[test] + fn parse_credential_signing_sigv4() { + let val = regorus::Value::from_json_str( + r#"{"protocol": "rest", "credential_signing": "sigv4", "signing_service": "bedrock", "host": "bedrock.us-east-1.amazonaws.com", "port": 443}"#, + ).unwrap(); + let config = parse_l7_config(&val).unwrap(); + assert_eq!(config.credential_signing, CredentialSigning::SigV4); + assert!(config.credential_signing.is_sigv4()); + } + + #[test] + fn parse_credential_signing_sigv4_body() { + let val = regorus::Value::from_json_str( + r#"{"protocol": "rest", "credential_signing": "sigv4:body", "signing_service": "bedrock", "host": "bedrock.us-east-1.amazonaws.com", "port": 443}"#, + ).unwrap(); + let config = parse_l7_config(&val).unwrap(); + assert_eq!(config.credential_signing, CredentialSigning::SigV4Body); + assert!(config.credential_signing.is_sigv4()); + } + + #[test] + fn parse_credential_signing_sigv4_no_body() { + let val = regorus::Value::from_json_str( + r#"{"protocol": "rest", "credential_signing": "sigv4:no_body", "signing_service": "s3", "host": "s3.us-east-1.amazonaws.com", "port": 443}"#, + ).unwrap(); + let config = parse_l7_config(&val).unwrap(); + assert_eq!(config.credential_signing, CredentialSigning::SigV4NoBody); + assert!(config.credential_signing.is_sigv4()); + } + + #[test] + fn is_sigv4_false_for_none() { + assert!(!CredentialSigning::None.is_sigv4()); + } + #[test] fn parse_l7_config_websocket_protocol() { let val = regorus::Value::from_json_str( diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index 9efa7ca9f..ce387beef 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -355,6 +355,9 @@ where websocket_extensions: websocket_extension_mode(config), request_body_credential_rewrite: config.protocol == L7Protocol::Rest && config.request_body_credential_rewrite, + credential_signing: config.credential_signing, + signing_service: &config.signing_service, + host: &ctx.host, }, ) .await?; @@ -780,6 +783,9 @@ where websocket_extensions: websocket_extension_mode(config), request_body_credential_rewrite: config.protocol == L7Protocol::Rest && config.request_body_credential_rewrite, + credential_signing: config.credential_signing, + signing_service: &config.signing_service, + host: &ctx.host, }, ) .await?; @@ -1429,6 +1435,8 @@ network_policies: websocket_credential_rewrite: true, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }]; let ctx = L7EvalContext { host: "gateway.example.test".into(), @@ -1530,6 +1538,8 @@ network_policies: websocket_credential_rewrite: true, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }]; let (child_env, resolver) = SecretResolver::from_provider_env( std::iter::once(("DISCORD_BOT_TOKEN".to_string(), "real-token".to_string())).collect(), @@ -1648,6 +1658,8 @@ network_policies: websocket_credential_rewrite: true, request_body_credential_rewrite: false, websocket_graphql_policy: true, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }]; let (child_env, resolver) = SecretResolver::from_provider_env( std::iter::once(("T".to_string(), "real-token".to_string())).collect(), diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 20d52459c..b6fac71b8 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -12,10 +12,12 @@ use crate::opa::PolicyGenerationGuard; use crate::secrets::{ SecretResolver, contains_reserved_credential_marker, rewrite_http_header_block, }; +use aws_sigv4::http_request::SignableBody; use base64::Engine as _; use miette::{IntoDiagnostic, Result, miette}; use sha1::{Digest, Sha1}; use std::collections::{HashMap, HashSet}; +use std::fmt; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::debug; @@ -377,6 +379,9 @@ where generation_guard, websocket_extensions: WebSocketExtensionMode::Preserve, request_body_credential_rewrite: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + host: "", }, ) .await @@ -395,6 +400,9 @@ pub(crate) struct RelayRequestOptions<'a> { pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>, pub(crate) websocket_extensions: WebSocketExtensionMode, pub(crate) request_body_credential_rewrite: bool, + pub(crate) credential_signing: crate::l7::CredentialSigning, + pub(crate) signing_service: &'a str, + pub(crate) host: &'a str, } pub(crate) async fn relay_http_request_with_options_guarded( @@ -421,8 +429,19 @@ where parse_websocket_upgrade_request(&req.raw_header[..header_end])? }; + // When SigV4 signing is configured, strip AWS auth headers before credential + // rewriting so the fail-closed placeholder scan doesn't reject the SigV4 + // 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[..] + } else { + &req.raw_header[..header_end] + }; + let (header_bytes, expected_websocket_extension) = rewrite_websocket_extensions_for_mode( - &req.raw_header[..header_end], + header_source, options.websocket_extensions, websocket_request.is_some(), )?; @@ -442,7 +461,182 @@ where guard.ensure_current()?; } - if options.request_body_credential_rewrite { + // Apply SigV4 signing if configured. + if options.credential_signing.is_sigv4() { + // SigV4 re-signing needs the body before forwarding. If the client + // sent `Expect: 100-continue`, acknowledge it so the client transmits + // the body. Scoped to SigV4 paths only — non-SigV4 traffic forwards + // the Expect header to upstream for normal handling. + if has_expect_continue(header_str) { + client + .write_all(b"HTTP/1.1 100 Continue\r\n\r\n") + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + } + if let Some(resolver) = options.resolver { + let access_key_placeholder = + crate::secrets::placeholder_for_env_key("AWS_ACCESS_KEY_ID"); + let secret_key_placeholder = + crate::secrets::placeholder_for_env_key("AWS_SECRET_ACCESS_KEY"); + let session_token_placeholder = + crate::secrets::placeholder_for_env_key("AWS_SESSION_TOKEN"); + + match ( + resolver.resolve_placeholder(&access_key_placeholder), + resolver.resolve_placeholder(&secret_key_placeholder), + ) { + (Some(access_key), Some(secret_key)) => { + let session_token = resolver.resolve_placeholder(&session_token_placeholder); + let region = crate::sigv4::extract_aws_region(options.host) + .unwrap_or_else(|| "us-east-1".to_string()); + let service = &options.signing_service; + if service.is_empty() { + return Err(miette!( + "SigV4 signing configured but signing_service not set in policy" + )); + } + + let payload_mode = match options.credential_signing { + crate::l7::CredentialSigning::SigV4Body => SigV4PayloadMode::SignBody, + crate::l7::CredentialSigning::SigV4NoBody => { + SigV4PayloadMode::UnsignedPayload + } + crate::l7::CredentialSigning::SigV4 => detect_payload_mode(header_str)?, + crate::l7::CredentialSigning::None => unreachable!(), + }; + + let event = openshell_ocsf::NetworkActivityBuilder::new( + crate::ocsf_ctx(), + ) + .activity(openshell_ocsf::ActivityId::Traffic) + .action(openshell_ocsf::ActionId::Allowed) + .disposition(openshell_ocsf::DispositionId::Allowed) + .severity(openshell_ocsf::SeverityId::Informational) + .status(openshell_ocsf::StatusId::Success) + .dst_endpoint(openshell_ocsf::Endpoint::from_domain( + options.host, + 0, + )) + .message(format!( + "SigV4 re-signing {host} service={service} region={region} mode={payload_mode}", + host = options.host, + )) + .build(); + openshell_ocsf::ocsf_emit!(event); + + if payload_mode == SigV4PayloadMode::SignBody { + // Buffer body and include its hash in the signature. + // This requires Content-Length — chunked bodies cannot + // be buffered for signing. detect_payload_mode() should + // route chunked requests to the streaming path, but + // guard here as defense-in-depth. + if matches!(parse_body_length(header_str)?, BodyLength::Chunked) { + return Err(miette!( + "SigV4 body signing requires Content-Length; \ + chunked transfer encoding is not supported in this mode" + )); + } + let overflow = &req.raw_header[header_end..]; + let mut full_request = rewrite_result.rewritten.clone(); + full_request.extend_from_slice(overflow); + if let BodyLength::ContentLength(body_len) = parse_body_length(header_str)? + { + if body_len > MAX_REWRITE_BODY_BYTES as u64 { + return Err(miette!( + "SigV4 body signing buffers at most {MAX_REWRITE_BODY_BYTES} bytes" + )); + } + let already_have = overflow.len() as u64; + if body_len > already_have { + let remaining = + usize::try_from(body_len - already_have).unwrap_or(usize::MAX); + let mut body_buf = vec![0u8; remaining]; + client.read_exact(&mut body_buf).await.into_diagnostic()?; + full_request.extend_from_slice(&body_buf); + } + } + + let signed = crate::sigv4::apply_sigv4_to_request( + &full_request, + options.host, + ®ion, + service, + access_key, + secret_key, + session_token, + )?; + 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( + &rewrite_result.rewritten, + options.host, + ®ion, + service, + access_key, + secret_key, + session_token, + signable_body, + )?; + upstream + .write_all(&signed_headers) + .await + .into_diagnostic()?; + + let overflow = &req.raw_header[header_end..]; + if !overflow.is_empty() { + if let Some(guard) = options.generation_guard { + guard.ensure_current()?; + } + upstream.write_all(overflow).await.into_diagnostic()?; + } + let overflow_len = overflow.len() as u64; + + match req.body_length { + BodyLength::ContentLength(len) => { + let remaining = len.saturating_sub(overflow_len); + if remaining > 0 { + relay_fixed( + client, + upstream, + remaining, + options.generation_guard, + ) + .await?; + } + } + BodyLength::Chunked => { + relay_chunked( + client, + upstream, + &req.raw_header[header_end..], + options.generation_guard, + ) + .await?; + } + BodyLength::None => {} + } + } + } + _ => { + return Err(miette!( + "SigV4 signing configured but AWS credentials not found in provider" + )); + } + } + } else { + return Err(miette!( + "SigV4 signing configured but no secret resolver available" + )); + } + } else if options.request_body_credential_rewrite { let body = collect_and_rewrite_request_body( req, client, @@ -1328,6 +1522,74 @@ fn non_empty(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } +/// Check if the request includes `Expect: 100-continue`. +fn has_expect_continue(headers: &str) -> bool { + headers.lines().skip(1).any(|line| { + let lower = line.to_ascii_lowercase(); + lower.starts_with("expect:") + && lower + .split_once(':') + .map_or(false, |(_, v)| v.trim() == "100-continue") + }) +} + +/// 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"), + } + } +} + +/// 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` → `StreamingUnsignedTrailer` (re-sign +/// headers only; the proxy cannot reproduce per-chunk signatures, but the +/// body streams through intact and AWS accepts unsigned streaming payloads) +/// - 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" | "streaming-aws4-hmac-sha256-payload" => { + Ok(SigV4PayloadMode::StreamingUnsignedTrailer) + } + "unsigned-payload" => Ok(SigV4PayloadMode::UnsignedPayload), + v if v.starts_with("streaming-") => { + Ok(SigV4PayloadMode::StreamingUnsignedTrailer) + } + _ => Ok(SigV4PayloadMode::SignBody), + }; + } + } + Ok( + if matches!(parse_body_length(headers)?, BodyLength::ContentLength(_)) { + SigV4PayloadMode::SignBody + } else { + SigV4PayloadMode::UnsignedPayload + }, + ) +} + /// Parse Content-Length or Transfer-Encoding from HTTP headers. /// /// Per RFC 7230 Section 3.3.3, rejects requests containing both diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 34661659c..3f5a0efa5 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -24,6 +24,7 @@ mod provider_credentials; pub mod proxy; mod sandbox; mod secrets; +pub mod sigv4; mod skills; mod ssh; mod supervisor_session; diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index f73f3bc14..545efdea8 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -1116,6 +1116,12 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if e.request_body_credential_rewrite { ep["request_body_credential_rewrite"] = true.into(); } + if !e.credential_signing.is_empty() { + ep["credential_signing"] = e.credential_signing.clone().into(); + } + if !e.signing_service.is_empty() { + ep["signing_service"] = e.signing_service.clone().into(); + } if !e.persisted_queries.is_empty() { ep["persisted_queries"] = e.persisted_queries.clone().into(); } @@ -2718,6 +2724,65 @@ network_policies: assert!(l7.websocket_credential_rewrite); } + #[test] + fn l7_endpoint_config_preserves_proto_credential_signing() { + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "bedrock".to_string(), + NetworkPolicyRule { + name: "bedrock".to_string(), + endpoints: vec![NetworkEndpoint { + host: "bedrock-runtime.us-east-2.amazonaws.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "read-write".to_string(), + credential_signing: "sigv4".to_string(), + signing_service: "bedrock".to_string(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/local/bin/claude".to_string(), + ..Default::default() + }], + }, + ); + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: Some(ProtoFs { + include_workdir: true, + read_only: vec![], + read_write: vec![], + }), + landlock: Some(openshell_core::proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + process: Some(ProtoProc { + run_as_user: "sandbox".to_string(), + run_as_group: "sandbox".to_string(), + }), + network_policies, + }; + + let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); + let input = NetworkInput { + host: "bedrock-runtime.us-east-2.amazonaws.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/local/bin/claude"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let config = engine + .query_endpoint_config(&input) + .unwrap() + .expect("endpoint config"); + let l7 = crate::l7::parse_l7_config(&config).unwrap(); + assert_eq!(l7.credential_signing, crate::l7::CredentialSigning::SigV4); + assert_eq!(l7.signing_service, "bedrock"); + } + #[test] fn l7_endpoint_config_preserves_proto_request_body_credential_rewrite() { let mut network_policies = std::collections::HashMap::new(); diff --git a/crates/openshell-sandbox/src/policy_local.rs b/crates/openshell-sandbox/src/policy_local.rs index d65bce324..3a9de88a2 100644 --- a/crates/openshell-sandbox/src/policy_local.rs +++ b/crates/openshell-sandbox/src/policy_local.rs @@ -1121,6 +1121,8 @@ fn network_endpoint_from_json( graphql_persisted_queries: HashMap::new(), graphql_max_body_bytes: 0, path: String::new(), + credential_signing: String::new(), + signing_service: String::new(), }) } diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 8e27e3d62..f26f2f083 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -2865,6 +2865,9 @@ where generation_guard: Some(options.generation_guard), websocket_extensions: options.websocket_extensions, request_body_credential_rewrite: options.request_body_credential_rewrite, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + host: "", }, ) .await @@ -3819,6 +3822,7 @@ async fn handle_forward_proxy( return Ok(()); } }; + if let Err(e) = forward_generation_guard.ensure_current() { emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); respond( @@ -3970,6 +3974,8 @@ mod tests { websocket_credential_rewrite, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), } } @@ -4516,6 +4522,8 @@ network_policies: websocket_credential_rewrite: false, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }, }, L7ConfigSnapshot { @@ -4529,6 +4537,8 @@ network_policies: websocket_credential_rewrite: false, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }, }, ]; diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs new file mode 100644 index 000000000..8765c0427 --- /dev/null +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -0,0 +1,556 @@ +// 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; + +/// 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 { + 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. +pub fn strip_aws_headers(raw: &[u8]) -> Vec { + let header_end = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(raw.len(), |p| p + 4); + + let header_str = String::from_utf8_lossy(&raw[..header_end]); + 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..]); + } + + 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. +fn parse_request_parts(header_str: &str) -> RequestParts<'_> { + 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) + } + }); + + // 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 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 = String::from_utf8_lossy(&raw[..header_end]); + 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`. +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 = String::from_utf8_lossy(raw_headers); + 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_endpoint_returns_none() { + assert!(extract_aws_region("s3.amazonaws.com").is_none()); + } + + #[test] + fn virtual_hosted_global_endpoint_returns_none() { + assert!(extract_aws_region("my-bucket.s3.amazonaws.com").is_none()); + } + + #[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-sandbox/tests/sigv4_real_aws.rs b/crates/openshell-sandbox/tests/sigv4_real_aws.rs new file mode 100644 index 000000000..009d70805 --- /dev/null +++ b/crates/openshell-sandbox/tests/sigv4_real_aws.rs @@ -0,0 +1,318 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for SigV4 signing against real AWS endpoints. +//! +//! These tests are `#[ignore]`d by default — they require real AWS credentials +//! in `~/.aws/credentials` and network access. +//! +//! Run with: +//! cargo test -p openshell-sandbox --test sigv4_real_aws -- --ignored --nocapture +//! +//! For S3 tests, also set: +//! S3_TEST_BUCKET=your-bucket-name + +use std::io::BufRead; +use std::net::TcpStream; +use std::sync::Arc; + +fn load_aws_credentials() -> Option<(String, String, Option)> { + let home = std::env::var("HOME").ok()?; + let path = std::path::Path::new(&home).join(".aws/credentials"); + let file = std::fs::File::open(path).ok()?; + let reader = std::io::BufReader::new(file); + + let mut access_key = None; + let mut secret_key = None; + let mut session_token = None; + let mut in_default = false; + + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_default = trimmed == "[default]"; + continue; + } + if !in_default { + continue; + } + if let Some((k, v)) = trimmed.split_once('=') { + match k.trim() { + "aws_access_key_id" => access_key = Some(v.trim().to_string()), + "aws_secret_access_key" => secret_key = Some(v.trim().to_string()), + "aws_session_token" => session_token = Some(v.trim().to_string()), + _ => {} + } + } + } + + Some((access_key?, secret_key?, session_token)) +} + +/// Send raw signed HTTP bytes over TLS and return (status_code, response_body). +fn send_https_request(host: &str, signed_request: &[u8]) -> (u16, String) { + use std::io::{Read, Write}; + + let root_store = + rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + let server_name: rustls::pki_types::ServerName<'_> = host.to_string().try_into().unwrap(); + let mut conn = rustls::ClientConnection::new(Arc::new(config), server_name).unwrap(); + let mut sock = TcpStream::connect(format!("{host}:443")).expect("TCP connect"); + sock.set_read_timeout(Some(std::time::Duration::from_secs(30))) + .ok(); + let mut tls = rustls::Stream::new(&mut conn, &mut sock); + + tls.write_all(signed_request).expect("write request"); + tls.flush().expect("flush"); + + // Read response headers + body. We read in chunks and stop when we've + // read Content-Length bytes of body, or on connection close / timeout. + let mut response = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + match tls.read(&mut buf) { + Ok(0) => break, + Ok(n) => response.extend_from_slice(&buf[..n]), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => break, + Err(e) => { + // ConnectionAborted / UnexpectedEof are normal for Connection: close + if matches!( + e.kind(), + std::io::ErrorKind::ConnectionAborted | std::io::ErrorKind::UnexpectedEof + ) { + break; + } + panic!("read error: {e}"); + } + } + // Check if we have the full response (headers + content-length body) + let resp_str = String::from_utf8_lossy(&response); + if let Some(header_end) = resp_str.find("\r\n\r\n") { + let headers = &resp_str[..header_end]; + let body_start = header_end + 4; + if let Some(cl) = headers.lines().find_map(|l| { + let lower = l.to_ascii_lowercase(); + lower + .strip_prefix("content-length:") + .and_then(|v| v.trim().parse::().ok()) + }) { + if response.len() >= body_start + cl { + break; + } + } + } + } + + let response_str = String::from_utf8_lossy(&response); + let status = response_str + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + let body = response_str + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .to_string(); + + (status, body) +} + +#[test] +#[ignore] +fn bedrock_invoke_with_signed_body() { + let (access_key, secret_key, session_token) = + load_aws_credentials().expect("AWS credentials not found in ~/.aws/credentials"); + + let host = "bedrock-runtime.us-east-2.amazonaws.com"; + let body = r#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":10,"messages":[{"role":"user","content":"Say exactly: sigv4_ok"}]}"#; + + let raw_request = format!( + "POST /model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/invoke HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len() + ); + + let signed = openshell_sandbox::sigv4::apply_sigv4_to_request( + raw_request.as_bytes(), + host, + "us-east-2", + "bedrock", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing failed"); + + let signed_str = String::from_utf8_lossy(&signed); + assert!( + signed_str.contains("x-amz-content-sha256: "), + "should contain body hash header" + ); + assert!( + !signed_str.contains("UNSIGNED-PAYLOAD"), + "should NOT contain UNSIGNED-PAYLOAD" + ); + + let (status, body) = send_https_request(host, &signed); + println!("Bedrock signed-body response: status={status}"); + println!(" body: {}", &body[..body.len().min(200)]); + + assert_eq!(status, 200, "Bedrock should accept signed payload"); +} + +#[test] +#[ignore] +fn bedrock_rejects_unsigned_body() { + let (access_key, secret_key, session_token) = + load_aws_credentials().expect("AWS credentials not found in ~/.aws/credentials"); + + let host = "bedrock-runtime.us-east-2.amazonaws.com"; + let body = r#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":10,"messages":[{"role":"user","content":"test"}]}"#; + + let raw_headers = format!( + "POST /model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/invoke HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + body.len() + ); + + let signed_headers = openshell_sandbox::sigv4::apply_sigv4_headers_only( + raw_headers.as_bytes(), + host, + "us-east-2", + "bedrock", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing failed"); + + let signed_str = String::from_utf8_lossy(&signed_headers); + assert!(signed_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); + + let mut full_request = signed_headers; + full_request.extend_from_slice(body.as_bytes()); + + let (status, resp_body) = send_https_request(host, &full_request); + println!("Bedrock unsigned-body response: status={status}"); + println!(" body: {}", &resp_body[..resp_body.len().min(200)]); + + assert_eq!(status, 403, "Bedrock should reject UNSIGNED-PAYLOAD"); +} + +#[test] +#[ignore] +fn s3_put_get_delete_with_unsigned_body() { + let bucket = + std::env::var("S3_TEST_BUCKET").expect("Set S3_TEST_BUCKET env var to run this test"); + let (access_key, secret_key, session_token) = + load_aws_credentials().expect("AWS credentials not found in ~/.aws/credentials"); + + let host = format!("{bucket}.s3.us-east-2.amazonaws.com"); + let key = format!( + "openshell-sigv4-test-{}.txt", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ); + let body = b"Hello from OpenShell SigV4 unsigned payload test"; + + // --- PUT --- + let raw_put = format!( + "PUT /{key} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: text/plain\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + body.len() + ); + + let signed_put = openshell_sandbox::sigv4::apply_sigv4_headers_only( + raw_put.as_bytes(), + &host, + "us-east-2", + "s3", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing PUT failed"); + + let signed_str = String::from_utf8_lossy(&signed_put); + assert!(signed_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); + + let mut full_put = signed_put; + full_put.extend_from_slice(body); + + let (put_status, _) = send_https_request(&host, &full_put); + println!("S3 PUT unsigned-body: status={put_status}"); + assert_eq!(put_status, 200, "S3 should accept UNSIGNED-PAYLOAD PUT"); + + // --- GET --- + let raw_get = format!( + "GET /{key} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Connection: close\r\n\ + \r\n" + ); + + let signed_get = openshell_sandbox::sigv4::apply_sigv4_headers_only( + raw_get.as_bytes(), + &host, + "us-east-2", + "s3", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing GET failed"); + + let (get_status, get_body) = send_https_request(&host, &signed_get); + println!("S3 GET: status={get_status}"); + println!(" body: {}", &get_body[..get_body.len().min(200)]); + assert_eq!(get_status, 200, "S3 GET should succeed"); + assert!( + get_body.contains("Hello from OpenShell"), + "GET body should contain uploaded content" + ); + + // --- DELETE cleanup --- + let raw_del = format!( + "DELETE /{key} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Connection: close\r\n\ + \r\n" + ); + + let signed_del = openshell_sandbox::sigv4::apply_sigv4_headers_only( + raw_del.as_bytes(), + &host, + "us-east-2", + "s3", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing DELETE failed"); + + let (del_status, _) = send_https_request(&host, &signed_del); + println!("S3 DELETE: status={del_status}"); +} diff --git a/proto/sandbox.proto b/proto/sandbox.proto index ef0b0540f..64dc26d7f 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -128,6 +128,13 @@ message NetworkEndpoint { // Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless // they are converted through an explicit user-authored policy path. bool advisor_proposed = 18; + // Proxy-side credential signing mode: "sigv4" for AWS SigV4 re-signing. + // When set, the proxy strips the client's Authorization header and computes + // a fresh SigV4 signature using real credentials from the provider. + string credential_signing = 19; + // AWS signing service name override. Required when credential_signing is + // "sigv4" — e.g. "bedrock" for bedrock-runtime endpoints. + string signing_service = 20; } // Trusted GraphQL operation classification.