From 4cce8ede6a1f27781b486016542b6b14ce39ce34 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Mon, 1 Jun 2026 09:36:30 -0400 Subject: [PATCH 1/9] feat(sandbox): proxy-side AWS SigV4 credential signing for CONNECT tunnels Add proxy-side AWS SigV4 re-signing so sandbox clients can reach AWS services (Bedrock) through the CONNECT tunnel using placeholder credentials. The proxy strips the invalid signature, resolves real credentials from the SecretResolver, re-signs with the aws-sigv4 crate, and forwards. Configuration is policy-driven via two new fields (credential_signing, signing_service). Policy YAML example: credential_signing: sigv4 signing_service: bedrock Implementation: - sigv4.rs: strip_aws_headers removes old auth headers before the fail-closed placeholder scan; apply_sigv4_to_request re-signs using the aws-sigv4 SDK with PayloadChecksumKind::XAmzSha256 enabled. Returns Result instead of panicking. Non-signed headers (Accept, User-Agent, etc.) are preserved in the output. - rest.rs: SigV4 path buffers body (capped at MAX_REWRITE_BODY_BYTES) for signing, then forwards the re-signed request upstream. - Proto: credential_signing (field 19), signing_service (field 20) on NetworkEndpoint. - Policy/OPA: plumbed through serde, proto conversion, and Rego data. - Supports AWS session tokens (STS temporary credentials). - Integration test against real Bedrock (ignored, requires AWS creds). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 259 +++++++++++++--- architecture/sandbox.md | 6 + crates/openshell-policy/src/lib.rs | 8 + crates/openshell-providers/src/profiles.rs | 2 + crates/openshell-sandbox/Cargo.toml | 7 +- crates/openshell-sandbox/src/l7/mod.rs | 33 ++ crates/openshell-sandbox/src/l7/relay.rs | 12 + crates/openshell-sandbox/src/l7/rest.rs | 96 +++++- crates/openshell-sandbox/src/lib.rs | 1 + crates/openshell-sandbox/src/opa.rs | 65 ++++ crates/openshell-sandbox/src/policy_local.rs | 2 + crates/openshell-sandbox/src/proxy.rs | 10 + crates/openshell-sandbox/src/sigv4.rs | 303 +++++++++++++++++++ proto/sandbox.proto | 7 + 14 files changed, 769 insertions(+), 42 deletions(-) create mode 100644 crates/openshell-sandbox/src/sigv4.rs 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..468d13731 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -74,6 +74,12 @@ 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, buffers the request body, +computes a fresh SigV4 signature using real credentials from the provider, and +forwards the re-signed request 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..b9c249fc3 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(), diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 68cc06260..274a8b524 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -596,6 +596,8 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { .collect(), graphql_max_body_bytes: endpoint.graphql_max_body_bytes, path: endpoint.path.clone(), + credential_signing: String::new(), + signing_service: String::new(), } } 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..11c0a97ea 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -50,6 +50,14 @@ pub enum TlsMode { Skip, } +/// Credential signing mode for proxy-side request signing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CredentialSigning { + #[default] + None, + SigV4, +} + /// Enforcement mode for L7 policy decisions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum EnforcementMode { @@ -88,6 +96,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 +178,24 @@ 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(other) if !other.is_empty() => { + let event = openshell_ocsf::NetworkActivityBuilder::new(crate::ocsf_ctx()) + .activity(openshell_ocsf::ActivityId::Other) + .severity(openshell_ocsf::SeverityId::Medium) + .message(format!( + "unrecognized credential_signing value {other:?}, falling back to none" + )) + .build(); + openshell_ocsf::ocsf_emit!(event); + CredentialSigning::None + } + _ => CredentialSigning::None, + }; + + let signing_service = get_object_str(val, "signing_service").unwrap_or_default(); + Some(L7EndpointConfig { protocol, path: get_object_str(val, "path").unwrap_or_default(), @@ -175,6 +206,8 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { websocket_credential_rewrite, request_body_credential_rewrite, websocket_graphql_policy, + credential_signing, + signing_service, }) } 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..1ec4f141f 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -377,6 +377,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 +398,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 +427,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 == crate::l7::CredentialSigning::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 +459,82 @@ where guard.ensure_current()?; } - if options.request_body_credential_rewrite { + // Apply SigV4 signing if configured. We need the full request (headers + body) + // to compute the signature, so for SigV4 we always buffer the body first. + if options.credential_signing == crate::l7::CredentialSigning::SigV4 { + 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" + )); + } + debug!( + host = %options.host, + region = %region, + service = %service, + "applying SigV4 signing to CONNECT tunnel request" + ); + + // Collect body from overflow + stream + let overflow = &req.raw_header[header_end..]; + let mut full_request = rewrite_result.rewritten.clone(); + full_request.extend_from_slice(overflow); + // Read remaining body based on content-length + if let BodyLength::ContentLength(body_len) = parse_body_length(header_str)? { + if body_len > MAX_REWRITE_BODY_BYTES as u64 { + return Err(miette!( + "SigV4 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()?; + } + _ => { + 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, 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..3a6e50b1b --- /dev/null +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -0,0 +1,303 @@ +// 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; + +/// Extract the AWS region from a standard AWS hostname. +/// Pattern: `..amazonaws.com` → ``. +pub fn extract_aws_region(host: &str) -> Option { + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() >= 4 && parts[parts.len() - 2] == "amazonaws" && parts[parts.len() - 1] == "com" + { + Some(parts[1].to_string()) + } else { + 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 +} + +/// 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. +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 lines: Vec<&str> = header_str.split("\r\n").collect(); + + let (method, path) = lines.first().map_or(("GET", "/"), |first_line| { + let parts: Vec<&str> = first_line.splitn(3, ' ').collect(); + if parts.len() >= 2 { + (parts[0], parts[1]) + } else { + ("GET", "/") + } + }); + + // Collect all non-AWS headers for forwarding, and a subset for 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. + 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 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; + } + all_headers.push((lower.clone(), v.trim().to_string())); + if lower == "host" || lower == "content-type" || lower == "content-length" { + headers_to_sign.push((lower, v.trim().to_string())); + } + } + } + + let uri = format!("https://{host}{path}"); + + let identity: Identity = Credentials::new( + access_key, + secret_key, + session_token.map(ToString::to_string), + None, + "openshell", + ) + .into(); + + let mut settings = SigningSettings::default(); + settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; + + let signing_params = v4::SigningParams::builder() + .identity(&identity) + .region(region) + .name(service) + .time(SystemTime::now()) + .settings(settings) + .build() + .map_err(|e| miette!("SigV4 signing params: {e}"))? + .into(); + + let signable_request = SignableRequest::new( + method, + &uri, + 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(); + + // Rebuild the request with signed headers + let mut output = Vec::with_capacity(raw.len() + 256); + + // Request line + if let Some(first_line) = lines.first() { + output.extend_from_slice(first_line.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + + // All original non-AWS headers + for (k, v) in &all_headers { + output.extend_from_slice(format!("{k}: {v}\r\n").as_bytes()); + } + + // Signed headers from the SDK + for (name, value) in instructions.headers() { + output.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); + } + + // End of headers + output.extend_from_slice(b"\r\n"); + + // Body + output.extend_from_slice(body); + + Ok(output) +} + +#[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 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")); + } +} 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. From 7141a8112afe350708d06acfbaf04dc89c78ce57 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Mon, 1 Jun 2026 16:50:48 -0400 Subject: [PATCH 2/9] fix(policy): validate signing_service at policy load time Reject policies where credential_signing is set but signing_service is empty during validate_sandbox_policy() instead of failing at connection time. The runtime check in rest.rs is kept as defense-in-depth. --- crates/openshell-policy/src/lib.rs | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index b9c249fc3..c97a7155c 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -702,6 +702,8 @@ 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 }, } impl fmt::Display for PolicyViolation { @@ -738,6 +740,13 @@ 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" + ) + } } } } @@ -842,6 +851,12 @@ pub fn validate_sandbox_policy( }); } } + if !ep.credential_signing.is_empty() && ep.signing_service.is_empty() { + violations.push(PolicyViolation::MissingSigningService { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } } } @@ -1401,6 +1416,49 @@ 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 normalize_path_collapses_separators() { assert_eq!(normalize_path("/usr//lib"), "/usr/lib"); From 6676d8bdcf7214f070fe4a212bab2da81bf68f9f Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Tue, 2 Jun 2026 12:30:17 -0400 Subject: [PATCH 3/9] =?UTF-8?q?feat(sandbox):=20support=20unsigned=20paylo?= =?UTF-8?q?ad=20and=20streaming=20SigV4=20signing=20Extend=20the=20SigV4?= =?UTF-8?q?=20proxy=20re-signing=20to=20auto-detect=20the=20correct=20payl?= =?UTF-8?q?oad=20signing=20mode=20from=20the=20client=20SDK's=20x-amz-cont?= =?UTF-8?q?ent-sha256=20header:=20-=20Hex=20hash=20=E2=86=92=20buffer=20bo?= =?UTF-8?q?dy=20and=20include=20hash=20in=20signature=20(Bedrock)=20-=20ST?= =?UTF-8?q?REAMING-UNSIGNED-PAYLOAD-TRAILER=20=E2=86=92=20sign=20headers?= =?UTF-8?q?=20only,=20stream=20body=20=20=20through=20for=20aws-chunked=20?= =?UTF-8?q?uploads=20(S3=20PutObject,=20upload=5Ffileobj)=20-=20UNSIGNED-P?= =?UTF-8?q?AYLOAD=20=E2=86=92=20sign=20headers=20only,=20no=20body=20buffe?= =?UTF-8?q?ring=20(S3=20over=20HTTPS)=20-=20Absent=20=E2=86=92=20fall=20ba?= =?UTF-8?q?ck=20to=20Content-Length=20heuristic=20This=20eliminates=20the?= =?UTF-8?q?=20need=20for=20body=20buffering=20on=20S3=20uploads=20and=20ad?= =?UTF-8?q?ds=20support=20for=20chunked=20transfer=20encoding=20that=20the?= =?UTF-8?q?=20previous=20implementation=20could=20not=20handle.=20New=20cr?= =?UTF-8?q?edential=5Fsigning=20policy=20values:=20-=20sigv4=20=20=20=20?= =?UTF-8?q?=20=20=20=E2=80=94=20auto-detect=20from=20client=20headers=20(r?= =?UTF-8?q?ecommended)=20-=20sigv4:body=20=20=E2=80=94=20always=20buffer?= =?UTF-8?q?=20and=20hash=20the=20body=20-=20sigv4:no=5Fbody=20=E2=80=94=20?= =?UTF-8?q?always=20use=20UNSIGNED-PAYLOAD=20Also=20adds=20Expect:=20100-c?= =?UTF-8?q?ontinue=20handling=20in=20the=20REST=20L7=20relay=20so=20client?= =?UTF-8?q?s=20like=20boto3's=20S3=20PutObject=20receive=20the=20interim?= =?UTF-8?q?=20100=20response=20before=20sending=20the=20body.=20Validated?= =?UTF-8?q?=20end-to-end=20from=20inside=20a=20Podman=20sandbox=20against?= =?UTF-8?q?=20real=20AWS:=20Bedrock=20InvokeModel,=20S3=20PUT/GET/DELETE,?= =?UTF-8?q?=20and=20streaming=20upload=5Ffileobj.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/openshell-policy/src/lib.rs | 73 +++- crates/openshell-sandbox/src/l7/mod.rs | 49 +++ crates/openshell-sandbox/src/l7/rest.rs | 206 +++++++++-- crates/openshell-sandbox/src/sigv4.rs | 333 +++++++++++++----- .../openshell-sandbox/tests/sigv4_real_aws.rs | 318 +++++++++++++++++ 5 files changed, 865 insertions(+), 114 deletions(-) create mode 100644 crates/openshell-sandbox/tests/sigv4_real_aws.rs diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index c97a7155c..46a7d3b0d 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -1434,9 +1434,11 @@ network_policies: }, ); let violations = validate_sandbox_policy(&policy).unwrap_err(); - assert!(violations - .iter() - .any(|v| matches!(v, PolicyViolation::MissingSigningService { .. }))); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::MissingSigningService { .. })) + ); } #[test] @@ -1459,6 +1461,71 @@ network_policies: 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 normalize_path_collapses_separators() { assert_eq!(normalize_path("/usr//lib"), "/usr/lib"); diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 11c0a97ea..276f61be9 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -55,7 +55,19 @@ pub enum TlsMode { 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. @@ -180,6 +192,8 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { 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) @@ -1229,6 +1243,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/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 1ec4f141f..408c946d6 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; @@ -431,7 +433,7 @@ where // 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 == crate::l7::CredentialSigning::SigV4 { + 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 { @@ -459,9 +461,19 @@ where guard.ensure_current()?; } - // Apply SigV4 signing if configured. We need the full request (headers + body) - // to compute the signature, so for SigV4 we always buffer the body first. - if options.credential_signing == crate::l7::CredentialSigning::SigV4 { + // If the client sent `Expect: 100-continue`, acknowledge it so the client + // sends the body. Without this, clients like boto3's S3 PutObject wait + // for the 100 response before transmitting any body bytes. + 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()?; + } + + // Apply SigV4 signing if configured. + if options.credential_signing.is_sigv4() { if let Some(resolver) = options.resolver { let access_key_placeholder = crate::secrets::placeholder_for_env_key("AWS_ACCESS_KEY_ID"); @@ -484,44 +496,113 @@ where "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!(), + }; + debug!( host = %options.host, region = %region, service = %service, + payload_mode = %payload_mode, "applying SigV4 signing to CONNECT tunnel request" ); - // Collect body from overflow + stream - let overflow = &req.raw_header[header_end..]; - let mut full_request = rewrite_result.rewritten.clone(); - full_request.extend_from_slice(overflow); - // Read remaining body based on content-length - if let BodyLength::ContentLength(body_len) = parse_body_length(header_str)? { - if body_len > MAX_REWRITE_BODY_BYTES as u64 { - return Err(miette!( - "SigV4 signing buffers at most {MAX_REWRITE_BODY_BYTES} bytes" - )); + if payload_mode == SigV4PayloadMode::SignBody { + // Buffer body and include its hash in the signature. + 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 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 => {} } } - - 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()?; } _ => { return Err(miette!( @@ -1420,6 +1501,67 @@ 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` +/// - Absent or a hex hash → `SignBody` (buffer + hash) +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 Ok(match val { + "streaming-unsigned-payload-trailer" => SigV4PayloadMode::StreamingUnsignedTrailer, + "unsigned-payload" => SigV4PayloadMode::UnsignedPayload, + _ => SigV4PayloadMode::SignBody, + }); + } + } + // No x-amz-content-sha256 header — default to signing body if Content-Length + // is present, otherwise unsigned. + 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/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs index 3a6e50b1b..3a24c2208 100644 --- a/crates/openshell-sandbox/src/sigv4.rs +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -68,47 +68,55 @@ pub fn strip_aws_headers(raw: &[u8]) -> Vec { 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. -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 { - &[] - }; +struct RequestParts<'a> { + method: &'a str, + path: &'a str, + request_line: &'a str, + headers_to_sign: Vec<(String, String)>, + all_headers: Vec<(String, String)>, +} - let header_str = String::from_utf8_lossy(&raw[..header_end]); +/// 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) = lines.first().map_or(("GET", "/"), |first_line| { - let parts: Vec<&str> = first_line.splitn(3, ' ').collect(); - if parts.len() >= 2 { - (parts[0], parts[1]) - } else { - ("GET", "/") - } - }); + 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", + ]; - // Collect all non-AWS headers for forwarding, and a subset for 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. 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) { @@ -117,48 +125,114 @@ pub fn apply_sigv4_to_request( } if let Some((k, v)) = line.split_once(':') { let lower = k.trim().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") - { + if STRIP_HEADERS.iter().any(|s| lower.starts_with(s)) { continue; } all_headers.push((lower.clone(), v.trim().to_string())); - if lower == "host" || lower == "content-type" || lower == "content-length" { + if !UNSIGNED_HEADERS.iter().any(|s| lower.starts_with(s)) { headers_to_sign.push((lower, v.trim().to_string())); } } } - let uri = format!("https://{host}{path}"); - - let identity: Identity = Credentials::new( - access_key, - secret_key, - session_token.map(ToString::to_string), - None, - "openshell", - ) - .into(); + 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; - let signing_params = v4::SigningParams::builder() - .identity(&identity) + 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(); + .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( - method, + parts.method, &uri, - headers_to_sign + parts + .headers_to_sign .iter() .map(|(k, v)| (k.as_str(), v.as_str())), SignableBody::Bytes(body), @@ -169,32 +243,72 @@ pub fn apply_sigv4_to_request( .map_err(|e| miette!("SigV4 signing failed: {e}"))? .into_parts(); - // Rebuild the request with signed headers - let mut output = Vec::with_capacity(raw.len() + 256); - - // Request line - if let Some(first_line) = lines.first() { - output.extend_from_slice(first_line.as_bytes()); - output.extend_from_slice(b"\r\n"); - } + Ok(rebuild_request(&parts, &instructions, body)) +} - // All original non-AWS headers - for (k, v) in &all_headers { - output.extend_from_slice(format!("{k}: {v}\r\n").as_bytes()); - } +/// 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, + ) +} - // Signed headers from the SDK - for (name, value) in instructions.headers() { - output.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); - } +/// 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)?; - // End of headers - output.extend_from_slice(b"\r\n"); + 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}"))?; - // Body - output.extend_from_slice(body); + let (instructions, _signature) = sign(signable_request, &signing_params) + .map_err(|e| miette!("SigV4 signing failed: {e}"))? + .into_parts(); - Ok(output) + Ok(rebuild_request(&parts, &instructions, &[])) } #[cfg(test)] @@ -300,4 +414,65 @@ mod tests { 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}"); +} From 04d932fc9f155696c8d3c08872ab884e376c2f3e Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Tue, 2 Jun 2026 12:53:39 -0400 Subject: [PATCH 4/9] =?UTF-8?q?fix(sandbox):=20address=20SigV4=20PR=20revi?= =?UTF-8?q?ew=20feedback=20Critical:=20-=20Scope=20Expect:=20100-continue?= =?UTF-8?q?=20handling=20to=20SigV4=20paths=20only.=20Previously=20=20=20i?= =?UTF-8?q?t=20fired=20for=20all=20L7-proxied=20requests,=20violating=20RF?= =?UTF-8?q?C=207231=20=C2=A75.1.1=20and=20=20=20risking=20double=20100=20r?= =?UTF-8?q?esponses=20on=20non-SigV4=20traffic.=20Warnings:=20-=20Reject?= =?UTF-8?q?=20unknown=20credential=5Fsigning=20values=20at=20policy=20vali?= =?UTF-8?q?dation=20time.=20=20=20A=20typo=20like=20"sigv4=5Ftypo"=20now?= =?UTF-8?q?=20produces=20a=20clear=20PolicyViolation=20instead=20=20=20of?= =?UTF-8?q?=20silently=20falling=20back=20to=20no=20signing.=20-=20Support?= =?UTF-8?q?=20dualstack,=20FIPS,=20virtual-hosted,=20and=20China=20partiti?= =?UTF-8?q?on=20hostnames=20=20=20in=20extract=5Faws=5Fregion=20(e.g.=20s3?= =?UTF-8?q?.dualstack.us-west-2.amazonaws.com,=20=20=20s3.cn-north-1.amazo?= =?UTF-8?q?naws.com.cn).=20-=20Emit=20OCSF=20NetworkActivity=20event=20for?= =?UTF-8?q?=20SigV4=20re-signing=20decisions=20=20=20instead=20of=20debug!?= =?UTF-8?q?=20tracing,=20per=20AGENTS.md=20structured=20logging=20guidelin?= =?UTF-8?q?es.=20-=20Update=20architecture/sandbox.md=20to=20document=20al?= =?UTF-8?q?l=20three=20signing=20modes=20=20=20(signed=20body,=20streaming?= =?UTF-8?q?=20unsigned=20trailer,=20unsigned=20payload)=20and=20the=20=20?= =?UTF-8?q?=20auto-detection=20mechanism.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture/sandbox.md | 19 ++++++++-- crates/openshell-policy/src/lib.rs | 29 ++++++++++++++ crates/openshell-sandbox/src/l7/rest.rs | 47 ++++++++++++++--------- crates/openshell-sandbox/src/sigv4.rs | 50 ++++++++++++++++++++++--- 4 files changed, 119 insertions(+), 26 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 468d13731..a29b432a0 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -76,9 +76,22 @@ 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, buffers the request body, -computes a fresh SigV4 signature using real credentials from the provider, and -forwards the re-signed request upstream. +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 diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 46a7d3b0d..bf9ed28c3 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -704,6 +704,12 @@ pub enum PolicyViolation { 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 { @@ -747,6 +753,17 @@ impl fmt::Display for PolicyViolation { 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)" + ) + } } } } @@ -851,6 +868,18 @@ 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(), diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 408c946d6..c73e64d06 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -461,19 +461,19 @@ where guard.ensure_current()?; } - // If the client sent `Expect: 100-continue`, acknowledge it so the client - // sends the body. Without this, clients like boto3's S3 PutObject wait - // for the 100 response before transmitting any body bytes. - 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()?; - } - // 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"); @@ -506,13 +506,24 @@ where crate::l7::CredentialSigning::None => unreachable!(), }; - debug!( - host = %options.host, - region = %region, - service = %service, - payload_mode = %payload_mode, - "applying SigV4 signing to CONNECT tunnel request" - ); + let event = openshell_ocsf::NetworkActivityBuilder::new( + crate::ocsf_ctx(), + ) + .activity(openshell_ocsf::ActivityId::Other) + .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. diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs index 3a24c2208..276bc2fbe 100644 --- a/crates/openshell-sandbox/src/sigv4.rs +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -10,16 +10,32 @@ use aws_smithy_runtime_api::client::identity::Identity; use miette::{Result, miette}; use std::time::SystemTime; -/// Extract the AWS region from a standard AWS hostname. -/// Pattern: `..amazonaws.com` → ``. +/// 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" + { + return Some(parts[parts.len() - 4].to_string()); + } + // Standard/dualstack/FIPS/virtual-hosted: *.amazonaws.com if parts.len() >= 4 && parts[parts.len() - 2] == "amazonaws" && parts[parts.len() - 1] == "com" { - Some(parts[1].to_string()) - } else { - None + let region = parts[parts.len() - 3]; + // Skip "dualstack" — the region is one level further left + if region == "dualstack" && parts.len() >= 5 { + return Some(parts[parts.len() - 4].to_string()); + } + return Some(region.to_string()); } + None } /// Strip AWS auth headers from raw HTTP request bytes. @@ -337,6 +353,30 @@ mod tests { assert!(extract_aws_region("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_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{}"; From 6d616fdcaef82c4303c588cdc981bf86f11816fb Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Tue, 2 Jun 2026 13:49:55 -0400 Subject: [PATCH 5/9] =?UTF-8?q?fix(sandbox):=20address=20second=20review?= =?UTF-8?q?=20=E2=80=94=20FIPS+dualstack,=20missing=20tests,=20OCSF=20nit?= =?UTF-8?q?=20-=20Fix=20extract=5Faws=5Fregion=20for=20FIPS+dualstack=20co?= =?UTF-8?q?mbo=20hostnames=20like=20=20=20s3-fips.dualstack.us-west-2.amaz?= =?UTF-8?q?onaws.com=20(scans=20past=20all=20"dualstack"=20=20=20labels=20?= =?UTF-8?q?instead=20of=20just=20one).=20-=20Add=20tests=20for=20FIPS+dual?= =?UTF-8?q?stack=20and=20GovCloud=20region=20extraction.=20-=20Add=20unit?= =?UTF-8?q?=20test=20for=20UnknownCredentialSigning=20policy=20validation?= =?UTF-8?q?=20=20=20(e.g.=20"sigv4=5Ftypo"=20produces=20the=20expected=20v?= =?UTF-8?q?iolation).=20-=20Use=20ActivityId::Traffic=20instead=20of=20Act?= =?UTF-8?q?ivityId::Other=20for=20the=20SigV4=20=20=20OCSF=20event=20?= =?UTF-8?q?=E2=80=94=20more=20descriptive=20for=20a=20signing=20operation?= =?UTF-8?q?=20on=20existing=20=20=20traffic=20flow.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/openshell-policy/src/lib.rs | 25 +++++++++++++++++++++++ crates/openshell-sandbox/src/l7/rest.rs | 2 +- crates/openshell-sandbox/src/sigv4.rs | 27 ++++++++++++++++++++----- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index bf9ed28c3..aba0f9483 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -1555,6 +1555,31 @@ network_policies: ); } + #[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-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index c73e64d06..2a6c3f1fc 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -509,7 +509,7 @@ where let event = openshell_ocsf::NetworkActivityBuilder::new( crate::ocsf_ctx(), ) - .activity(openshell_ocsf::ActivityId::Other) + .activity(openshell_ocsf::ActivityId::Traffic) .action(openshell_ocsf::ActionId::Allowed) .disposition(openshell_ocsf::DispositionId::Allowed) .severity(openshell_ocsf::SeverityId::Informational) diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs index 276bc2fbe..ea4df7ced 100644 --- a/crates/openshell-sandbox/src/sigv4.rs +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -26,14 +26,19 @@ pub fn extract_aws_region(host: &str) -> Option { return Some(parts[parts.len() - 4].to_string()); } // 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 region = parts[parts.len() - 3]; - // Skip "dualstack" — the region is one level further left - if region == "dualstack" && parts.len() >= 5 { - return Some(parts[parts.len() - 4].to_string()); + let mut idx = parts.len() - 3; + while idx > 0 && parts[idx] == "dualstack" { + idx -= 1; + } + if idx > 0 { + return Some(parts[idx].to_string()); } - return Some(region.to_string()); } None } @@ -371,6 +376,18 @@ mod tests { 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(); From 216a9a9146555ad24d8b0c17787626da091f8c5b Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Tue, 2 Jun 2026 14:25:16 -0400 Subject: [PATCH 6/9] =?UTF-8?q?fix(sandbox):=20address=20third=20review=20?= =?UTF-8?q?=E2=80=94=20chunked=20SignBody=20gap,=20provider=20DTO,=20start?= =?UTF-8?q?up=20validation=20Critical:=20-=20Reject=20STREAMING-AWS4-HMAC-?= =?UTF-8?q?SHA256-PAYLOAD=20in=20detect=5Fpayload=5Fmode()=20=20=20instead?= =?UTF-8?q?=20of=20silently=20treating=20it=20as=20SignBody=20(per-chunk?= =?UTF-8?q?=20signing=20is=20not=20=20=20supported).=20Returns=20a=20clear?= =?UTF-8?q?=20error=20directing=20the=20user=20to=20sigv4:no=5Fbody.=20-?= =?UTF-8?q?=20Add=20defense-in-depth=20guard=20in=20the=20SignBody=20path:?= =?UTF-8?q?=20fail=20closed=20if=20the=20=20=20request=20uses=20chunked=20?= =?UTF-8?q?transfer=20encoding,=20preventing=20body-less=20forwards.=20War?= =?UTF-8?q?nings:=20-=20Wire=20credential=5Fsigning=20and=20signing=5Fserv?= =?UTF-8?q?ice=20through=20EndpointProfile=20=20=20DTO=20in=20openshell-pr?= =?UTF-8?q?oviders.=20Both=20endpoint=5Fto=5Fproto()=20and=20=20=20endpoin?= =?UTF-8?q?t=5Ffrom=5Fproto()=20now=20preserve=20the=20fields=20during=20r?= =?UTF-8?q?ound-trip.=20-=20Reject=20unknown=20credential=5Fsigning=20valu?= =?UTF-8?q?es=20at=20sandbox=20L7=20config=20parse=20=20=20time=20(returns?= =?UTF-8?q?=20None,=20disabling=20L7=20for=20the=20endpoint)=20instead=20o?= =?UTF-8?q?f=20=20=20silently=20downgrading=20to=20CredentialSigning::None?= =?UTF-8?q?.=20Also=20reject=20SigV4=20=20=20modes=20with=20empty=20signin?= =?UTF-8?q?g=5Fservice=20at=20startup=20rather=20than=20deferring=20=20=20?= =?UTF-8?q?the=20error=20to=20request=20time.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/openshell-providers/src/profiles.rs | 10 +++++-- crates/openshell-sandbox/src/l7/mod.rs | 16 +++++++++-- crates/openshell-sandbox/src/l7/rest.rs | 32 ++++++++++++++++------ 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 274a8b524..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,8 +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: String::new(), - signing_service: String::new(), + credential_signing: endpoint.credential_signing.clone(), + signing_service: endpoint.signing_service.clone(), } } @@ -628,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/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 276f61be9..5aec0523c 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -197,19 +197,29 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { Some(other) if !other.is_empty() => { let event = openshell_ocsf::NetworkActivityBuilder::new(crate::ocsf_ctx()) .activity(openshell_ocsf::ActivityId::Other) - .severity(openshell_ocsf::SeverityId::Medium) + .severity(openshell_ocsf::SeverityId::High) .message(format!( - "unrecognized credential_signing value {other:?}, falling back to none" + "rejecting endpoint: unrecognized credential_signing value {other:?}" )) .build(); openshell_ocsf::ocsf_emit!(event); - CredentialSigning::None + 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(), diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 2a6c3f1fc..8a619ac74 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -527,6 +527,16 @@ where 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); @@ -1549,21 +1559,27 @@ impl fmt::Display for SigV4PayloadMode { /// Mirrors the mode the client SDK chose by inspecting `x-amz-content-sha256`: /// - `STREAMING-UNSIGNED-PAYLOAD-TRAILER` → `StreamingUnsignedTrailer` /// - `UNSIGNED-PAYLOAD` → `UnsignedPayload` -/// - Absent or a hex hash → `SignBody` (buffer + hash) +/// - Hex hash → `SignBody` (buffer + hash, requires `Content-Length`) +/// - `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` → rejected (per-chunk signing unsupported) +/// - 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 Ok(match val { - "streaming-unsigned-payload-trailer" => SigV4PayloadMode::StreamingUnsignedTrailer, - "unsigned-payload" => SigV4PayloadMode::UnsignedPayload, - _ => SigV4PayloadMode::SignBody, - }); + return match val { + "streaming-unsigned-payload-trailer" => { + Ok(SigV4PayloadMode::StreamingUnsignedTrailer) + } + "unsigned-payload" => Ok(SigV4PayloadMode::UnsignedPayload), + v if v.starts_with("streaming-") => Err(miette!( + "SigV4 per-chunk streaming signing ({v}) is not supported; \ + use credential_signing: sigv4 (auto-detect) or sigv4:no_body" + )), + _ => Ok(SigV4PayloadMode::SignBody), + }; } } - // No x-amz-content-sha256 header — default to signing body if Content-Length - // is present, otherwise unsigned. Ok( if matches!(parse_body_length(headers)?, BodyLength::ContentLength(_)) { SigV4PayloadMode::SignBody From c2ad378ee1e1cdf9ecec4aab29bf139af441cc71 Mon Sep 17 00:00:00 2001 From: Russell Bryant Date: Tue, 2 Jun 2026 16:01:44 -0400 Subject: [PATCH 7/9] fix(sandbox): reject service names as regions in extract_aws_region Boto3 connects to global S3 endpoints like bucket.s3.amazonaws.com (no region in the hostname). The previous extract_aws_region returned "s3" for this pattern because it took the label at parts[len-3] without checking if it was actually a region. Add looks_like_region() which requires a hyphen followed by a digit (e.g., us-east-1). Service names like "s3" or "bedrock-runtime" are rejected, causing the fallback to us-east-1. Refs #1576 --- crates/openshell-sandbox/src/sigv4.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs index ea4df7ced..8765c0427 100644 --- a/crates/openshell-sandbox/src/sigv4.rs +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -10,6 +10,18 @@ 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 @@ -23,7 +35,11 @@ pub fn extract_aws_region(host: &str) -> Option { && parts[parts.len() - 2] == "com" && parts[parts.len() - 1] == "cn" { - return Some(parts[parts.len() - 4].to_string()); + 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 @@ -36,7 +52,7 @@ pub fn extract_aws_region(host: &str) -> Option { while idx > 0 && parts[idx] == "dualstack" { idx -= 1; } - if idx > 0 { + if idx > 0 && looks_like_region(parts[idx]) { return Some(parts[idx].to_string()); } } @@ -358,6 +374,11 @@ mod tests { 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(); From c477f26fe538b6dcf7fa5cf55efab65e9f31df44 Mon Sep 17 00:00:00 2001 From: Russell Bryant Date: Tue, 2 Jun 2026 16:01:53 -0400 Subject: [PATCH 8/9] fix(sandbox): handle streaming SigV4 payload modes from boto3 Boto3 put_object sends x-amz-content-sha256 with the value STREAMING-AWS4-HMAC-SHA256-PAYLOAD, which was rejected by detect_payload_mode() because per-chunk signing is not supported. Treat all streaming- variants as StreamingUnsignedTrailer: re-sign headers only and stream the body through. The proxy cannot reproduce per-chunk signatures, but AWS accepts unsigned streaming payloads over HTTPS. Refs #1576 --- crates/openshell-sandbox/src/l7/rest.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 8a619ac74..b6fac71b8 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -1560,7 +1560,9 @@ impl fmt::Display for SigV4PayloadMode { /// - `STREAMING-UNSIGNED-PAYLOAD-TRAILER` → `StreamingUnsignedTrailer` /// - `UNSIGNED-PAYLOAD` → `UnsignedPayload` /// - Hex hash → `SignBody` (buffer + hash, requires `Content-Length`) -/// - `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` → rejected (per-chunk signing unsupported) +/// - `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) { @@ -1568,14 +1570,13 @@ fn detect_payload_mode(headers: &str) -> Result { 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-unsigned-payload-trailer" | "streaming-aws4-hmac-sha256-payload" => { Ok(SigV4PayloadMode::StreamingUnsignedTrailer) } "unsigned-payload" => Ok(SigV4PayloadMode::UnsignedPayload), - v if v.starts_with("streaming-") => Err(miette!( - "SigV4 per-chunk streaming signing ({v}) is not supported; \ - use credential_signing: sigv4 (auto-detect) or sigv4:no_body" - )), + v if v.starts_with("streaming-") => { + Ok(SigV4PayloadMode::StreamingUnsignedTrailer) + } _ => Ok(SigV4PayloadMode::SignBody), }; } From 95e2ecb395540e095c0a4a1854d77a3e1c3207bc Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Wed, 3 Jun 2026 16:05:24 -0400 Subject: [PATCH 9/9] test(sandbox): remove sigv4 integration test that uses real AWS credentials The sigv4_real_aws test reads ~/.aws/credentials and makes live calls to Bedrock and S3. Even though the tests are #[ignore]d, committing code that consumes real credentials is a security risk. --- .../openshell-sandbox/tests/sigv4_real_aws.rs | 318 ------------------ 1 file changed, 318 deletions(-) delete mode 100644 crates/openshell-sandbox/tests/sigv4_real_aws.rs diff --git a/crates/openshell-sandbox/tests/sigv4_real_aws.rs b/crates/openshell-sandbox/tests/sigv4_real_aws.rs deleted file mode 100644 index 009d70805..000000000 --- a/crates/openshell-sandbox/tests/sigv4_real_aws.rs +++ /dev/null @@ -1,318 +0,0 @@ -// 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}"); -}