Enable masked AWS credentials for local proxy signing (#56554)

## What changed

- Expose `aws_sigv4` actions in version 2 MITM permissions configuration and the config schema, rejecting multiple signing actions per hook.
- Give local managed commands placeholder AWS credentials for the selected signing pair, and mask configured credentials in environment values and protected shell snapshots.
- Set `AWS_CA_BUNDLE` to the managed proxy CA bundle when unset, and require `features.credential_masking` for local AWS signing hooks.
- Validate placeholders against every configured signing pair to prevent overlap with real credentials.

## Testing

Extend configuration, signing, and TLS forwarding tests to cover action resolution, invalid signing configuration, credential masking, CA trust, snapshot protection, and exclusion of local AWS defaults from remote environments.

GitOrigin-RevId: 81dfdea30ebde74829882b38891ea41e9737058a
This commit is contained in:
viyatb-oai
2026-10-23 18:40:55 +00:00
committed by copyberry
parent c483edf177
commit 15fbcac045
15 changed files with 480 additions and 40 deletions
+23 -2
View File
@@ -397,6 +397,7 @@ enum NetworkModeSchema {
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct NetworkMitmActionToml {
pub aws_sigv4: Option<codex_network_proxy::AwsSigV4Config>,
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
}
@@ -445,7 +446,7 @@ impl NetworkMitmToml {
.inject_request_headers
.iter()
.any(|header| header.placeholder.is_some());
if self.version.is_none() && uses_placeholder {
if self.version.is_none() && (uses_placeholder || action.aws_sigv4.is_some()) {
return Err("scoped MITM actions require network.mitm.version = 2".to_string());
}
if action.is_empty() {
@@ -461,6 +462,21 @@ impl NetworkMitmToml {
};
for (hook_name, hook) in hooks {
let signing_count = hook
.action
.iter()
.filter(|name| {
self.actions
.as_ref()
.and_then(|actions| actions.get(*name))
.is_some_and(|action| action.aws_sigv4.is_some())
})
.count();
if signing_count > 1 {
return Err(format!(
"network.mitm.hooks.{hook_name} must select at most one AWS signing action"
));
}
if self.version.is_none() && (!hook.schemes.is_empty() || !hook.ports.is_empty()) {
return Err("scoped MITM hooks require network.mitm.version = 2".to_string());
}
@@ -492,7 +508,9 @@ impl NetworkMitmToml {
impl NetworkMitmActionToml {
pub fn is_empty(&self) -> bool {
self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty()
self.aws_sigv4.is_none()
&& self.strip_request_headers.is_empty()
&& self.inject_request_headers.is_empty()
}
}
@@ -590,6 +608,9 @@ impl NetworkMitmHookToml {
let mut selected = MitmHookActionsConfig::default();
for action_name in &self.action {
if let Some(action) = actions_by_name.get(action_name) {
if action.aws_sigv4.is_some() {
selected.aws_sigv4 = action.aws_sigv4.clone();
}
selected
.strip_request_headers
.extend(action.strip_request_headers.clone());
+45 -10
View File
@@ -1,10 +1,19 @@
//! Trusted network extensions survive TOML resolution with origin-scoped header placeholders.
//! Trusted network extensions survive TOML resolution without ambiguous signing authority.
use super::*;
use codex_network_proxy::ProxySecretSource;
use pretty_assertions::assert_eq;
const HEADER_ACTION: &str = r#"
const SIGNING: &str = r#"
version = 2
[actions.aws.aws_sigv4]
placeholder_access_key_id = "MASKEDACCESSKEY"
region = "us-east-1"
service = "s3"
access_key_id = { env_var = "PROXY_ACCESS_KEY" }
secret_access_key = { file = "/trusted/aws-secret" }
session_token = { env_var = "PROXY_SESSION_TOKEN" }
[[actions.substitute.inject_request_headers]]
name = "x-api-key"
secret_env_var = "PROXY_API_KEY"
@@ -16,15 +25,21 @@ schemes = ["https"]
ports = [443]
methods = ["PUT"]
path_prefixes = ["/workspace/"]
action = ["substitute"]
action = ["substitute", "aws"]
"#;
#[test]
fn resolves_origin_scoped_header_substitution_from_toml() {
let config: NetworkMitmToml = toml::from_str(HEADER_ACTION).unwrap();
fn resolves_native_signing_and_substitution_actions_from_toml() {
let config: NetworkMitmToml = toml::from_str(SIGNING).unwrap();
let hooks = config.to_runtime_hooks(config.actions.as_ref());
assert_eq!(hooks[0].matcher.schemes, vec!["https"]);
assert_eq!(hooks[0].matcher.ports, vec![443]);
let signer = hooks[0].actions.aws_sigv4.as_ref().unwrap();
assert_eq!(
signer.access_key_id,
ProxySecretSource::EnvVar("PROXY_ACCESS_KEY".into())
);
assert_eq!(signer.port, 443);
assert_eq!(
hooks[0].actions.inject_request_headers[0]
.placeholder
@@ -47,18 +62,20 @@ fn scoped_rules_reject_rollback_to_the_legacy_format() {
_actions: toml::Value,
}
let unversioned = HEADER_ACTION.replacen("version = 2\n", "", 1);
let unversioned = SIGNING.replacen("version = 2\n", "", 1);
assert!(toml::from_str::<NetworkMitmToml>(&unversioned).is_err());
assert!(toml::from_str::<LegacyMitm>(&unversioned).is_ok());
assert!(toml::from_str::<LegacyMitm>(HEADER_ACTION).is_err());
assert!(toml::from_str::<LegacyMitm>(SIGNING).is_err());
}
#[test]
fn profile_and_managed_requirements_reject_misspelled_mitm_matchers() {
let profile = HEADER_ACTION
.replace("[[actions.", "[[permissions.test.network.mitm.actions.")
let profile = SIGNING
.replace("[actions.", "[permissions.test.network.mitm.actions.")
.replace("[hooks.", "[permissions.test.network.mitm.hooks.");
let profile = format!("[permissions.test.network.mitm]\n{profile}");
toml::from_str::<crate::config_toml::ConfigToml>(&profile).unwrap();
toml::from_str::<crate::ConfigRequirementsToml>(&profile).unwrap();
for (key, value) in [
("header", r#"{ x-tenant = ["allowed"] }"#),
("port", "[443]"),
@@ -80,7 +97,7 @@ fn profile_and_managed_requirements_reject_misspelled_mitm_matchers() {
#[test]
fn rejects_misspelled_header_placeholder() {
let input = HEADER_ACTION.replacen("placeholder =", "unknown_placeholder =", 1);
let input = SIGNING.replacen("placeholder =", "unknown_placeholder =", 1);
let error = toml::from_str::<NetworkMitmToml>(&input).unwrap_err();
assert!(
error
@@ -88,3 +105,21 @@ fn rejects_misspelled_header_placeholder() {
.contains("unknown field `unknown_placeholder`")
);
}
#[test]
fn rejects_multiple_signing_actions_and_unknown_signing_parameters() {
assert!(
toml::from_str::<NetworkMitmToml>(&SIGNING.replace(
"action = [\"substitute\", \"aws\"]",
"action = [\"aws\", \"aws\"]"
))
.is_err()
);
assert!(
toml::from_str::<NetworkMitmToml>(&SIGNING.replace(
"region = \"us-east-1\"",
"region = \"us-east-1\"\nallow_unsigned_payload = true"
))
.is_err()
);
}
+82
View File
@@ -521,6 +521,51 @@
],
"type": "object"
},
"AwsSigV4Config": {
"additionalProperties": false,
"description": "AWS SigV4 action selected by an exact-host, method, and path MITM hook.",
"properties": {
"access_key_id": {
"$ref": "#/definitions/ProxySecretSource"
},
"max_body_bytes": {
"default": 8388608,
"format": "uint",
"minimum": 0.0,
"type": "integer"
},
"placeholder_access_key_id": {
"description": "Nonsecret access key ID installed in the guest AWS profile.",
"type": "string"
},
"port": {
"default": 443,
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"region": {
"type": "string"
},
"secret_access_key": {
"$ref": "#/definitions/ProxySecretSource"
},
"service": {
"type": "string"
},
"session_token": {
"$ref": "#/definitions/ProxySecretSource"
}
},
"required": [
"access_key_id",
"placeholder_access_key_id",
"region",
"secret_access_key",
"service"
],
"type": "object"
},
"BrowserUseConfigToml": {
"additionalProperties": false,
"properties": {
@@ -3155,6 +3200,14 @@
"NetworkMitmActionToml": {
"additionalProperties": false,
"properties": {
"aws_sigv4": {
"allOf": [
{
"$ref": "#/definitions/AwsSigV4Config"
}
],
"default": null
},
"inject_request_headers": {
"default": [],
"items": {
@@ -3960,6 +4013,35 @@
},
"type": "object"
},
"ProxySecretSource": {
"description": "A credential source in the trusted proxy process, never in the executor environment.",
"oneOf": [
{
"additionalProperties": false,
"properties": {
"env_var": {
"type": "string"
}
},
"required": [
"env_var"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"file": {
"type": "string"
}
},
"required": [
"file"
],
"type": "object"
}
]
},
"RawMcpServerConfig": {
"additionalProperties": false,
"description": "Raw MCP config shape used for deserialization and supported-field JSON Schema generation.\n\nFields that are accepted only to produce targeted validation errors should be skipped in the generated schema.\n\nKeep `TryFrom<RawMcpServerConfig> for McpServerConfig` exhaustively destructuring this struct so new TOML fields cannot be added here without updating the validation/mapping logic that produces [`McpServerConfig`].",
+2
View File
@@ -1683,6 +1683,7 @@ strip_request_headers = ["authorization"]
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
aws_sigv4: None,
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
@@ -1765,6 +1766,7 @@ fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() {
actions: Some(IndexMap::from([(
"strip_auth".to_string(),
NetworkMitmActionToml {
aws_sigv4: None,
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: Vec::new(),
},
@@ -174,6 +174,19 @@ impl NetworkProxySpec {
} else {
(config, NetworkProxyConstraints::default())
};
if config.enabled
&& config.mitm
&& !config.credential_broker
&& config
.mitm_hooks
.iter()
.any(|hook| hook.actions.aws_sigv4.is_some())
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"local AWS SigV4 hooks require features.credential_masking for protected shell snapshots",
));
}
validate_policy_against_constraints(&config, &constraints).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
+38 -8
View File
@@ -274,12 +274,42 @@ what it can reasonably guarantee.
transport layer. If your threat model includes hostile DNS, enforce network egress at a lower
layer too (e.g., firewall / VPC / corporate proxy policies).
### Native origin and exact secret substitution
### Native AWS signing and exact secret substitution
MITM hook `match.schemes` and `match.ports` constrain the trusted tunnel origin;
empty lists preserve existing behavior. An injected header can set `placeholder`
to require one exact whole-header value before substitution. Missing, mismatched,
or duplicate values fail closed. This form requires HTTPS port 443 and cannot
modify routing or framing headers. Secret sources remain trusted host references;
resolved values are marked sensitive in diagnostics and file sources are bounded
to 64 KiB. Ambiguous semicolon paths cannot authorize scoped credentials.
A trusted `aws_sigv4` action pairs a `placeholder_access_key_id` with a real
`access_key_id`, `secret_access_key`, and optional `session_token`. The latter
three reference files or environment variables in the proxy process. Resolve them
at startup and replace the proxy installation to rotate them.
For local Codex, enable `credential_masking = true` under `[features]` in
`config.toml` and set `placeholder_access_key_id = "CODEXMASKEDWORKSPACE"` on the
proxy action. Local managed commands receive the dummy access key, secret and,
for temporary credentials, session token in their standard AWS environment.
An empty AWS environment selects a single configured pair. With multiple pairs,
set `AWS_ACCESS_KEY_ID` to the intended placeholder. An unrelated access key or
an explicit `AWS_PROFILE` keeps its own selection. Inherited copies of configured
credentials are masked in child environment values and protected shell snapshots.
File-backed credentials require filesystem permissions that prevent child access.
The AWS CLI or SDK signs with the dummy secret. After the exact
host, method and path hook authorizes the request, the proxy recognizes the
placeholder in the SigV4 credential scope and re-signs for the configured HTTPS
port (443 by default), region and service. The dummy ID selects the pair and is
visible to the workload. Unsigned requests and requests using other credentials
follow the ordinary network and hook policy with their original AWS identity.
A matched placeholder with an invalid scope or unsupported signature is denied.
Re-signing preserves the client timestamp and signed headers and substitutes the
real session token when configured. The AWS SDK signs the forwarded bytes after
header mutations. Literal payloads use a 30-second deadline and an 8 MiB default
buffer limit (16 MiB maximum). An explicit `UNSIGNED-PAYLOAD` streams without
using the signing buffer. OpenSearch Serverless requires a literal payload hash.
Masked presigned URLs, SigV4a, AWS signed streams and declared trailers are denied.
An injected header can additionally set `placeholder` to require one exact
whole-header value before substituting the secret. Missing, mismatched, or
duplicate values fail closed. This form requires HTTPS port 443 and cannot
modify routing or framing headers. Resolved secret headers and signing headers
are marked sensitive in diagnostics.
All native buffering stages share eight request slots, with a five-second bounded
wait. Each lease follows the prepared body until consumed or dropped; the 16 MiB
body cap bounds retained memory even when upstream connections are slow.
+132 -11
View File
@@ -1,6 +1,9 @@
//! Trusted, destination-scoped AWS signing after network and HTTP hook authorization.
//! Credentials never enter the workload; unsupported streaming signatures fail closed.
use crate::credential_broker::env_value;
use crate::credential_broker::remove_env_value;
use crate::credential_broker::set_env_value;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::ensure;
@@ -29,6 +32,9 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use sha2::Digest as _;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;
@@ -153,17 +159,6 @@ impl AwsSigV4Config {
/*expires_after*/ None,
"network-proxy",
);
ensure!(
[
Some(credentials.access_key_id()),
Some(credentials.secret_access_key()),
credentials.session_token(),
]
.into_iter()
.flatten()
.all(|value| !self.placeholder_access_key_id.contains(value)),
"AWS guest placeholders overlap a real credential"
);
Ok(AwsSigV4Signer {
config: self.clone(),
credentials,
@@ -236,6 +231,132 @@ pub(crate) struct GuestSignature {
unsigned_payload: bool,
}
const DUMMY_SECRET: &str = "codex-local-aws-secret";
const DUMMY_TOKEN: &str = "codex-local-aws-token";
pub(crate) const AWS_CA_BUNDLE: &str = "AWS_CA_BUNDLE";
/// Every value exposed to a child must be safe with respect to every configured pair.
pub(crate) fn validate_guest_placeholders<'a>(
signers: impl Iterator<Item = &'a AwsSigV4Signer> + Clone,
) -> Result<()> {
for signer in signers.clone() {
let credentials = &signer.credentials;
let mut placeholders = [DUMMY_SECRET, DUMMY_TOKEN].into_iter().chain(
signers
.clone()
.map(|other| other.config.placeholder_access_key_id.as_str()),
);
ensure!(
placeholders.all(|placeholder| {
[
Some(credentials.access_key_id()),
Some(credentials.secret_access_key()),
credentials.session_token(),
]
.into_iter()
.flatten()
.all(|real| !placeholder.contains(real))
}),
"AWS guest placeholders overlap a real credential"
);
}
Ok(())
}
pub(crate) fn snapshot_env_keys(env: &HashMap<String, String>) -> Option<[&'static str; 5]> {
(env_value(env, "AWS_SECRET_ACCESS_KEY") == Some(DUMMY_SECRET)
|| env_value(env, AWS_CA_BUNDLE)
.is_some_and(crate::certs::is_managed_mitm_ca_trust_bundle_path))
.then_some([
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_SECURITY_TOKEN",
AWS_CA_BUNDLE,
])
}
/// Prepare the standard AWS environment for a local process before generic credential discovery.
pub(crate) fn mask_local_aws_env(
signers: &[Arc<AwsSigV4Signer>],
env: &mut HashMap<String, String>,
ca_path: Option<&Path>,
) {
if signers.is_empty() {
return;
}
if env_value(env, AWS_CA_BUNDLE).is_none_or(str::is_empty)
&& let Some(path) = ca_path
{
set_env_value(env, AWS_CA_BUNDLE, path.to_string_lossy().into_owned());
}
let access = env_value(env, "AWS_ACCESS_KEY_ID").filter(|id| !id.is_empty());
let use_default = access.is_none()
&& env_value(env, "AWS_PROFILE").is_none()
&& env_value(env, "AWS_DEFAULT_PROFILE").is_none();
let mut candidates = signers.iter().filter(|s| {
use_default
|| access.is_some_and(|id| {
id == s.credentials.access_key_id() || id == s.config.placeholder_access_key_id
})
});
let first = candidates.next();
let selected = first.filter(|first| {
candidates.all(|s| {
s.config.placeholder_access_key_id == first.config.placeholder_access_key_id
&& s.credentials.session_token().is_some()
== first.credentials.session_token().is_some()
})
});
let ambiguous_access = access.is_some() && first.is_some() && selected.is_none();
// Mask aliases of every configured pair, including pairs the SDK didn't select.
for value in env.values_mut() {
mask_local_aws_text(signers, value);
}
if ambiguous_access {
for key in [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_SECURITY_TOKEN",
] {
remove_env_value(env, key);
}
} else if let Some(signer) = selected {
set_env_value(
env,
"AWS_ACCESS_KEY_ID",
signer.config.placeholder_access_key_id.clone(),
);
set_env_value(env, "AWS_SECRET_ACCESS_KEY", DUMMY_SECRET.to_string());
remove_env_value(env, "AWS_SECURITY_TOKEN");
if signer.credentials.session_token().is_some() {
set_env_value(env, "AWS_SESSION_TOKEN", DUMMY_TOKEN.to_string());
} else {
remove_env_value(env, "AWS_SESSION_TOKEN");
}
}
}
pub(crate) fn mask_local_aws_text(signers: &[Arc<AwsSigV4Signer>], text: &mut String) {
for signer in signers {
for (real, dummy) in [
(
Some(signer.credentials.access_key_id()),
signer.config.placeholder_access_key_id.as_str(),
),
(Some(signer.credentials.secret_access_key()), DUMMY_SECRET),
(signer.credentials.session_token(), DUMMY_TOKEN),
] {
if let Some(real) = real
&& text.contains(real)
{
*text = text.replace(real, dummy);
}
}
}
}
impl AwsSigV4Signer {
pub(crate) fn plan(&self, req: &Request) -> Result<Option<GuestSignature>> {
let placeholder = &self.config.placeholder_access_key_id;
+37 -1
View File
@@ -92,7 +92,33 @@ fn matches_aws_published_iam_signature_vector() {
async fn signs_actual_payload_and_replaces_untrusted_signing_headers() {
let mut config = config("s3");
config.session_token = Some(ProxySecretSource::EnvVar("SESSION_TOKEN".into()));
let signer = signer(config);
let signer = Arc::new(signer(config));
let mut env = HashMap::from([
(
"AWS_ACCESS_KEY_ID".to_string(),
signer.credentials.access_key_id().to_string(),
),
(
"SESSION_TOKEN".to_string(),
"secret-session-token".to_string(),
),
]);
mask_local_aws_env(
std::slice::from_ref(&signer),
&mut env,
/*ca_path*/ None,
);
assert_eq!(env["AWS_ACCESS_KEY_ID"], "MASKEDACCESSKEY");
assert_eq!(env["SESSION_TOKEN"], env["AWS_SESSION_TOKEN"]);
assert_ne!(env["AWS_SESSION_TOKEN"], "secret-session-token");
env.insert("AWS_ACCESS_KEY_ID".to_string(), "OTHERKEY".to_string());
env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "unrelated".to_string());
mask_local_aws_env(
std::slice::from_ref(&signer),
&mut env,
/*ca_path*/ None,
);
assert_eq!(env["AWS_SECRET_ACCESS_KEY"], "unrelated");
let mut cookie = HeaderValue::from_static("b=2");
cookie.set_sensitive(true);
let request = Request::builder()
@@ -289,6 +315,16 @@ fn credentials_are_required_and_configured_limits_are_bounded() {
.compile(&|_| None, &|_| panic!("unexpected file"))
.is_err()
);
let first = signer(config("s3"));
let mut other = config("sts");
other.placeholder_access_key_id = format!("GUEST{}", first.credentials.access_key_id());
let other = other
.compile(&|key| Some(format!("other-{key}")), &|_| {
panic!("unexpected file")
})
.unwrap();
assert!(validate_guest_placeholders([&first, &other].into_iter()).is_err());
let mut config = config("s3");
config.max_body_bytes = 16 * 1024 * 1024 + 1;
assert_eq!(
+9
View File
@@ -51,8 +51,17 @@ static MANAGED_MITM_CAS: LazyLock<Mutex<HashMap<PathBuf, Arc<ManagedMitmCa>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
const MAX_EXTERNAL_CA_FILE_BYTES: u64 = 1024 * 1024;
#[cfg(test)]
tokio::task_local! {
pub(super) static TEST_MITM_CA: Arc<ManagedMitmCa>;
}
impl ManagedMitmCa {
pub(super) fn load_or_create() -> Result<Arc<Self>> {
#[cfg(test)]
if let Ok(ca) = TEST_MITM_CA.try_with(Arc::clone) {
return Ok(ca);
}
let proxy_dir = managed_ca_dir()?;
let mut managed_cas = MANAGED_MITM_CAS
.lock()
@@ -258,14 +258,14 @@ pub(super) fn env_value<'a>(env: &'a HashMap<String, String>, key: &str) -> Opti
env_entry(env, key).map(|(_, value)| value)
}
fn set_env_value(env: &mut HashMap<String, String>, key: &str, value: String) {
pub(super) fn set_env_value(env: &mut HashMap<String, String>, key: &str, value: String) {
if cfg!(windows) {
env.retain(|candidate, _| !env_key_matches(candidate, key));
}
env.insert(key.to_string(), value);
}
fn remove_env_value(env: &mut HashMap<String, String>, key: &str) {
pub(super) fn remove_env_value(env: &mut HashMap<String, String>, key: &str) {
if cfg!(windows) {
env.retain(|candidate, _| !env_key_matches(candidate, key));
} else {
@@ -455,6 +455,9 @@ pub fn brokered_credential_env_keys(
let active = env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY).is_some_and(|value| value == "1");
let mut keys = Vec::new();
if active {
if let Some(aws_keys) = crate::aws_sigv4::snapshot_env_keys(env) {
keys.extend(aws_keys);
}
let brokered_keys = brokered_credential_dummy_env_keys(env);
keys.extend(providers::credential_env_keys().filter(|key| {
brokered_keys
@@ -94,6 +94,7 @@ async fn tls_origin(
}],
..Default::default()
};
config.set_credential_broker_enabled(/*enabled*/ true);
config.set_allowed_domains(vec!["localhost".to_string()]);
let app_state = Arc::new(network_proxy_state_for_policy(config));
let roots = Arc::new(roots);
@@ -194,6 +195,34 @@ fn request(
async fn forwards_aoss_payload_hashes_and_signatures_over_tls() {
let dir = tempdir().expect("TLS fixture directory");
let (context, listener, acceptor) = tls_origin(dir.path(), "aoss", b"http/1.1").await;
let builder = crate::NetworkProxy::builder()
.state(Arc::clone(&context.policy.app_state))
.managed_proxy_routing(crate::ManagedProxyRouting::DedicatedListeners);
let proxy = crate::certs::TEST_MITM_CA
.scope(Arc::clone(&context.mitm.ca), builder.build())
.await
.expect("local proxy");
let mut env = HashMap::from([("ALIAS".to_string(), "prefix:test-secret-key".to_string())]);
proxy.apply_to_env_for_snapshot(&mut env);
assert_eq!(env["AWS_ACCESS_KEY_ID"], "MASKEDACCESSKEY");
assert_eq!(env["AWS_CA_BUNDLE"], env["SSL_CERT_FILE"]);
assert_eq!(Path::new(&env["AWS_CA_BUNDLE"]).parent(), Some(dir.path()));
assert!(crate::brokered_credential_env_keys(&env).any(|key| key == "AWS_ACCESS_KEY_ID"));
assert_eq!(
env["ALIAS"],
format!("prefix:{}", env["AWS_SECRET_ACCESS_KEY"])
);
let mut snapshot = "export COPY=test-secret-key".to_string();
assert!(proxy.virtualize_brokered_text(&mut snapshot, &env));
assert_eq!(
snapshot,
format!("export COPY={}", env["AWS_SECRET_ACCESS_KEY"])
);
let remote = proxy
.prepare_for_remote_environment(HashMap::new(), "remote")
.expect("remote environment");
assert!(!remote.env.contains_key("AWS_ACCESS_KEY_ID"));
assert!(!remote.env.contains_key("AWS_CA_BUNDLE"));
let cases: [(&str, &[u8]); 4] = [
("/unsigned", b""),
("/other-key", b""),
+6
View File
@@ -389,6 +389,12 @@ where
});
}
crate::aws_sigv4::validate_guest_placeholders(
hooks_by_host
.values()
.flatten()
.filter_map(|hook| hook.actions.aws_sigv4.as_deref()),
)?;
Ok(hooks_by_host)
}
+10 -2
View File
@@ -683,7 +683,7 @@ pub fn is_managed_proxy_env_var(key: &str, value: &str) -> bool {
if PROXY_ENV_KEYS.contains(&key) {
return true;
}
if crate::certs::CUSTOM_CA_ENV_KEYS.contains(&key) {
if key == crate::aws_sigv4::AWS_CA_BUNDLE || crate::certs::CUSTOM_CA_ENV_KEYS.contains(&key) {
return crate::certs::is_managed_mitm_ca_trust_bundle_path(value);
}
#[cfg(target_os = "macos")]
@@ -1153,7 +1153,6 @@ impl NetworkProxy {
&self,
mut env: HashMap<String, String>,
addrs: EnvironmentProxyAddrs,
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
client: EnvironmentProxyClient,
environment_id: Option<&str>,
) -> PreparedManagedNetwork {
@@ -1171,6 +1170,15 @@ impl NetworkProxy {
..addrs
};
let runtime_settings = self.runtime_settings();
if client == EnvironmentProxyClient::SandboxedProcess {
self.state.mask_local_aws_credentials(
&mut env,
runtime_settings
.mitm_ca_trust_bundle
.as_ref()
.map(|bundle| bundle.path.as_path()),
);
}
// Enforce proxying for child processes. Proxy endpoint values are always rewritten;
// managed MITM CA vars preserve child-scoped overrides after proxy startup.
apply_proxy_env_overrides(
+49 -4
View File
@@ -1,3 +1,6 @@
use crate::aws_sigv4::AwsSigV4Signer;
use crate::aws_sigv4::mask_local_aws_env;
use crate::aws_sigv4::mask_local_aws_text;
use crate::config::NetworkDomainPermission;
use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
@@ -189,6 +192,19 @@ pub struct ConfigState {
pub blocked_total: u64,
}
impl ConfigState {
fn local_aws_signers(&self) -> Vec<Arc<AwsSigV4Signer>> {
if !self.config.enabled || !self.config.credential_broker || !self.config.mitm {
return Vec::new();
}
self.mitm_hooks
.values()
.flatten()
.filter_map(|hook| hook.actions.aws_sigv4.clone())
.collect()
}
}
pub trait ConfigReloader: Send + Sync {
/// Human-readable description of where config is loaded from, for logs.
fn source_label(&self) -> String;
@@ -278,6 +294,7 @@ pub struct NetworkProxyState {
pub(crate) process_log_metadata: crate::NetworkProxyProcessLogMetadata,
/// Shared by this proxy's listeners; rebinding leaves other proxies' cached brokers intact.
credential_broker: Arc<std::sync::RwLock<CredentialBroker>>,
local_aws_signers: Arc<std::sync::RwLock<Vec<Arc<AwsSigV4Signer>>>>,
audit_metadata: NetworkProxyAuditMetadata,
execution_attributions: Arc<Mutex<HashMap<String, ExecutionAttribution>>>,
environment_id: Option<Arc<str>>,
@@ -317,6 +334,7 @@ impl Clone for NetworkProxyState {
launch_span_context: self.launch_span_context.clone(),
process_log_metadata: self.process_log_metadata.clone(),
credential_broker: self.credential_broker.clone(),
local_aws_signers: self.local_aws_signers.clone(),
audit_metadata: self.audit_metadata.clone(),
execution_attributions: self.execution_attributions.clone(),
environment_id: self.environment_id.clone(),
@@ -424,6 +442,7 @@ impl NetworkProxyState {
credential_broker.configure(&local_binding_policy.resolved_config(&state.config));
Self {
credential_broker: Arc::new(std::sync::RwLock::new(credential_broker)),
local_aws_signers: Arc::new(std::sync::RwLock::new(state.local_aws_signers())),
state: Arc::new(RwLock::new(state)),
local_binding_policy,
proxy_private_ips_via_upstream: false,
@@ -561,6 +580,27 @@ impl NetworkProxyState {
Ok(())
}
fn install_config(&self, current: &mut ConfigState, next: ConfigState) {
let mut signers = self
.local_aws_signers
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*signers = next.local_aws_signers();
*current = next;
}
pub(crate) fn mask_local_aws_credentials(
&self,
env: &mut HashMap<String, String>,
ca_path: Option<&Path>,
) {
let signers = self
.local_aws_signers
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
mask_local_aws_env(&signers, env, ca_path);
}
pub fn virtualize_child_credentials(&self, env: &mut HashMap<String, String>) {
self.virtualize_child_credentials_for_environment(env, self.environment_id());
}
@@ -624,6 +664,11 @@ impl NetworkProxyState {
text: &mut String,
env: &HashMap<String, String>,
) -> bool {
let signers = self
.local_aws_signers
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
mask_local_aws_text(&signers, text);
self.credential_broker().virtualize_text(text, env)
}
@@ -742,7 +787,7 @@ impl NetworkProxyState {
// Log policy diffs without dumping potentially sensitive config values.
log_policy_changes(&guard.config, &new_state.config);
new_state.blocked = guard.blocked.clone();
*guard = new_state;
self.install_config(&mut guard, new_state);
}
let source = self.reloader.source_label();
info!("reloaded config from {source}");
@@ -764,7 +809,7 @@ impl NetworkProxyState {
log_policy_changes(&guard.config, &new_state.config);
new_state.blocked = guard.blocked.clone();
new_state.blocked_total = guard.blocked_total;
*guard = new_state;
self.install_config(&mut guard, new_state);
info!("updated network proxy config state");
Ok(())
}
@@ -1176,7 +1221,7 @@ impl NetworkProxyState {
}
log_policy_changes(&guard.config, &candidate);
*guard = new_state;
self.install_config(&mut guard, new_state);
info!("updated network {list_name} with {normalized_host}");
return Ok(());
}
@@ -1193,7 +1238,7 @@ impl NetworkProxyState {
log_policy_changes(&guard.config, &new_state.config);
new_state.blocked = guard.blocked.clone();
new_state.blocked_total = guard.blocked_total;
*guard = new_state;
self.install_config(&mut guard, new_state);
}
let source = self.reloader.source_label();
info!("reloaded config from {source}");