Skip to content

Commit f569a0a

Browse files
jhjaggarsrussellb
andauthored
feat(sandbox): proxy-side AWS SigV4 credential signing for CONNECT tunnels (#1638)
Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> Co-authored-by: Russell Bryant <russell.bryant@gmail.com>
1 parent 4b78b44 commit f569a0a

17 files changed

Lines changed: 2113 additions & 37 deletions

File tree

Cargo.lock

Lines changed: 216 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

architecture/sandbox.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,39 @@ and returned access tokens must be bearer-compatible before they are cached or
9090
injected. Token response lifetimes are capped and cached with an expiry margin
9191
unless a profile supplies an explicit cache TTL override.
9292

93+
For AWS endpoints that require request-level signing, the proxy supports SigV4
94+
re-signing. When `credential_signing: sigv4` is set on an L7 endpoint, the proxy
95+
strips the client's placeholder-based AWS auth headers, re-signs with real
96+
credentials from the provider, and forwards the request upstream. The signing
97+
mode is auto-detected from the client SDK's `x-amz-content-sha256` header:
98+
99+
- **Signed body** (hex hash): buffers the request body (up to 10 MiB), computes
100+
its SHA-256, and includes the hash in the signature. Used by Bedrock and most
101+
AWS services.
102+
- **Streaming unsigned** (`STREAMING-UNSIGNED-PAYLOAD-TRAILER`): signs headers
103+
only and streams the body through without buffering. Used by S3 uploads with
104+
`aws-chunked` encoding.
105+
- **Unsigned payload** (`UNSIGNED-PAYLOAD`): signs headers only with no body
106+
hash. Used by S3 over HTTPS for non-chunked requests.
107+
108+
Chunk-signed streaming modes (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD` and other
109+
`STREAMING-*` variants) are rejected — the proxy cannot reproduce per-chunk
110+
signatures. Use `sigv4:no_body` for those clients.
111+
112+
Two explicit overrides are available: `credential_signing: sigv4:body` (always
113+
buffer and hash) and `sigv4:no_body` (always unsigned). The `Expect:
114+
100-continue` header is handled within the SigV4 path so clients like boto3
115+
transmit the body before the proxy forwards to upstream.
116+
117+
The AWS region is extracted from the endpoint hostname. For non-standard
118+
endpoints (VPC endpoints, custom proxies), set `signing_region` in the policy
119+
endpoint to provide an explicit override. The proxy rejects requests when
120+
neither hostname extraction nor `signing_region` yields a region.
121+
122+
`credential_signing` and `request_body_credential_rewrite` are mutually
123+
exclusive on the same endpoint. The policy validator rejects policies that
124+
set both.
125+
93126
## Connect and Logs
94127

95128
The supervisor runs an SSH server on a Unix socket inside the sandbox. The

crates/openshell-policy/src/lib.rs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ struct NetworkEndpointDef {
138138
graphql_persisted_queries: BTreeMap<String, GraphqlOperationDef>,
139139
#[serde(default, skip_serializing_if = "is_zero_u32")]
140140
graphql_max_body_bytes: u32,
141+
#[serde(default, skip_serializing_if = "String::is_empty")]
142+
credential_signing: String,
143+
#[serde(default, skip_serializing_if = "String::is_empty")]
144+
signing_service: String,
145+
#[serde(default, skip_serializing_if = "String::is_empty")]
146+
signing_region: String,
141147
}
142148

143149
// Signature dictated by serde's `skip_serializing_if`, which requires `&T`.
@@ -350,6 +356,9 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
350356
})
351357
.collect(),
352358
graphql_max_body_bytes: e.graphql_max_body_bytes,
359+
credential_signing: e.credential_signing,
360+
signing_service: e.signing_service,
361+
signing_region: e.signing_region,
353362
}
354363
})
355364
.collect(),
@@ -515,6 +524,9 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
515524
})
516525
.collect(),
517526
graphql_max_body_bytes: e.graphql_max_body_bytes,
527+
credential_signing: e.credential_signing.clone(),
528+
signing_service: e.signing_service.clone(),
529+
signing_region: e.signing_region.clone(),
518530
}
519531
})
520532
.collect(),
@@ -697,6 +709,16 @@ pub enum PolicyViolation {
697709
TooManyPaths { count: usize },
698710
/// A network endpoint uses a TLD wildcard (e.g. `*.com`).
699711
TldWildcard { policy_name: String, host: String },
712+
/// `credential_signing` is set but `signing_service` is missing.
713+
MissingSigningService { policy_name: String, host: String },
714+
/// `credential_signing` has an unrecognized value.
715+
UnknownCredentialSigning {
716+
policy_name: String,
717+
host: String,
718+
value: String,
719+
},
720+
/// `credential_signing` and `request_body_credential_rewrite` are both set.
721+
CredentialSigningWithBodyRewrite { policy_name: String, host: String },
700722
}
701723

702724
impl fmt::Display for PolicyViolation {
@@ -733,6 +755,31 @@ impl fmt::Display for PolicyViolation {
733755
use subdomain wildcards like '*.example.com' instead"
734756
)
735757
}
758+
Self::MissingSigningService { policy_name, host } => {
759+
write!(
760+
f,
761+
"network policy '{policy_name}': endpoint '{host}' has credential_signing \
762+
set but signing_service is empty"
763+
)
764+
}
765+
Self::UnknownCredentialSigning {
766+
policy_name,
767+
host,
768+
value,
769+
} => {
770+
write!(
771+
f,
772+
"network policy '{policy_name}': endpoint '{host}' has unrecognized \
773+
credential_signing value '{value}' (expected sigv4, sigv4:body, or sigv4:no_body)"
774+
)
775+
}
776+
Self::CredentialSigningWithBodyRewrite { policy_name, host } => {
777+
write!(
778+
f,
779+
"network policy '{policy_name}': endpoint '{host}' has both credential_signing \
780+
and request_body_credential_rewrite set; these options are mutually exclusive"
781+
)
782+
}
736783
}
737784
}
738785
}
@@ -837,6 +884,30 @@ pub fn validate_sandbox_policy(
837884
});
838885
}
839886
}
887+
if !ep.credential_signing.is_empty()
888+
&& !matches!(
889+
ep.credential_signing.as_str(),
890+
"sigv4" | "sigv4:body" | "sigv4:no_body"
891+
)
892+
{
893+
violations.push(PolicyViolation::UnknownCredentialSigning {
894+
policy_name: name.clone(),
895+
host: ep.host.clone(),
896+
value: ep.credential_signing.clone(),
897+
});
898+
}
899+
if !ep.credential_signing.is_empty() && ep.signing_service.is_empty() {
900+
violations.push(PolicyViolation::MissingSigningService {
901+
policy_name: name.clone(),
902+
host: ep.host.clone(),
903+
});
904+
}
905+
if !ep.credential_signing.is_empty() && ep.request_body_credential_rewrite {
906+
violations.push(PolicyViolation::CredentialSigningWithBodyRewrite {
907+
policy_name: name.clone(),
908+
host: ep.host.clone(),
909+
});
910+
}
840911
}
841912
}
842913

@@ -1380,6 +1451,167 @@ network_policies:
13801451
assert!(validate_sandbox_policy(&policy).is_ok());
13811452
}
13821453

1454+
#[test]
1455+
fn validate_rejects_credential_signing_without_signing_service() {
1456+
let mut policy = restrictive_default_policy();
1457+
policy.network_policies.insert(
1458+
"aws".into(),
1459+
NetworkPolicyRule {
1460+
name: "bedrock".into(),
1461+
endpoints: vec![NetworkEndpoint {
1462+
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
1463+
port: 443,
1464+
credential_signing: "sigv4".into(),
1465+
signing_service: String::new(),
1466+
..Default::default()
1467+
}],
1468+
..Default::default()
1469+
},
1470+
);
1471+
let violations = validate_sandbox_policy(&policy).unwrap_err();
1472+
assert!(
1473+
violations
1474+
.iter()
1475+
.any(|v| matches!(v, PolicyViolation::MissingSigningService { .. }))
1476+
);
1477+
}
1478+
1479+
#[test]
1480+
fn validate_accepts_credential_signing_with_signing_service() {
1481+
let mut policy = restrictive_default_policy();
1482+
policy.network_policies.insert(
1483+
"aws".into(),
1484+
NetworkPolicyRule {
1485+
name: "bedrock".into(),
1486+
endpoints: vec![NetworkEndpoint {
1487+
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
1488+
port: 443,
1489+
credential_signing: "sigv4".into(),
1490+
signing_service: "bedrock".into(),
1491+
..Default::default()
1492+
}],
1493+
..Default::default()
1494+
},
1495+
);
1496+
assert!(validate_sandbox_policy(&policy).is_ok());
1497+
}
1498+
1499+
#[test]
1500+
fn validate_accepts_sigv4_body_with_signing_service() {
1501+
let mut policy = restrictive_default_policy();
1502+
policy.network_policies.insert(
1503+
"aws".into(),
1504+
NetworkPolicyRule {
1505+
name: "bedrock".into(),
1506+
endpoints: vec![NetworkEndpoint {
1507+
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
1508+
port: 443,
1509+
credential_signing: "sigv4:body".into(),
1510+
signing_service: "bedrock".into(),
1511+
..Default::default()
1512+
}],
1513+
..Default::default()
1514+
},
1515+
);
1516+
assert!(validate_sandbox_policy(&policy).is_ok());
1517+
}
1518+
1519+
#[test]
1520+
fn validate_accepts_sigv4_no_body_with_signing_service() {
1521+
let mut policy = restrictive_default_policy();
1522+
policy.network_policies.insert(
1523+
"aws".into(),
1524+
NetworkPolicyRule {
1525+
name: "s3".into(),
1526+
endpoints: vec![NetworkEndpoint {
1527+
host: "s3.us-east-1.amazonaws.com".into(),
1528+
port: 443,
1529+
credential_signing: "sigv4:no_body".into(),
1530+
signing_service: "s3".into(),
1531+
..Default::default()
1532+
}],
1533+
..Default::default()
1534+
},
1535+
);
1536+
assert!(validate_sandbox_policy(&policy).is_ok());
1537+
}
1538+
1539+
#[test]
1540+
fn validate_rejects_sigv4_no_body_without_signing_service() {
1541+
let mut policy = restrictive_default_policy();
1542+
policy.network_policies.insert(
1543+
"aws".into(),
1544+
NetworkPolicyRule {
1545+
name: "s3".into(),
1546+
endpoints: vec![NetworkEndpoint {
1547+
host: "s3.us-east-1.amazonaws.com".into(),
1548+
port: 443,
1549+
credential_signing: "sigv4:no_body".into(),
1550+
signing_service: String::new(),
1551+
..Default::default()
1552+
}],
1553+
..Default::default()
1554+
},
1555+
);
1556+
let violations = validate_sandbox_policy(&policy).unwrap_err();
1557+
assert!(
1558+
violations
1559+
.iter()
1560+
.any(|v| matches!(v, PolicyViolation::MissingSigningService { .. }))
1561+
);
1562+
}
1563+
1564+
#[test]
1565+
fn validate_rejects_unknown_credential_signing() {
1566+
let mut policy = restrictive_default_policy();
1567+
policy.network_policies.insert(
1568+
"aws".into(),
1569+
NetworkPolicyRule {
1570+
name: "test".into(),
1571+
endpoints: vec![NetworkEndpoint {
1572+
host: "example.amazonaws.com".into(),
1573+
port: 443,
1574+
credential_signing: "sigv4_typo".into(),
1575+
signing_service: "bedrock".into(),
1576+
..Default::default()
1577+
}],
1578+
..Default::default()
1579+
},
1580+
);
1581+
let violations = validate_sandbox_policy(&policy).unwrap_err();
1582+
assert!(
1583+
violations
1584+
.iter()
1585+
.any(|v| matches!(v, PolicyViolation::UnknownCredentialSigning { .. }))
1586+
);
1587+
}
1588+
1589+
#[test]
1590+
fn validate_rejects_credential_signing_with_body_rewrite() {
1591+
let mut policy = restrictive_default_policy();
1592+
policy.network_policies.insert(
1593+
"aws".into(),
1594+
NetworkPolicyRule {
1595+
name: "bedrock".into(),
1596+
endpoints: vec![NetworkEndpoint {
1597+
host: "bedrock-runtime.us-east-1.amazonaws.com".into(),
1598+
port: 443,
1599+
credential_signing: "sigv4".into(),
1600+
signing_service: "bedrock".into(),
1601+
request_body_credential_rewrite: true,
1602+
..Default::default()
1603+
}],
1604+
..Default::default()
1605+
},
1606+
);
1607+
let violations = validate_sandbox_policy(&policy).unwrap_err();
1608+
assert!(
1609+
violations
1610+
.iter()
1611+
.any(|v| matches!(v, PolicyViolation::CredentialSigningWithBodyRewrite { .. }))
1612+
);
1613+
}
1614+
13831615
#[test]
13841616
fn normalize_path_collapses_separators() {
13851617
assert_eq!(normalize_path("/usr//lib"), "/usr/lib");

crates/openshell-providers/src/profiles.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@ pub struct EndpointProfile {
205205
pub graphql_max_body_bytes: u32,
206206
#[serde(default, skip_serializing_if = "String::is_empty")]
207207
pub path: String,
208+
#[serde(default, skip_serializing_if = "String::is_empty")]
209+
pub credential_signing: String,
210+
#[serde(default, skip_serializing_if = "String::is_empty")]
211+
pub signing_service: String,
212+
#[serde(default, skip_serializing_if = "String::is_empty")]
213+
pub signing_region: String,
208214
}
209215

210216
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
@@ -775,6 +781,9 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint {
775781
.collect(),
776782
graphql_max_body_bytes: endpoint.graphql_max_body_bytes,
777783
path: endpoint.path.clone(),
784+
credential_signing: endpoint.credential_signing.clone(),
785+
signing_service: endpoint.signing_service.clone(),
786+
signing_region: endpoint.signing_region.clone(),
778787
}
779788
}
780789

@@ -805,6 +814,9 @@ fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile {
805814
.collect(),
806815
graphql_max_body_bytes: endpoint.graphql_max_body_bytes,
807816
path: endpoint.path.clone(),
817+
credential_signing: endpoint.credential_signing.clone(),
818+
signing_service: endpoint.signing_service.clone(),
819+
signing_region: endpoint.signing_region.clone(),
808820
}
809821
}
810822

crates/openshell-supervisor-network/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ openshell-policy = { path = "../openshell-policy" }
1717
openshell-router = { path = "../openshell-router" }
1818

1919
apollo-parser = { workspace = true }
20+
aws-sigv4 = { version = "1", features = ["sign-http", "http1"] }
21+
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
22+
aws-smithy-runtime-api = { version = "1", features = ["client"] }
23+
http = { workspace = true }
2024
base64 = { workspace = true }
2125
bytes = { workspace = true }
2226
flate2 = "1"

0 commit comments

Comments
 (0)