Skip to content

Commit e2f78da

Browse files
committed
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) <noreply@anthropic.com>
1 parent e98ea3e commit e2f78da

15 files changed

Lines changed: 873 additions & 43 deletions

File tree

Cargo.lock

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

architecture/sandbox.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ Credential placeholders in proxied HTTP requests can be resolved by the proxy
7474
when policy allows the target endpoint. Secrets must not be logged in OCSF or
7575
plain tracing output.
7676

77+
For AWS endpoints that require request-level signing, the proxy supports SigV4
78+
re-signing. When `credential_signing: sigv4` is set on an L7 endpoint, the proxy
79+
strips the client's placeholder-based AWS auth headers, buffers the request body,
80+
computes a fresh SigV4 signature using real credentials from the provider, and
81+
forwards the re-signed request upstream.
82+
7783
## Connect and Logs
7884

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

crates/openshell-policy/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ struct NetworkEndpointDef {
135135
graphql_persisted_queries: BTreeMap<String, GraphqlOperationDef>,
136136
#[serde(default, skip_serializing_if = "is_zero_u32")]
137137
graphql_max_body_bytes: u32,
138+
#[serde(default, skip_serializing_if = "String::is_empty")]
139+
credential_signing: String,
140+
#[serde(default, skip_serializing_if = "String::is_empty")]
141+
signing_service: String,
138142
}
139143

140144
// Signature dictated by serde's `skip_serializing_if`, which requires `&T`.
@@ -347,6 +351,8 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
347351
})
348352
.collect(),
349353
graphql_max_body_bytes: e.graphql_max_body_bytes,
354+
credential_signing: e.credential_signing,
355+
signing_service: e.signing_service,
350356
}
351357
})
352358
.collect(),
@@ -512,6 +518,8 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
512518
})
513519
.collect(),
514520
graphql_max_body_bytes: e.graphql_max_body_bytes,
521+
credential_signing: e.credential_signing.clone(),
522+
signing_service: e.signing_service.clone(),
515523
}
516524
})
517525
.collect(),

crates/openshell-providers/src/profiles.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,8 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint {
596596
.collect(),
597597
graphql_max_body_bytes: endpoint.graphql_max_body_bytes,
598598
path: endpoint.path.clone(),
599+
credential_signing: String::new(),
600+
signing_service: String::new(),
599601
}
600602
}
601603

crates/openshell-sandbox/Cargo.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,14 @@ clap = { workspace = true }
3434
miette = { workspace = true }
3535
thiserror = { workspace = true }
3636
anyhow = { workspace = true }
37-
hmac = "0.12"
3837
sha2 = { workspace = true }
3938
hex = "0.4"
39+
http = { workspace = true }
40+
41+
# AWS SigV4 request signing
42+
aws-sigv4 = { version = "1", features = ["sign-http", "http1"] }
43+
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
44+
aws-smithy-runtime-api = { version = "1", features = ["client"] }
4045
russh = "0.57"
4146
rand_core = "0.6"
4247

@@ -89,6 +94,10 @@ seccompiler = "0.5"
8994
tempfile = "3"
9095
uuid = { version = "1", features = ["v4"] }
9196

97+
[[test]]
98+
name = "sigv4_signing"
99+
path = "tests/sigv4_signing.rs"
100+
92101
[dev-dependencies]
93102
tempfile = "3"
94103
temp-env = "0.3"

crates/openshell-sandbox/src/l7/mod.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ pub enum TlsMode {
5050
Skip,
5151
}
5252

