Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 220 additions & 39 deletions Cargo.lock

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,25 @@ Credential placeholders in proxied HTTP requests can be resolved by the proxy
when policy allows the target endpoint. Secrets must not be logged in OCSF or
plain tracing output.

For AWS endpoints that require request-level signing, the proxy supports SigV4
re-signing. When `credential_signing: sigv4` is set on an L7 endpoint, the proxy
strips the client's placeholder-based AWS auth headers, re-signs with real
credentials from the provider, and forwards the request upstream. The signing
mode is auto-detected from the client SDK's `x-amz-content-sha256` header:

- **Signed body** (hex hash): buffers the request body, computes its SHA-256,
and includes the hash in the signature. Used by Bedrock and most AWS services.
- **Streaming unsigned** (`STREAMING-UNSIGNED-PAYLOAD-TRAILER`): signs headers
only and streams the body through without buffering. Used by S3 uploads with
`aws-chunked` encoding.
- **Unsigned payload** (`UNSIGNED-PAYLOAD`): signs headers only with no body
hash. Used by S3 over HTTPS for non-chunked requests.

Two explicit overrides are available: `credential_signing: sigv4:body` (always
buffer and hash) and `sigv4:no_body` (always unsigned). The `Expect:
100-continue` header is handled within the SigV4 path so clients like boto3
transmit the body before the proxy forwards to upstream.

## Connect and Logs

The supervisor runs an SSH server on a Unix socket inside the sandbox. The
Expand Down
187 changes: 187 additions & 0 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ struct NetworkEndpointDef {
graphql_persisted_queries: BTreeMap<String, GraphqlOperationDef>,
#[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,
Comment on lines +138 to +141
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there practical extensions to the values for these fields? Is there ever a non-AWS use case? Just trying to understand how AWS-specific these settings are vs the names of the settings that imply they could map to other service provider use cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These feel pretty specific to AWS. It's awkward, but I didn't see an obviously better way to have a special case like this today. credential_signing might be used in other services, but the signing_service almost certainly wouldn't.

Copy link
Copy Markdown
Contributor Author

@jhjaggars jhjaggars Jun 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could imagine a cleaner way of expressing these things aligning with your issue here: #896. It does seem like a middleware pattern would fit nicely as well. I have opened an issue to propose a possible middleware system: #1694

}

// Signature dictated by serde's `skip_serializing_if`, which requires `&T`.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -694,6 +702,14 @@ pub enum PolicyViolation {
TooManyPaths { count: usize },
/// A network endpoint uses a TLD wildcard (e.g. `*.com`).
TldWildcard { policy_name: String, host: String },
/// `credential_signing` is set but `signing_service` is missing.
MissingSigningService { policy_name: String, host: String },
/// `credential_signing` has an unrecognized value.
UnknownCredentialSigning {
policy_name: String,
host: String,
value: String,
},
}

impl fmt::Display for PolicyViolation {
Expand Down Expand Up @@ -730,6 +746,24 @@ impl fmt::Display for PolicyViolation {
use subdomain wildcards like '*.example.com' instead"
)
}
Self::MissingSigningService { policy_name, host } => {
write!(
f,
"network policy '{policy_name}': endpoint '{host}' has credential_signing \
set but signing_service is empty"
)
}
Self::UnknownCredentialSigning {
policy_name,
host,
value,
} => {
write!(
f,
"network policy '{policy_name}': endpoint '{host}' has unrecognized \
credential_signing value '{value}' (expected sigv4, sigv4:body, or sigv4:no_body)"
)
}
}
}
}
Expand Down Expand Up @@ -834,6 +868,24 @@ pub fn validate_sandbox_policy(
});
}
}
if !ep.credential_signing.is_empty()
&& !matches!(
ep.credential_signing.as_str(),
"sigv4" | "sigv4:body" | "sigv4:no_body"
)
{
violations.push(PolicyViolation::UnknownCredentialSigning {
policy_name: name.clone(),
host: ep.host.clone(),
value: ep.credential_signing.clone(),
});
}
if !ep.credential_signing.is_empty() && ep.signing_service.is_empty() {
violations.push(PolicyViolation::MissingSigningService {
policy_name: name.clone(),
host: ep.host.clone(),
});
}
}
}

Expand Down Expand Up @@ -1393,6 +1445,141 @@ network_policies:
assert!(validate_sandbox_policy(&policy).is_ok());
}

#[test]
fn validate_rejects_credential_signing_without_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "bedrock".into(),
endpoints: vec![NetworkEndpoint {
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4".into(),
signing_service: String::new(),
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::MissingSigningService { .. }))
);
}

#[test]
fn validate_accepts_credential_signing_with_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "bedrock".into(),
endpoints: vec![NetworkEndpoint {
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4".into(),
signing_service: "bedrock".into(),
..Default::default()
}],
..Default::default()
},
);
assert!(validate_sandbox_policy(&policy).is_ok());
}

#[test]
fn validate_accepts_sigv4_body_with_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "bedrock".into(),
endpoints: vec![NetworkEndpoint {
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4:body".into(),
signing_service: "bedrock".into(),
..Default::default()
}],
..Default::default()
},
);
assert!(validate_sandbox_policy(&policy).is_ok());
}

#[test]
fn validate_accepts_sigv4_no_body_with_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "s3".into(),
endpoints: vec![NetworkEndpoint {
host: "s3.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4:no_body".into(),
signing_service: "s3".into(),
..Default::default()
}],
..Default::default()
},
);
assert!(validate_sandbox_policy(&policy).is_ok());
}

#[test]
fn validate_rejects_sigv4_no_body_without_signing_service() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "s3".into(),
endpoints: vec![NetworkEndpoint {
host: "s3.us-east-1.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4:no_body".into(),
signing_service: String::new(),
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::MissingSigningService { .. }))
);
}

#[test]
fn validate_rejects_unknown_credential_signing() {
let mut policy = restrictive_default_policy();
policy.network_policies.insert(
"aws".into(),
NetworkPolicyRule {
name: "test".into(),
endpoints: vec![NetworkEndpoint {
host: "example.amazonaws.com".into(),
port: 443,
credential_signing: "sigv4_typo".into(),
signing_service: "bedrock".into(),
..Default::default()
}],
..Default::default()
},
);
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
violations
.iter()
.any(|v| matches!(v, PolicyViolation::UnknownCredentialSigning { .. }))
);
}

#[test]
fn normalize_path_collapses_separators() {
assert_eq!(normalize_path("/usr//lib"), "/usr/lib");
Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -596,6 +600,8 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint {
.collect(),
graphql_max_body_bytes: endpoint.graphql_max_body_bytes,
path: endpoint.path.clone(),
credential_signing: endpoint.credential_signing.clone(),
signing_service: endpoint.signing_service.clone(),
}
}

Expand Down Expand Up @@ -626,6 +632,8 @@ fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile {
.collect(),
graphql_max_body_bytes: endpoint.graphql_max_body_bytes,
path: endpoint.path.clone(),
credential_signing: endpoint.credential_signing.clone(),
signing_service: endpoint.signing_service.clone(),
}
}

Expand Down
7 changes: 6 additions & 1 deletion crates/openshell-sandbox/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading
Loading