Scope MITM credential substitution to trusted origins and placeholders (#56003)

## Why

Credential substitution needs to authorize the destination and the original request before exposing host secrets. Project configuration must not redirect trusted credential policies, and older clients must reject scoped policies rather than silently apply unconditional injection.

## What changed

- Add hook `schemes` and `ports` constraints and an exact whole-header `placeholder` for secret substitution. Require `network.mitm.version = 2` for these options in permission profiles.
- Restrict placeholder substitution to HTTPS on port 443 and reject missing, mismatched, or duplicate placeholder headers.
- Reject project changes to trusted MITM definitions and their inheritance chains. Allow self-contained project header-stripping policies only when no trusted MITM policy exists.
- Reject ambiguous query values, value-constrained headers, and semicolon paths during authorization, and preserve case-sensitive extension methods.
- Mark resolved secret headers sensitive, reject secret injection into `User-Agent`, restrict substitution headers, and limit secret files to 64 KiB.

## Testing

Add regression coverage for scoped TOML resolution, legacy-format rejection, unknown fields, project policy restrictions, origin and placeholder denials, ambiguous request matching, secret redaction, and hook application after credential brokerage.

GitOrigin-RevId: 78dc115ab6c88c9a734377626429bc4840731f89
This commit is contained in:
viyatb-oai
2026-10-21 22:42:48 +00:00
committed by copyberry
parent 6203846fb3
commit 82ef0672e3
17 changed files with 899 additions and 98 deletions
+32
View File
@@ -202,6 +202,38 @@ pub(super) async fn load_local_config_layers_with_overrides(
append_legacy_config_layers(&mut config_layers, loaded_managed, &codex_home)?;
// Local config reads preserve raw executor paths. Only the profile ancestry and
// presence of MITM rules are needed here, before schema-specific path resolution.
let mut managed_profiles = TomlValue::Table(Default::default());
for layer in &requirements.layers {
if let Some(permissions) = layer.toml.get("permissions") {
merge_toml_values(&mut managed_profiles, permissions);
}
}
super::validate_project_mitm_profiles(
config_layers
.iter()
.map(|layer| (&layer.source, &layer.toml)),
managed_profiles
.as_table()
.into_iter()
.flatten()
.filter(|(name, _)| name.as_str() != "filesystem")
.map(|(name, profile)| {
(
name.clone(),
profile
.get("extends")
.and_then(TomlValue::as_str)
.map(str::to_owned),
profile
.get("network")
.and_then(|network| network.get("mitm"))
.is_some(),
)
}),
)?;
Ok(LocalConfigLayers {
config: LocalTomlLayerStack {
layers: config_layers,
@@ -262,6 +262,187 @@ async fn managed_project_discovery_uses_managed_project_trust() -> anyhow::Resul
Ok(())
}
#[tokio::test]
async fn project_cannot_define_or_rebind_credentials_via_permission_profiles() -> anyhow::Result<()>
{
let fixture = Fixture::new()?;
let trusted = r#"
[permissions.bridge]
[permissions.vault.network.mitm]
version = 2
[[permissions.vault.network.mitm.actions.token.inject_request_headers]]
name = "authorization"
secret_env_var = "HOST_ONLY_TOKEN"
placeholder = "dummy"
[permissions.vault.network.mitm.hooks.authorized]
host = "private.example"
methods = ["POST"]
path_prefixes = ["/upload/"]
action = ["token"]
"#;
std::fs::write(
fixture.home.join("config.toml"),
format!("{}{trusted}", fixture.trust("trusted")),
)?;
let managed = r#"
[permissions.documents]
extends = "bridge"
[permissions.documents.network.mitm]
version = 2
[permissions.documents.network.mitm.hooks.public]
host = "public.example"
methods = ["POST"]
path_prefixes = ["/"]
action = ["token"]
"#;
std::fs::write(fixture.managed.with_file_name("requirements.toml"), managed)?;
let project_config = fixture.cwd.join(".codex/config.toml");
let attacker_hook = trusted
.replace("permissions.vault", "permissions.project")
.replace("private.example", "attacker.example");
for (project, message) in [
(attacker_hook.as_str(), "permissions.project.network.mitm"),
(
r#"[permissions.vault.network.mitm.hooks.authorized]
host = "attacker.example"
methods = ["POST"]
path_prefixes = ["/upload/"]
action = ["token"]"#,
"permissions.vault.network.mitm",
),
(
r#"[permissions.bridge]
extends = "vault""#,
"inheritance of MITM profile bridge",
),
] {
std::fs::write(&project_config, project)?;
let canonical = load_config_layers_state(
&TestFileSystem,
&fixture.home,
Some(fixture.cwd.clone()),
&[],
fixture.overrides.clone(),
&NoopThreadConfigLoader,
)
.await
.unwrap_err();
let local = load_local_config_layers_with_overrides(
&TestFileSystem,
&fixture.home,
&fixture.cwd,
&fixture.overrides,
)
.await
.unwrap_err();
assert!(canonical.to_string().contains(message), "{canonical}");
assert!(local.to_string().contains(message), "{local}");
}
std::fs::write(
&project_config,
r#"
[permissions.project]
extends = "vault"
[permissions.project.network.domains]
"other.example" = "deny"
"#,
)?;
let (canonical, _) = fixture.load().await?;
let profiles: crate::permissions_toml::PermissionsToml =
canonical.effective_config()["permissions"]
.clone()
.try_into()?;
let resolved = profiles.resolve_profile("project", |_| None)?;
let network = resolved
.network
.expect("inherited network")
.to_network_proxy_config();
assert_eq!(network.mitm_hooks.len(), 1);
assert_eq!(network.mitm_hooks[0].host, "private.example");
assert_eq!(
network.denied_domains(),
Some(vec!["other.example".to_string()])
);
Ok(())
}
#[tokio::test]
async fn project_strip_hooks_respect_trusted_broker_gates() -> anyhow::Result<()> {
let fixture = Fixture::new()?;
std::fs::write(
fixture.home.join("config.toml"),
format!(
"{}\n[features.network_proxy]\nenabled = true\ncredential_broker = true\n",
fixture.trust("trusted")
),
)?;
let file = fixture.cwd.join(".codex/config.toml");
let strip = r#"
[permissions.project.network.mitm.actions.redact]
strip_request_headers = ["x-debug"]
[permissions.project.network.mitm.hooks.redact]
host = "api.github.com"
methods = ["GET"]
path_prefixes = ["/"]
action = ["redact"]
"#;
std::fs::write(&file, "[permissions.project.network.mitm]")?;
fixture.load().await?;
std::fs::write(&file, strip)?;
let (canonical, local) = fixture.load().await?;
let mut local_config = TomlValue::Table(Default::default());
for layer in local.config.layers {
merge_toml_values(&mut local_config, &layer.toml);
}
for config in [canonical.effective_config(), local_config] {
let profiles: crate::permissions_toml::PermissionsToml =
config["permissions"].clone().try_into()?;
let resolved = profiles.resolve_profile("project", |_| None)?;
let hooks = resolved
.network
.unwrap()
.to_network_proxy_config()
.mitm_hooks;
assert_eq!(hooks.len(), 1);
assert_eq!(
hooks[0].actions.strip_request_headers,
vec!["x-debug".to_string()]
);
assert!(hooks[0].actions.inject_request_headers.is_empty());
}
let unresolved = strip.replace("action = [\"redact\"]", "action = [\"unknown\"]");
std::fs::write(&file, unresolved)?;
let error = fixture.load().await.unwrap_err();
assert!(
error
.to_string()
.contains("permissions.project.network.mitm")
);
// Even a trusted strip-only hook gates the broker on other aliases of its provider.
let managed = strip.replace("permissions.project", "permissions.managed");
std::fs::write(fixture.managed.with_file_name("requirements.toml"), managed)?;
std::fs::write(&file, strip.replace("api.github.com", "github.com"))?;
let canonical = fixture.load().await.unwrap_err();
let local = load_local_config_layers_with_overrides(
&TestFileSystem,
&fixture.home,
&fixture.cwd,
&fixture.overrides,
)
.await
.unwrap_err();
for message in [canonical.to_string(), local.to_string()] {
assert!(
message.contains("permissions.project.network.mitm"),
"{message}"
);
}
Ok(())
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn managed_project_discovery_preserves_remapped_provider_bindings() -> anyhow::Result<()> {
+148
View File
@@ -1171,6 +1171,154 @@ fn project_layer_entry(
entry.with_hooks_config_folder_override(hooks_config_folder_override)
}
// A project strip-only policy can only narrow broker access when no trusted hook gates it.
// Use a separate action schema so future credential operations cannot enter this exception.
fn self_contained_project_strip_policy(mitm: &TomlValue) -> bool {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StripAction {
strip_request_headers: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StripPolicy {
#[serde(default)]
actions: std::collections::BTreeMap<String, StripAction>,
#[serde(default)]
hooks: std::collections::BTreeMap<String, crate::permissions_toml::NetworkMitmHookToml>,
}
let Ok(policy) = mitm.clone().try_into::<StripPolicy>() else {
return false;
};
policy.actions.values().all(|action| {
!action.strip_request_headers.is_empty()
&& action
.strip_request_headers
.iter()
.all(|name| !name.is_empty())
}) && policy.hooks.values().all(|hook| {
hook.schemes.is_empty()
&& hook.ports.is_empty()
&& hook.body.is_none()
&& !hook.action.is_empty()
&& hook
.action
.iter()
.all(|name| policy.actions.contains_key(name))
})
}
// A trusted repository can configure its sandbox, but cannot bind host credentials to a
// new destination. Compare the actual layer merge with the same inputs minus project layers.
pub(crate) fn validate_project_mitm_profiles<'a>(
layers: impl Iterator<Item = (&'a ConfigLayerSource, &'a TomlValue)>,
managed: impl Iterator<Item = (String, Option<String>, bool)>,
) -> io::Result<()> {
let layers: Vec<_> = layers
.filter_map(|(source, config)| Some((source, config.get("permissions")?)))
.collect();
if !layers
.iter()
.any(|(source, _)| matches!(source, ConfigLayerSource::Project { .. }))
{
return Ok(());
}
let mut trusted = TomlValue::Table(Default::default());
let mut effective = trusted.clone();
for (source, permissions) in layers {
let overlay = TomlValue::Table(toml::map::Map::from_iter([(
"permissions".to_string(),
permissions.clone(),
)]));
if !matches!(source, ConfigLayerSource::Project { .. }) {
merge_toml_values(&mut trusted, &overlay);
}
merge_toml_values(&mut effective, &overlay);
}
let trusted = trusted.get("permissions").and_then(TomlValue::as_table);
let effective = effective.get("permissions").and_then(TomlValue::as_table);
let managed = managed.collect::<Vec<_>>();
let trusted_mitm = trusted.into_iter().flatten().any(|(_, profile)| {
profile
.get("network")
.and_then(|network| network.get("mitm"))
.is_some()
});
let has_trusted_mitm = trusted_mitm || managed.iter().any(|(_, _, has_mitm)| *has_mitm);
let profile_names = trusted
.into_iter()
.chain(effective)
.flat_map(|profiles| profiles.keys())
.collect::<std::collections::BTreeSet<_>>();
let mut credential_profiles = Vec::new();
let mut trusted_parents = std::collections::BTreeMap::new();
let mut effective_parents = std::collections::BTreeMap::new();
for name in profile_names {
let original = trusted.and_then(|profiles| profiles.get(name));
let merged = effective.and_then(|profiles| profiles.get(name));
let original_mitm = original.and_then(|profile| profile.get("network")?.get("mitm"));
let merged_mitm = merged.and_then(|profile| profile.get("network")?.get("mitm"));
let original_hooks = original_mitm.and_then(|mitm| mitm.get("hooks")?.as_table());
let merged_hooks = merged_mitm.and_then(|mitm| mitm.get("hooks")?.as_table());
let changed = original_mitm != merged_mitm
|| original_hooks
.into_iter()
.flat_map(|hooks| hooks.keys())
.ne(merged_hooks.into_iter().flat_map(|hooks| hooks.keys()));
if changed
&& (has_trusted_mitm || !merged_mitm.is_some_and(self_contained_project_strip_policy))
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("project configuration cannot change permissions.{name}.network.mitm"),
));
}
if original_mitm.is_some() {
credential_profiles.push(name.clone());
}
trusted_parents.insert(
name.clone(),
original.and_then(|profile| profile.get("extends")).cloned(),
);
effective_parents.insert(
name.clone(),
merged.and_then(|profile| profile.get("extends")).cloned(),
);
}
// Managed profiles can inherit configured parents. Their own definitions take
// precedence, and Core separately rejects colliding managed and configured names.
for (name, parent, has_mitm) in managed {
if has_mitm {
credential_profiles.push(name.clone());
}
let parent = parent.map(TomlValue::String);
trusted_parents.insert(name.clone(), parent.clone());
effective_parents.insert(name, parent);
}
let mut visited = std::collections::BTreeSet::new();
while let Some(name) = credential_profiles.pop() {
if !visited.insert(name.clone()) {
continue;
}
let trusted_parent = trusted_parents.get(&name).and_then(Option::as_ref);
let effective_parent = effective_parents.get(&name).and_then(Option::as_ref);
if trusted_parent != effective_parent {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("project configuration cannot change inheritance of MITM profile {name}"),
));
}
if let Some(parent) = trusted_parent.and_then(TomlValue::as_str) {
credential_profiles.push(parent.to_string());
}
}
Ok(())
}
fn sanitize_project_config(
config: &mut TomlValue,
credential_broker: CredentialBrokerProjectState,
+36 -3
View File
@@ -350,6 +350,10 @@ pub struct NetworkToml {
#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmToml {
/// Set to 2 when using origin-scoped hooks or credential substitution.
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 2, max = 2))]
pub version: Option<u8>,
#[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
#[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]
@@ -359,13 +363,18 @@ pub struct NetworkMitmToml {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkMitmTomlUnchecked {
version: Option<u8>,
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
#[serde(deny_unknown_fields)]
pub struct NetworkMitmHookToml {
#[serde(default)]
pub schemes: Vec<String>,
#[serde(default)]
pub ports: Vec<u16>,
pub host: String,
pub methods: Vec<String>,
pub path_prefixes: Vec<String>,
@@ -386,15 +395,16 @@ enum NetworkModeSchema {
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct NetworkMitmActionToml {
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
#[serde(default, deny_unknown_fields)]
pub struct NetworkMitmInjectedHeaderToml {
pub placeholder: Option<String>,
pub name: String,
pub secret_env_var: Option<String>,
pub secret_file: Option<String>,
@@ -412,6 +422,7 @@ impl<'de> Deserialize<'de> for NetworkMitmToml {
{
let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?;
let mitm = Self {
version: unchecked.version,
hooks: unchecked.hooks,
actions: unchecked.actions,
};
@@ -423,8 +434,20 @@ impl<'de> Deserialize<'de> for NetworkMitmToml {
impl NetworkMitmToml {
pub fn validate_action_definitions(&self) -> Result<(), String> {
if let Some(version) = self.version
&& version != 2
{
return Err(format!("unsupported network.mitm.version {version}"));
}
if let Some(actions) = self.actions.as_ref() {
for (action_name, action) in actions {
let uses_placeholder = action
.inject_request_headers
.iter()
.any(|header| header.placeholder.is_some());
if self.version.is_none() && uses_placeholder {
return Err("scoped MITM actions require network.mitm.version = 2".to_string());
}
if action.is_empty() {
return Err(format!(
"network.mitm.actions.{action_name} must define at least one operation"
@@ -438,6 +461,9 @@ impl NetworkMitmToml {
};
for (hook_name, hook) in hooks {
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());
}
if hook.action.is_empty() {
return Err(format!(
"network.mitm.hooks.{hook_name}.action must not be empty"
@@ -541,6 +567,8 @@ impl NetworkMitmHookToml {
MitmHookConfig {
host: self.host.clone(),
matcher: MitmHookMatchConfig {
schemes: self.schemes.clone(),
ports: self.ports.clone(),
methods: self.methods.clone(),
path_prefixes: self.path_prefixes.clone(),
query: self.query.clone(),
@@ -580,6 +608,7 @@ impl NetworkMitmHookToml {
impl NetworkMitmInjectedHeaderToml {
fn to_runtime(&self) -> InjectedHeaderConfig {
InjectedHeaderConfig {
placeholder: self.placeholder.clone(),
name: self.name.clone(),
secret_env_var: self.secret_env_var.clone(),
secret_file: self.secret_file.clone(),
@@ -600,3 +629,7 @@ pub fn overlay_network_domain_permissions(
config.upsert_domain_permission(pattern.clone(), permission, normalize_host);
}
}
#[cfg(test)]
#[path = "permissions_toml_tests.rs"]
mod tests;
@@ -0,0 +1,90 @@
//! Trusted network extensions survive TOML resolution with origin-scoped header placeholders.
use super::*;
use pretty_assertions::assert_eq;
const HEADER_ACTION: &str = r#"
version = 2
[[actions.substitute.inject_request_headers]]
name = "x-api-key"
secret_env_var = "PROXY_API_KEY"
placeholder = "dummy-key"
[hooks.bucket]
host = "bucket.s3.amazonaws.com"
schemes = ["https"]
ports = [443]
methods = ["PUT"]
path_prefixes = ["/workspace/"]
action = ["substitute"]
"#;
#[test]
fn resolves_origin_scoped_header_substitution_from_toml() {
let config: NetworkMitmToml = toml::from_str(HEADER_ACTION).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]);
assert_eq!(
hooks[0].actions.inject_request_headers[0]
.placeholder
.as_deref(),
Some("dummy-key")
);
let wire = serde_json::to_value(&hooks).unwrap();
let decoded: Vec<MitmHookConfig> = serde_json::from_value(wire).unwrap();
assert_eq!(decoded, hooks);
}
#[test]
fn scoped_rules_reject_rollback_to_the_legacy_format() {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyMitm {
#[serde(rename = "hooks")]
_hooks: toml::Value,
#[serde(rename = "actions")]
_actions: toml::Value,
}
let unversioned = HEADER_ACTION.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());
}
#[test]
fn profile_and_managed_requirements_reject_misspelled_mitm_matchers() {
let profile = HEADER_ACTION
.replace("[[actions.", "[[permissions.test.network.mitm.actions.")
.replace("[hooks.", "[permissions.test.network.mitm.hooks.");
let profile = format!("[permissions.test.network.mitm]\n{profile}");
for (key, value) in [
("header", r#"{ x-tenant = ["allowed"] }"#),
("port", "[443]"),
("scheme", r#"["https"]"#),
] {
let input = format!("{profile}\n{key} = {value}");
for error in [
toml::from_str::<crate::config_toml::ConfigToml>(&input)
.unwrap_err()
.to_string(),
toml::from_str::<crate::ConfigRequirementsToml>(&input)
.unwrap_err()
.to_string(),
] {
assert!(error.contains(&format!("unknown field `{key}`")), "{error}");
}
}
}
#[test]
fn rejects_misspelled_header_placeholder() {
let input = HEADER_ACTION.replacen("placeholder =", "unknown_placeholder =", 1);
let error = toml::from_str::<NetworkMitmToml>(&input).unwrap_err();
assert!(
error
.to_string()
.contains("unknown field `unknown_placeholder`")
);
}
+26 -2
View File
@@ -312,7 +312,7 @@ impl ConfigLayerStack {
let model_provider_requirements = Some(crate::model_provider_requirements::to_config(
&requirements_toml,
)?);
Ok(Self {
let stack = Self {
cloud_config_binding: None,
mcp_ema_cancellation: crate::McpEmaCancellation::default(),
model_provider_requirements,
@@ -322,7 +322,31 @@ impl ConfigLayerStack {
ignore_user_and_project_exec_policy_rules: false,
startup_warnings: None,
is_projectless: false,
})
};
stack.validate_project_mitm_profiles()?;
Ok(stack)
}
/// Recheck project provenance after combining layer snapshots as well as on initial load.
pub fn validate_project_mitm_profiles(&self) -> std::io::Result<()> {
crate::loader::validate_project_mitm_profiles(
self.layers_low_to_high()
.map(|layer| (&layer.name, &layer.config)),
self.requirements_toml
.permissions
.iter()
.flat_map(|permissions| permissions.profiles.iter())
.map(|(name, profile)| {
(
name.clone(),
profile.extends.clone(),
profile
.network
.as_ref()
.is_some_and(|network| network.mitm.is_some()),
)
}),
)
}
pub fn with_user_and_project_exec_policy_rules_ignored(
+29
View File
@@ -3118,6 +3118,7 @@
"type": "object"
},
"NetworkMitmActionToml": {
"additionalProperties": false,
"properties": {
"inject_request_headers": {
"default": [],
@@ -3171,6 +3172,15 @@
},
"type": "array"
},
"ports": {
"default": [],
"items": {
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"type": "array"
},
"query": {
"additionalProperties": {
"items": {
@@ -3180,6 +3190,13 @@
},
"default": {},
"type": "object"
},
"schemes": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
@@ -3191,11 +3208,16 @@
"type": "object"
},
"NetworkMitmInjectedHeaderToml": {
"additionalProperties": false,
"properties": {
"name": {
"default": "",
"type": "string"
},
"placeholder": {
"default": null,
"type": "string"
},
"prefix": {
"default": null,
"type": "string"
@@ -3225,6 +3247,13 @@
"$ref": "#/definitions/NetworkMitmHookToml"
},
"type": "object"
},
"version": {
"description": "Set to 2 when using origin-scoped hooks or credential substitution.",
"format": "uint8",
"maximum": 2.0,
"minimum": 2.0,
"type": "integer"
}
},
"type": "object"
+6
View File
@@ -1534,9 +1534,12 @@ strip_request_headers = ["authorization"]
unix_sockets: None,
allow_local_binding: None,
mitm: Some(NetworkMitmToml {
version: None,
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
schemes: Vec::new(),
ports: Vec::new(),
host: "api.github.com".to_string(),
methods: vec!["POST".to_string(), "PUT".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
@@ -1615,9 +1618,12 @@ fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() {
let network = NetworkToml {
mode: Some(NetworkMode::Full),
mitm: Some(NetworkMitmToml {
version: None,
hooks: Some(IndexMap::from([(
"github_write".to_string(),
NetworkMitmHookToml {
schemes: Vec::new(),
ports: Vec::new(),
host: "api.github.com".to_string(),
methods: vec!["POST".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
+1
View File
@@ -3320,6 +3320,7 @@ impl Config {
codex_home: AbsolutePathBuf,
config_layer_stack: ConfigLayerStack,
) -> std::io::Result<Self> {
config_layer_stack.validate_project_mitm_profiles()?;
// Keep the large config-construction future off small test thread stacks.
Box::pin(async move {
if cfg.experimental_thread_store_endpoint.is_some() {
+14
View File
@@ -83,6 +83,10 @@ strip_request_headers = ["authorization"]
### 2) Run the proxy
In a Codex permission profile, set `version = 2` on `[permissions.<profile>.network.mitm]`
before using scoped hooks or credential substitution. Older clients reject this table key,
so rolling back cannot silently enable unconditional header injection.
The proxy can also run without a full Codex permissions profile. Put the network policy in a
standalone JSON file:
@@ -269,3 +273,13 @@ what it can reasonably guarantee.
- DNS rebinding is hard to fully prevent without pinning the resolved IP(s) all the way down to the
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
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.
@@ -18,7 +18,7 @@ fn is_safe_segment_for_authorization(segment: &str) -> bool {
decoded_dots += 1;
index += 1;
}
b'\\' => return false,
b'\\' | b';' => return false,
b'%' => {
let Some(high) = bytes
.get(index + 1)
@@ -34,7 +34,7 @@ fn is_safe_segment_for_authorization(segment: &str) -> bool {
};
let decoded = high << 4 | low;
match decoded {
b'%' | b'/' | b'\\' => return false,
b'%' | b'/' | b'\\' | b';' => return false,
b'.' => decoded_dots += 1,
_ => has_non_dot = true,
}
@@ -35,12 +35,16 @@ fn rejects_paths_with_ambiguous_segments_or_encodings() {
"/openai/openai/%",
"/openai/openai/%2",
"/openai/openai/%zz",
"/openai/openai/file;parameter",
"/openai/openai/file%3bparameter",
"/openai/openai/file%3Bparameter",
];
assert_eq!(
paths.map(is_safe_for_authorization),
[
false, false, false, false, false, false, false, false, false, false, false
false, false, false, false, false, false, false, false, false, false, false, false,
false, false
]
);
}
+9 -1
View File
@@ -936,8 +936,16 @@ async fn http_plain_proxy(
};
// Evaluate the original Authorization header before replacing brokered dummy credentials.
// Otherwise an unhooked provider alias can receive the real credential without hook policy.
let origin = crate::mitm_hook::MitmHookOrigin {
scheme: req
.uri()
.scheme()
.cloned()
.unwrap_or(rama_http::uri::Scheme::HTTP),
port,
};
match app_state
.evaluate_mitm_hook_request(&host, &req, &credential_destinations)
.evaluate_mitm_hook_request(&host, &req, &origin, &credential_destinations)
.await
{
Ok(HookEvaluation::HookedHostNoMatch) => {
+35 -9
View File
@@ -78,6 +78,20 @@ struct MitmPolicyContext {
}
impl MitmPolicyContext {
fn placeholders_match(&self, req: &Request, actions: &MitmHookActions) -> bool {
actions.inject_request_headers.iter().all(|header| {
header.placeholder.as_ref().is_none_or(|placeholder| {
self.scheme == Scheme::HTTPS
&& self.target_port == 443
&& req
.headers()
.get_all(&header.name)
.iter()
.eq(std::iter::once(placeholder))
})
})
}
fn credential_destination(&self, req: &Request) -> String {
let authority = authority_header_value(&self.target_host, self.target_port, &self.scheme);
let path = path_and_query(req.uri());
@@ -279,11 +293,12 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu
.authority(authority.as_str())
.path_and_query(path.as_str())
.build()?;
request_ctx
.policy
.app_state
.inject_request_credentials(&credential_destination, &mut parts.headers);
apply_mitm_hook_actions(&mut parts.headers, hook_actions.as_ref());
apply_request_credentials(
&mut parts.headers,
hook_actions.as_ref(),
request_ctx.policy.app_state.as_ref(),
&credential_destination,
);
parts
.headers
.insert(HOST, HeaderValue::from_str(&authority)?);
@@ -432,12 +447,18 @@ async fn evaluate_mitm_policy(
.evaluate_mitm_hook_request(
&policy.target_host,
req,
&crate::mitm_hook::MitmHookOrigin {
scheme: policy.scheme.clone(),
port: policy.target_port,
},
&[policy.credential_destination(req)],
)
.await?
{
HookEvaluation::Matched { actions } => Some(actions),
HookEvaluation::HookedHostNoMatch => {
HookEvaluation::Matched { actions } if policy.placeholders_match(req, &actions) => {
Some(actions)
}
HookEvaluation::Matched { .. } | HookEvaluation::HookedHostNoMatch => {
let _ = policy
.app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
@@ -493,11 +514,16 @@ async fn evaluate_mitm_policy(
Ok(MitmPolicyDecision::Allow { hook_actions })
}
fn apply_mitm_hook_actions(headers: &mut HeaderMap, actions: Option<&MitmHookActions>) {
fn apply_request_credentials(
headers: &mut HeaderMap,
actions: Option<&MitmHookActions>,
app_state: &NetworkProxyState,
credential_destination: &str,
) {
app_state.inject_request_credentials(credential_destination, headers);
let Some(actions) = actions else {
return;
};
for header_name in &actions.strip_request_headers {
headers.remove(header_name);
}
+220 -29
View File
@@ -17,6 +17,7 @@ use serde::Serialize;
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::io::Read as _;
use std::path::Path;
use url::form_urlencoded;
@@ -36,6 +37,8 @@ pub struct MitmHookConfig {
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(default)]
pub struct MitmHookMatchConfig {
pub schemes: Vec<String>,
pub ports: Vec<u16>,
pub methods: Vec<String>,
pub path_prefixes: Vec<String>,
pub query: BTreeMap<String, Vec<String>>,
@@ -53,6 +56,8 @@ pub struct MitmHookActionsConfig {
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(default)]
pub struct InjectedHeaderConfig {
/// Replace exactly this entire guest header value. Requires HTTPS on port 443.
pub placeholder: Option<String>,
pub name: String,
pub secret_env_var: Option<String>,
pub secret_file: Option<String>,
@@ -72,6 +77,8 @@ pub struct MitmHook {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MitmHookMatcher {
pub schemes: Vec<String>,
pub ports: Vec<u16>,
pub methods: Vec<String>,
pub path_prefixes: Vec<PathMatcher>,
pub query: Vec<QueryConstraint>,
@@ -99,6 +106,7 @@ pub struct MitmHookActions {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedInjectedHeader {
pub placeholder: Option<HeaderValue>,
pub name: HeaderName,
pub value: HeaderValue,
pub source: SecretSource,
@@ -162,6 +170,12 @@ impl CompiledGlobMatcher {
pub type MitmHooksByHost = BTreeMap<String, Vec<MitmHook>>;
/// The authenticated tunnel destination, independent of guest HTTP authority headers.
pub(crate) struct MitmHookOrigin {
pub scheme: rama_http::uri::Scheme,
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookEvaluation {
NoHooksForHost,
@@ -183,6 +197,14 @@ pub(crate) fn validate_mitm_hook_config(config: &NetworkProxyConfig) -> Result<(
let host = normalize_hook_host(&hook.host)
.with_context(|| format!("invalid network.mitm_hooks[{hook_index}].host"))?;
if hook.matcher.schemes.iter().any(|scheme| {
!scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https")
}) || hook.matcher.ports.contains(&0)
{
return Err(anyhow!(
"MITM hook schemes must be HTTP/HTTPS and ports must be nonzero"
));
}
let methods = normalize_methods(&hook.matcher.methods)
.with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.methods"))?;
if methods.is_empty() {
@@ -234,9 +256,14 @@ pub(crate) fn compile_mitm_hooks(config: &NetworkProxyConfig) -> Result<MitmHook
config,
|name| env::var(name).ok(),
|path| {
let value = fs::read_to_string(path.as_path()).with_context(|| {
format!("failed to read secret file {}", path.as_path().display())
})?;
let mut value = String::new();
fs::File::open(path.as_path())?
.take(65_537)
.read_to_string(&mut value)
.map_err(|_| anyhow!("failed to read proxy secret file"))?;
if value.len() > 65_536 {
return Err(anyhow!("proxy secret file exceeds 64 KiB"));
}
Ok(value.trim().to_string())
},
)
@@ -246,6 +273,7 @@ pub(crate) fn evaluate_mitm_hooks(
hooks_by_host: &MitmHooksByHost,
host: &str,
req: &Request,
origin: &MitmHookOrigin,
) -> HookEvaluation {
let normalized_host = normalize_host(host);
let Some(hooks) = hooks_by_host.get(&normalized_host) else {
@@ -253,7 +281,7 @@ pub(crate) fn evaluate_mitm_hooks(
};
for hook in hooks {
if hook_matches(hook, req) {
if hook_matches(hook, req, origin) {
return HookEvaluation::Matched {
actions: hook.actions.clone(),
};
@@ -323,6 +351,13 @@ where
.push(MitmHook {
host,
matcher: MitmHookMatcher {
schemes: hook
.matcher
.schemes
.iter()
.map(|scheme| scheme.to_ascii_lowercase())
.collect(),
ports: hook.matcher.ports.clone(),
methods,
path_prefixes,
query,
@@ -371,23 +406,38 @@ where
};
let prefix = header.prefix.clone().unwrap_or_default();
let value = HeaderValue::from_str(&format!("{prefix}{secret}"))
let mut value = HeaderValue::from_str(&format!("{prefix}{secret}"))
.with_context(|| format!("invalid value for injected header {}", header.name))?;
value.set_sensitive(true);
Ok(ResolvedInjectedHeader {
placeholder: header
.placeholder
.as_ref()
.map(|value| HeaderValue::from_str(value))
.transpose()?,
name,
value,
source,
})
}
fn hook_matches(hook: &MitmHook, req: &Request) -> bool {
let method = req.method().as_str().to_ascii_uppercase();
fn hook_matches(hook: &MitmHook, req: &Request, origin: &MitmHookOrigin) -> bool {
if (!hook.matcher.schemes.is_empty()
&& !hook
.matcher
.schemes
.iter()
.any(|scheme| scheme == origin.scheme.as_str()))
|| (!hook.matcher.ports.is_empty() && !hook.matcher.ports.contains(&origin.port))
{
return false;
}
if !hook
.matcher
.methods
.iter()
.any(|allowed| allowed == &method)
.any(|allowed| allowed.as_str() == req.method().as_str())
{
return false;
}
@@ -420,34 +470,38 @@ fn query_matches(query_constraints: &[QueryConstraint], req: &Request) -> bool {
query_constraints.iter().all(|constraint| {
actual_values.get(&constraint.name).is_some_and(|actual| {
actual.iter().any(|candidate| {
constraint
.allowed_values
.iter()
.any(|allowed| allowed.matches(candidate))
})
// Origins disagree on which occurrence wins. Authorize only a single value.
let [candidate] = actual.as_slice() else {
return false;
};
constraint
.allowed_values
.iter()
.any(|allowed| allowed.matches(candidate))
})
})
}
fn headers_match(header_constraints: &[HeaderConstraint], req: &Request) -> bool {
header_constraints.iter().all(|constraint| {
let actual = req.headers().get_all(&constraint.name);
if actual.iter().next().is_none() {
let values = req.headers().get_all(&constraint.name);
let mut actual = values.iter();
let Some(value) = actual.next() else {
return false;
}
};
if constraint.allowed_values.is_empty() {
return true;
}
actual.iter().any(|value| {
value.to_str().ok().is_some_and(|candidate| {
constraint
.allowed_values
.iter()
.any(|allowed| allowed.matches(candidate))
actual.next().is_none()
&& value.to_str().ok().is_some_and(|candidate| {
constraint.allowed_values.iter().any(|allowed| {
// A field line can combine multiple logical values. A glob must not
// consume the comma and accidentally authorize a second list member.
(!candidate.contains(',') || matches!(allowed, ValueMatcher::Exact(_)))
&& allowed.matches(candidate)
})
})
})
})
}
@@ -550,11 +604,20 @@ fn normalize_methods(methods: &[String]) -> Result<Vec<String>> {
methods
.iter()
.map(|method| {
let normalized = method.trim().to_ascii_uppercase();
if normalized.is_empty() {
let method = method.trim();
if method.is_empty() {
return Err(anyhow!("methods must not contain empty entries"));
}
Ok(normalized)
// Keep existing case-insensitive configuration for standard methods. Extension
// methods have case-sensitive names that must match what the origin receives.
let standard = [
"GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH",
];
Ok(standard
.into_iter()
.find(|name| name.eq_ignore_ascii_case(method))
.unwrap_or(method)
.to_string())
})
.collect()
}
@@ -601,7 +664,27 @@ fn validate_strip_request_headers(header_names: &[String]) -> Result<()> {
fn validate_injected_headers(headers: &[InjectedHeaderConfig]) -> Result<()> {
for header in headers {
let _ = parse_header_name(&header.name)?;
let name = parse_header_name(&header.name)?;
// Rama records User-Agent as a string in its upstream connection spans.
if name == rama_http::header::USER_AGENT {
return Err(anyhow!("proxy secrets cannot be injected into User-Agent"));
}
if let Some(placeholder) = &header.placeholder {
if placeholder.is_empty()
|| matches!(
name.as_str(),
"host"
| "content-length"
| "transfer-encoding"
| "connection"
| "trailer"
| "upgrade"
)
{
return Err(anyhow!("invalid secret substitution header or placeholder"));
}
HeaderValue::from_str(placeholder)?;
}
match (
header.secret_env_var.as_deref(),
header.secret_file.as_deref(),
@@ -643,6 +726,34 @@ fn parse_secret_file(path: &str) -> Result<AbsolutePathBuf> {
#[cfg(test)]
mod tests {
#[test]
fn rejects_secret_user_agent_before_loading_the_source() {
for placeholder in [None, Some("guest".to_string())] {
let header = InjectedHeaderConfig {
name: "User-Agent".to_string(),
secret_env_var: Some("PROXY_SECRET".to_string()),
placeholder,
..Default::default()
};
assert!(validate_injected_headers(&[header]).is_err());
}
}
fn evaluate_mitm_hooks(
hooks: &super::MitmHooksByHost,
host: &str,
request: &rama_http::Request,
) -> super::HookEvaluation {
super::evaluate_mitm_hooks(
hooks,
host,
request,
&super::MitmHookOrigin {
scheme: rama_http::uri::Scheme::HTTPS,
port: 443,
},
)
}
use super::*;
use crate::NetworkMode;
use crate::config::NetworkProxyConfig;
@@ -670,6 +781,7 @@ mod tests {
actions: MitmHookActionsConfig {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: vec![InjectedHeaderConfig {
placeholder: None,
name: "authorization".to_string(),
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
secret_file: None,
@@ -785,7 +897,8 @@ mod tests {
fn evaluate_returns_first_matching_hook() {
let mut config = base_config();
let mut first = github_hook();
first.matcher.path_prefixes = vec!["/repos/openai/".to_string()];
first.matcher.schemes = vec!["https".to_string()];
first.matcher.ports = vec![443];
let mut second = github_hook();
second.actions.inject_request_headers[0].prefix = Some("Token ".to_string());
config.mitm_hooks = vec![first, second];
@@ -812,6 +925,22 @@ mod tests {
actions.inject_request_headers[0].value,
HeaderValue::from_static("Bearer abc")
);
assert!(!format!("{actions:?}").contains("Bearer abc"));
for (scheme, port) in [
(rama_http::uri::Scheme::HTTP, 443),
(rama_http::uri::Scheme::HTTPS, 8443),
] {
let origin = MitmHookOrigin { scheme, port };
let HookEvaluation::Matched { actions } =
super::evaluate_mitm_hooks(&hooks, "api.github.com", &req, &origin)
else {
panic!("expected the second hook");
};
assert_eq!(
actions.inject_request_headers[0].value,
HeaderValue::from_static("Token abc")
);
}
}
#[test]
@@ -849,6 +978,68 @@ mod tests {
);
}
#[test]
fn credential_hooks_reject_ambiguous_query_and_header_values() {
let mut config = base_config();
let mut hook = github_hook();
hook.matcher
.query
.insert("tenant".into(), vec!["good".into()]);
hook.matcher
.headers
.insert("x-tenant".into(), vec!["pattern:good*".into()]);
hook.matcher.methods = vec!["post".into(), "ReadItem".into()];
hook.actions.inject_request_headers[0].placeholder = Some("dummy".into());
config.mitm_hooks = vec![hook];
let hooks = compile_mitm_hooks_with_resolvers(
&config,
|_| Some("host-secret".to_string()),
|_| Err(anyhow!("unexpected file lookup")),
)
.unwrap();
for (query, tenants, allowed) in [
("tenant=good", &["good"][..], true),
("tenant=bad&tenant=good", &["good"][..], false),
("tenant=good&tenant=bad", &["good"][..], false),
("tenant=good&%74enant=bad", &["good"][..], false),
("tenant=good", &["good", "bad"][..], false),
("tenant=good", &["bad", "good"][..], false),
("tenant=good", &["good, bad"][..], false),
] {
let mut req = Request::builder()
.method(Method::POST)
.uri(format!("/repos/openai/issues?{query}"))
.header("authorization", "dummy");
for &tenant in tenants {
req = req.header("x-tenant", tenant);
}
let mut req = req.body(Body::empty()).unwrap();
assert_eq!(
matches!(
evaluate_mitm_hooks(&hooks, "api.github.com", &req),
HookEvaluation::Matched { .. }
),
allowed,
"{query} {tenants:?}"
);
if allowed {
for (method, matched) in [("post", false), ("ReadItem", true), ("READITEM", false)]
{
*req.method_mut() = Method::from_bytes(method.as_bytes()).unwrap();
assert_eq!(
matches!(
evaluate_mitm_hooks(&hooks, "api.github.com", &req),
HookEvaluation::Matched { .. }
),
matched,
"{method}"
);
}
}
}
}
#[test]
fn evaluate_matches_wildcard_path_query_and_header_constraints() {
let mut config = base_config();
+63 -50
View File
@@ -31,6 +31,7 @@ fn github_write_hook() -> crate::mitm_hook::MitmHookConfig {
actions: crate::mitm_hook::MitmHookActionsConfig {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: vec![crate::mitm_hook::InjectedHeaderConfig {
placeholder: None,
name: "authorization".to_string(),
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
secret_file: None,
@@ -200,6 +201,7 @@ async fn mitm_policy_allows_matching_hooked_write_in_full_mode() {
let secret_file = NamedTempFile::new().unwrap();
std::fs::write(secret_file.path(), "ghp-secret\n").unwrap();
let mut hook = github_write_hook();
hook.actions.inject_request_headers[0].placeholder = Some("guest".into());
hook.actions.inject_request_headers[0].secret_env_var = None;
hook.actions.inject_request_headers[0].secret_file =
Some(secret_file.path().display().to_string());
@@ -221,6 +223,7 @@ async fn mitm_policy_allows_matching_hooked_write_in_full_mode() {
.method(Method::POST)
.uri("/repos/openai/codex/issues")
.header(HOST, "api.github.com")
.header("authorization", "guest")
.body(Body::empty())
.unwrap();
@@ -492,10 +495,11 @@ async fn mitm_policy_blocks_matching_hooked_write_in_limited_mode() {
}
#[tokio::test]
async fn mitm_policy_blocks_hook_miss_for_hooked_host_and_records_telemetry_in_full_mode() {
async fn mitm_policy_records_hook_denials_in_full_mode() {
let secret_file = NamedTempFile::new().unwrap();
std::fs::write(secret_file.path(), "ghp-secret\n").unwrap();
let mut hook = github_write_hook();
hook.actions.inject_request_headers[0].placeholder = Some("guest".into());
hook.actions.inject_request_headers[0].secret_env_var = None;
hook.actions.inject_request_headers[0].secret_file =
Some(secret_file.path().display().to_string());
@@ -507,70 +511,79 @@ async fn mitm_policy_blocks_hook_miss_for_hooked_host_and_records_telemetry_in_f
};
network.set_allowed_domains(vec!["api.github.com".to_string()]);
let app_state = Arc::new(network_proxy_state_for_policy(network));
let ctx = policy_ctx(
app_state.clone(),
NetworkMode::Full,
"api.github.com",
/*target_port*/ 443,
);
let req = Request::builder()
.method(Method::GET)
.uri("/repos/openai/codex/issues?token=secret")
.header(HOST, "api.github.com")
.header("authorization", "Bearer user-supplied")
.body(Body::empty())
.unwrap();
for (method, scheme, port, values) in [
(Method::GET, Scheme::HTTPS, 443, &["guest"][..]),
(Method::POST, Scheme::HTTPS, 443, &[]),
(Method::POST, Scheme::HTTPS, 443, &["wrong"]),
(Method::POST, Scheme::HTTPS, 443, &["guest", "guest"]),
(Method::POST, Scheme::HTTP, 80, &["guest"]),
(Method::POST, Scheme::HTTPS, 8443, &["guest"]),
] {
let mut ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "api.github.com", port);
ctx.scheme = scheme;
let mut req = Request::builder()
.method(method.as_str())
.uri("/repos/openai/codex/issues?token=secret")
.header(HOST, "api.github.com");
for value in values {
req = req.header("authorization", *value);
}
let req = req.body(Body::empty()).unwrap();
let response = mitm_blocking_response(&req, &ctx).await.unwrap().unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(response.headers()["x-proxy-error"], "blocked-by-mitm-hook");
let response = mitm_blocking_response(&req, &ctx)
.await
.unwrap()
.expect("hook miss should be blocked");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-mitm-hook"
);
let blocked = app_state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_MITM_HOOK_DENIED);
assert_eq!(blocked[0].method.as_deref(), Some("GET"));
assert_eq!(blocked[0].host, "api.github.com");
assert_eq!(blocked[0].port, Some(443));
let blocked = app_state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_MITM_HOOK_DENIED);
assert_eq!(blocked[0].method.as_deref(), Some(method.as_str()));
assert_eq!(blocked[0].host, "api.github.com");
assert_eq!(blocked[0].port, Some(port));
}
}
#[test]
fn apply_mitm_hook_actions_replaces_authorization_header() {
fn request_credentials_apply_hook_after_brokerage() {
let app_state = network_proxy_state_for_policy(NetworkProxyConfig {
credential_broker: true,
mitm: true,
..NetworkProxyConfig::default()
});
let mut env = HashMap::from([("GH_TOKEN".to_string(), "ghp-real".to_string())]);
app_state.virtualize_child_credentials(&mut env);
assert_ne!(env["GH_TOKEN"], "ghp-real");
let placeholder = HeaderValue::from_str(&format!("Bearer {}", env["GH_TOKEN"])).unwrap();
let mut headers = HeaderMap::new();
headers.append(
HeaderName::from_static("authorization"),
HeaderValue::from_static("Bearer user-supplied"),
);
headers.append(
HeaderName::from_static("x-request-id"),
HeaderValue::from_static("req_123"),
headers.insert("authorization", placeholder.clone());
headers.insert("x-request-id", HeaderValue::from_static("req_123"));
let destination = "https://api.github.com/";
let mut expected = headers.clone();
let mut brokered = headers.clone();
apply_request_credentials(
&mut brokered,
/*actions*/ None,
&app_state,
destination,
);
expected.insert("authorization", HeaderValue::from_static("Bearer ghp-real"));
assert_eq!(brokered, expected);
let mut secret = HeaderValue::from_static("Bearer secret-token");
secret.set_sensitive(true);
let actions = crate::mitm_hook::MitmHookActions {
strip_request_headers: vec![HeaderName::from_static("authorization")],
inject_request_headers: vec![crate::mitm_hook::ResolvedInjectedHeader {
placeholder: Some(placeholder),
name: HeaderName::from_static("authorization"),
value: HeaderValue::from_static("Bearer secret-token"),
value: secret.clone(),
source: crate::mitm_hook::SecretSource::File(
AbsolutePathBuf::try_from("/tmp/github-token").unwrap(),
),
}],
};
apply_mitm_hook_actions(&mut headers, Some(&actions));
assert_eq!(
headers.get("authorization"),
Some(&HeaderValue::from_static("Bearer secret-token"))
);
assert_eq!(
headers.get("x-request-id"),
Some(&HeaderValue::from_static("req_123"))
);
expected.insert("authorization", secret);
apply_request_credentials(&mut headers, Some(&actions), &app_state, destination);
assert_eq!(headers, expected);
assert!(headers["authorization"].is_sensitive());
}
+2 -1
View File
@@ -1051,12 +1051,13 @@ impl NetworkProxyState {
&self,
host: &str,
req: &rama_http::Request,
origin: &crate::mitm_hook::MitmHookOrigin,
credential_destinations: &[String],
) -> Result<HookEvaluation> {
self.reload_if_needed().await?;
let hooked_hosts = {
let guard = self.state.read().await;
match evaluate_mitm_hooks(&guard.mitm_hooks, host, req) {
match evaluate_mitm_hooks(&guard.mitm_hooks, host, req, origin) {
HookEvaluation::NoHooksForHost if !guard.mitm_hooks.is_empty() => {
guard.mitm_hooks.keys().cloned().collect::<Vec<_>>()
}