53+
/// Credential signing mode for proxy-side request signing.
54+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55+
pub enum CredentialSigning {
56+
#[default]
57+
None,
58+
SigV4,
59+
}
60+
5361
/// Enforcement mode for L7 policy decisions.
5462
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5563
pub enum EnforcementMode {
@@ -88,6 +96,11 @@ pub struct L7EndpointConfig {
8896
/// When true, client-to-server GraphQL-over-WebSocket operation messages
8997
/// are classified with the same operation policy used by GraphQL-over-HTTP.
9098
pub websocket_graphql_policy: bool,
99+
/// Proxy-side credential signing mode for this endpoint.
100+
pub credential_signing: CredentialSigning,
101+
/// AWS signing service name (e.g. `"bedrock"`). Required when
102+
/// `credential_signing` is `SigV4`.
103+
pub signing_service: String,
91104
}
92105

93106
/// Result of an L7 policy decision for a single request.
@@ -165,6 +178,24 @@ pub fn parse_l7_config(val: &regorus::Value) -> Option<L7EndpointConfig> {
165178
.filter(|v| *v > 0)
166179
.unwrap_or(graphql::DEFAULT_MAX_BODY_BYTES);
167180

181+
let credential_signing = match get_object_str(val, "credential_signing").as_deref() {
182+
Some("sigv4") => CredentialSigning::SigV4,
183+
Some(other) if !other.is_empty() => {
184+
let event = openshell_ocsf::NetworkActivityBuilder::new(crate::ocsf_ctx())
185+
.activity(openshell_ocsf::ActivityId::Other)
186+
.severity(openshell_ocsf::SeverityId::Medium)
187+
.message(format!(
188+
"unrecognized credential_signing value {other:?}, falling back to none"
189+
))
190+
.build();
191+
openshell_ocsf::ocsf_emit!(event);
192+
CredentialSigning::None
193+
}
194+
_ => CredentialSigning::None,
195+
};
196+
197+
let signing_service = get_object_str(val, "signing_service").unwrap_or_default();
198+
168199
Some(L7EndpointConfig {
169200
protocol,
170201
path: get_object_str(val, "path").unwrap_or_default(),
@@ -175,6 +206,8 @@ pub fn parse_l7_config(val: &regorus::Value) -> Option<L7EndpointConfig> {
175206
websocket_credential_rewrite,
176207
request_body_credential_rewrite,
177208
websocket_graphql_policy,
209+
credential_signing,
210+
signing_service,
178211
})
179212
}
180213

crates/openshell-sandbox/src/l7/relay.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,9 @@ where
351351
websocket_extensions: websocket_extension_mode(config),
352352
request_body_credential_rewrite: config.protocol == L7Protocol::Rest
353353
&& config.request_body_credential_rewrite,
354+
credential_signing: config.credential_signing,
355+
signing_service: &config.signing_service,
356+
host: &ctx.host,
354357
},
355358
)
356359
.await?;
@@ -769,6 +772,9 @@ where
769772
websocket_extensions: websocket_extension_mode(config),
770773
request_body_credential_rewrite: config.protocol == L7Protocol::Rest
771774
&& config.request_body_credential_rewrite,
775+
credential_signing: config.credential_signing,
776+
signing_service: &config.signing_service,
777+
host: &ctx.host,
772778
},
773779
)
774780
.await?;
@@ -1417,6 +1423,8 @@ network_policies:
14171423
websocket_credential_rewrite: true,
14181424
request_body_credential_rewrite: false,
14191425
websocket_graphql_policy: false,
1426+
credential_signing: crate::l7::CredentialSigning::None,
1427+
signing_service: String::new(),
14201428
}];
14211429
let ctx = L7EvalContext {
14221430
host: "gateway.example.test".into(),
@@ -1517,6 +1525,8 @@ network_policies:
15171525
websocket_credential_rewrite: true,
15181526
request_body_credential_rewrite: false,
15191527
websocket_graphql_policy: false,
1528+
credential_signing: crate::l7::CredentialSigning::None,
1529+
signing_service: String::new(),
15201530
}];
15211531
let (child_env, resolver) = SecretResolver::from_provider_env(
15221532
std::iter::once(("DISCORD_BOT_TOKEN".to_string(), "real-token".to_string())).collect(),
@@ -1634,6 +1644,8 @@ network_policies:
16341644
websocket_credential_rewrite: true,
16351645
request_body_credential_rewrite: false,
16361646
websocket_graphql_policy: true,
1647+
credential_signing: crate::l7::CredentialSigning::None,
1648+
signing_service: String::new(),
16371649
}];
16381650
let (child_env, resolver) = SecretResolver::from_provider_env(
16391651
std::iter::once(("T".to_string(), "real-token".to_string())).collect(),

crates/openshell-sandbox/src/l7/rest.rs

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,9 @@ where
377377
generation_guard,
378378
websocket_extensions: WebSocketExtensionMode::Preserve,
379379
request_body_credential_rewrite: false,
380+
credential_signing: crate::l7::CredentialSigning::None,
381+
signing_service: "",
382+
host: "",
380383
},
381384
)
382385
.await
@@ -389,12 +392,15 @@ pub(crate) enum WebSocketExtensionMode {
389392
PermessageDeflate,
390393
}
391394

392-
#[derive(Clone, Copy, Default)]
395+
#[derive(Clone, Default)]
393396
pub(crate) struct RelayRequestOptions<'a> {
394397
pub(crate) resolver: Option<&'a SecretResolver>,
395398
pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>,
396399
pub(crate) websocket_extensions: WebSocketExtensionMode,
397400
pub(crate) request_body_credential_rewrite: bool,
401+
pub(crate) credential_signing: crate::l7::CredentialSigning,
402+
pub(crate) signing_service: &'a str,
403+
pub(crate) host: &'a str,
398404
}
399405

400406
pub(crate) async fn relay_http_request_with_options_guarded<C, U>(
@@ -421,8 +427,19 @@ where
421427
parse_websocket_upgrade_request(&req.raw_header[..header_end])?
422428
};
423429

430+
// When SigV4 signing is configured, strip AWS auth headers before credential
431+
// rewriting so the fail-closed placeholder scan doesn't reject the SigV4
432+
// Authorization header (which embeds placeholder strings).
433+
let raw_for_rewrite;
434+
let header_source = if options.credential_signing == crate::l7::CredentialSigning::SigV4 {
435+
raw_for_rewrite = crate::sigv4::strip_aws_headers(&req.raw_header[..header_end]);
436+
&raw_for_rewrite[..]
437+
} else {
438+
&req.raw_header[..header_end]
439+
};
440+
424441
let (header_bytes, expected_websocket_extension) = rewrite_websocket_extensions_for_mode(
425-
&req.raw_header[..header_end],
442+
header_source,
426443
options.websocket_extensions,
427444
websocket_request.is_some(),
428445
)?;
@@ -442,7 +459,82 @@ where
442459
guard.ensure_current()?;
443460
}
444461

445-
if options.request_body_credential_rewrite {
462+
// Apply SigV4 signing if configured. We need the full request (headers + body)
463+
// to compute the signature, so for SigV4 we always buffer the body first.
464+
if options.credential_signing == crate::l7::CredentialSigning::SigV4 {
465+
if let Some(resolver) = options.resolver {
466+
let access_key_placeholder =
467+
crate::secrets::placeholder_for_env_key("AWS_ACCESS_KEY_ID");
468+
let secret_key_placeholder =
469+
crate::secrets::placeholder_for_env_key("AWS_SECRET_ACCESS_KEY");
470+
let session_token_placeholder =
471+
crate::secrets::placeholder_for_env_key("AWS_SESSION_TOKEN");
472+
473+
match (
474+
resolver.resolve_placeholder(&access_key_placeholder),
475+
resolver.resolve_placeholder(&secret_key_placeholder),
476+
) {
477+
(Some(access_key), Some(secret_key)) => {
478+
let session_token = resolver.resolve_placeholder(&session_token_placeholder);
479+
let region = crate::sigv4::extract_aws_region(options.host)
480+
.unwrap_or_else(|| "us-east-1".to_string());
481+
let service = &options.signing_service;
482+
if service.is_empty() {
483+
return Err(miette!(
484+
"SigV4 signing configured but signing_service not set in policy"
485+
));
486+
}
487+
debug!(
488+
host = %options.host,
489+
region = %region,
490+
service = %service,
491+
"applying SigV4 signing to CONNECT tunnel request"
492+
);
493+
494+
// Collect body from overflow + stream
495+
let overflow = &req.raw_header[header_end..];
496+
let mut full_request = rewrite_result.rewritten.clone();
497+
full_request.extend_from_slice(overflow);
498+
// Read remaining body based on content-length
499+
if let BodyLength::ContentLength(body_len) = parse_body_length(header_str)? {
500+
if body_len > MAX_REWRITE_BODY_BYTES as u64 {
501+
return Err(miette!(
502+
"SigV4 signing buffers at most {MAX_REWRITE_BODY_BYTES} bytes"
503+
));
504+
}
505+
let already_have = overflow.len() as u64;
506+
if body_len > already_have {
507+
let remaining =
508+
usize::try_from(body_len - already_have).unwrap_or(usize::MAX);
509+
let mut body_buf = vec![0u8; remaining];
510+
client.read_exact(&mut body_buf).await.into_diagnostic()?;
511+
full_request.extend_from_slice(&body_buf);
512+
}
513+
}
514+
515+
let signed = crate::sigv4::apply_sigv4_to_request(
516+
&full_request,
517+
options.host,
518+
&region,
519+
service,
520+
access_key,
521+
secret_key,
522+
session_token,
523+
)?;
524+
upstream.write_all(&signed).await.into_diagnostic()?;
525+
}
526+
_ => {
527+
return Err(miette!(
528+
"SigV4 signing configured but AWS credentials not found in provider"
529+
));
530+
}
531+
}
532+
} else {
533+
return Err(miette!(
534+
"SigV4 signing configured but no secret resolver available"
535+
));
536+
}
537+
} else if options.request_body_credential_rewrite {
446538
let body = collect_and_rewrite_request_body(
447539
req,
448540
client,

crates/openshell-sandbox/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mod provider_credentials;
2323
pub mod proxy;
2424
mod sandbox;
2525
mod secrets;
26+
pub mod sigv4;
2627
mod skills;
2728
mod ssh;
2829
mod supervisor_session;

0 commit comments

Comments
 (0)