mirror of
https://github.com/openai/codex.git
synced 2026-03-25 16:13:56 +00:00
Compare commits
1 Commits
stack/util
...
codex/viya
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d16c7aa41c |
@@ -670,6 +670,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"InjectedHeaderConfigSchema": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
"prefix": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"secret_env_var": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"secret_file": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MemoriesToml": {
|
||||
"additionalProperties": false,
|
||||
"description": "Memories settings loaded from config.toml.",
|
||||
@@ -724,6 +745,101 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MitmHookActionsConfigSchema": {
|
||||
"properties": {
|
||||
"inject_request_headers": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/InjectedHeaderConfigSchema"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"strip_request_headers": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MitmHookConfigSchema": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MitmHookActionsConfigSchema"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"inject_request_headers": [],
|
||||
"strip_request_headers": []
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
"match": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MitmHookMatchConfigSchema"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"body": null,
|
||||
"headers": {},
|
||||
"methods": [],
|
||||
"path_prefixes": [],
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MitmHookMatchConfigSchema": {
|
||||
"properties": {
|
||||
"body": {
|
||||
"default": null
|
||||
},
|
||||
"headers": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"default": {},
|
||||
"type": "object"
|
||||
},
|
||||
"methods": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"path_prefixes": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"query": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"default": {},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModelAvailabilityNuxConfig": {
|
||||
"additionalProperties": {
|
||||
"format": "uint32",
|
||||
@@ -869,6 +985,15 @@
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mitm": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mitm_hooks": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MitmHookConfigSchema"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"mode": {
|
||||
"$ref": "#/definitions/NetworkModeSchema"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,11 @@ use crate::config_loader::RequirementSource;
|
||||
use crate::features::Feature;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_network_proxy::InjectedHeaderConfig;
|
||||
use codex_network_proxy::MitmHookActionsConfig;
|
||||
use codex_network_proxy::MitmHookConfig;
|
||||
use codex_network_proxy::MitmHookMatchConfig;
|
||||
use codex_network_proxy::NetworkMode;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
@@ -217,7 +222,27 @@ enabled = true
|
||||
proxy_url = "http://127.0.0.1:43128"
|
||||
enable_socks5 = false
|
||||
allow_upstream_proxy = false
|
||||
mode = "full"
|
||||
mitm = true
|
||||
allowed_domains = ["openai.com"]
|
||||
|
||||
[[permissions.workspace.network.mitm_hooks]]
|
||||
host = "api.github.com"
|
||||
|
||||
[permissions.workspace.network.mitm_hooks.match]
|
||||
methods = ["POST", "PUT"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
|
||||
[permissions.workspace.network.mitm_hooks.match.headers]
|
||||
"x-github-api-version" = ["2022-11-28"]
|
||||
|
||||
[permissions.workspace.network.mitm_hooks.actions]
|
||||
strip_request_headers = ["authorization"]
|
||||
|
||||
[[permissions.workspace.network.mitm_hooks.actions.inject_request_headers]]
|
||||
name = "authorization"
|
||||
secret_env_var = "CODEX_GITHUB_TOKEN"
|
||||
prefix = "Bearer "
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
|
||||
@@ -253,11 +278,34 @@ allowed_domains = ["openai.com"]
|
||||
allow_upstream_proxy: Some(false),
|
||||
dangerously_allow_non_loopback_proxy: None,
|
||||
dangerously_allow_all_unix_sockets: None,
|
||||
mode: None,
|
||||
mode: Some(NetworkMode::Full),
|
||||
allowed_domains: Some(vec!["openai.com".to_string()]),
|
||||
denied_domains: None,
|
||||
allow_unix_sockets: None,
|
||||
allow_local_binding: None,
|
||||
mitm: Some(true),
|
||||
mitm_hooks: Some(vec![MitmHookConfig {
|
||||
host: "api.github.com".to_string(),
|
||||
matcher: MitmHookMatchConfig {
|
||||
methods: vec!["POST".to_string(), "PUT".to_string()],
|
||||
path_prefixes: vec!["/repos/openai/".to_string()],
|
||||
query: BTreeMap::new(),
|
||||
headers: BTreeMap::from([(
|
||||
"x-github-api-version".to_string(),
|
||||
vec!["2022-11-28".to_string()],
|
||||
)]),
|
||||
body: None,
|
||||
},
|
||||
actions: MitmHookActionsConfig {
|
||||
strip_request_headers: vec!["authorization".to_string()],
|
||||
inject_request_headers: vec![InjectedHeaderConfig {
|
||||
name: "authorization".to_string(),
|
||||
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
|
||||
secret_file: None,
|
||||
prefix: Some("Bearer ".to_string()),
|
||||
}],
|
||||
},
|
||||
}]),
|
||||
}),
|
||||
},
|
||||
)]),
|
||||
@@ -265,6 +313,40 @@ allowed_domains = ["openai.com"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() {
|
||||
let network = NetworkToml {
|
||||
enabled: Some(true),
|
||||
mode: Some(NetworkMode::Full),
|
||||
mitm: Some(true),
|
||||
mitm_hooks: Some(vec![MitmHookConfig {
|
||||
host: "api.github.com".to_string(),
|
||||
matcher: MitmHookMatchConfig {
|
||||
methods: vec!["POST".to_string()],
|
||||
path_prefixes: vec!["/repos/openai/".to_string()],
|
||||
..MitmHookMatchConfig::default()
|
||||
},
|
||||
actions: MitmHookActionsConfig {
|
||||
strip_request_headers: vec!["authorization".to_string()],
|
||||
inject_request_headers: vec![InjectedHeaderConfig {
|
||||
name: "authorization".to_string(),
|
||||
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
|
||||
secret_file: None,
|
||||
prefix: Some("Bearer ".to_string()),
|
||||
}],
|
||||
},
|
||||
}]),
|
||||
..NetworkToml::default()
|
||||
};
|
||||
|
||||
let config = network.to_network_proxy_config();
|
||||
|
||||
assert!(config.network.enabled);
|
||||
assert_eq!(config.network.mode, NetworkMode::Full);
|
||||
assert!(config.network.mitm);
|
||||
assert_eq!(config.network.mitm_hooks, network.mitm_hooks.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_profiles_network_populates_runtime_network_proxy_spec() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_network_proxy::MitmHookConfig;
|
||||
use codex_network_proxy::NetworkMode;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
@@ -73,6 +74,9 @@ pub struct NetworkToml {
|
||||
pub denied_domains: Option<Vec<String>>,
|
||||
pub allow_unix_sockets: Option<Vec<String>>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
pub mitm: Option<bool>,
|
||||
#[schemars(with = "Option<Vec<MitmHookConfigSchema>>")]
|
||||
pub mitm_hooks: Option<Vec<MitmHookConfig>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -82,6 +86,46 @@ enum NetworkModeSchema {
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(default)]
|
||||
struct MitmHookConfigSchema {
|
||||
pub host: String,
|
||||
#[serde(rename = "match", default)]
|
||||
pub matcher: MitmHookMatchConfigSchema,
|
||||
#[serde(default)]
|
||||
pub actions: MitmHookActionsConfigSchema,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(default)]
|
||||
struct MitmHookMatchConfigSchema {
|
||||
pub methods: Vec<String>,
|
||||
pub path_prefixes: Vec<String>,
|
||||
pub query: BTreeMap<String, Vec<String>>,
|
||||
pub headers: BTreeMap<String, Vec<String>>,
|
||||
pub body: Option<MitmHookBodyConfigSchema>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(default)]
|
||||
struct MitmHookActionsConfigSchema {
|
||||
pub strip_request_headers: Vec<String>,
|
||||
pub inject_request_headers: Vec<InjectedHeaderConfigSchema>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(default)]
|
||||
struct InjectedHeaderConfigSchema {
|
||||
pub name: String,
|
||||
pub secret_env_var: Option<String>,
|
||||
pub secret_file: Option<String>,
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(transparent)]
|
||||
struct MitmHookBodyConfigSchema(pub serde_json::Value);
|
||||
|
||||
impl NetworkToml {
|
||||
pub(crate) fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
|
||||
if let Some(enabled) = self.enabled {
|
||||
@@ -126,6 +170,12 @@ impl NetworkToml {
|
||||
if let Some(allow_local_binding) = self.allow_local_binding {
|
||||
config.network.allow_local_binding = allow_local_binding;
|
||||
}
|
||||
if let Some(mitm) = self.mitm {
|
||||
config.network.mitm = mitm;
|
||||
}
|
||||
if let Some(mitm_hooks) = self.mitm_hooks.as_ref() {
|
||||
config.network.mitm_hooks = mitm_hooks.clone();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_network_proxy_config(&self) -> NetworkProxyConfig {
|
||||
|
||||
@@ -319,7 +319,16 @@ mod tests {
|
||||
default_permissions = "workspace"
|
||||
|
||||
[permissions.workspace.network]
|
||||
mode = "full"
|
||||
mitm = true
|
||||
allowed_domains = ["lower.example.com"]
|
||||
|
||||
[[permissions.workspace.network.mitm_hooks]]
|
||||
host = "lower.example.com"
|
||||
|
||||
[permissions.workspace.network.mitm_hooks.match]
|
||||
methods = ["POST"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
"#,
|
||||
)
|
||||
.expect("lower layer should parse");
|
||||
@@ -328,7 +337,16 @@ allowed_domains = ["lower.example.com"]
|
||||
default_permissions = "workspace"
|
||||
|
||||
[permissions.workspace.network]
|
||||
mode = "full"
|
||||
mitm = true
|
||||
allowed_domains = ["higher.example.com"]
|
||||
|
||||
[[permissions.workspace.network.mitm_hooks]]
|
||||
host = "api.github.com"
|
||||
|
||||
[permissions.workspace.network.mitm_hooks.match]
|
||||
methods = ["PUT"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
"#,
|
||||
)
|
||||
.expect("higher layer should parse");
|
||||
@@ -346,6 +364,11 @@ allowed_domains = ["higher.example.com"]
|
||||
.expect("higher layer should apply");
|
||||
|
||||
assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]);
|
||||
assert_eq!(config.network.mode, codex_network_proxy::NetworkMode::Full);
|
||||
assert!(config.network.mitm);
|
||||
assert_eq!(config.network.mitm_hooks.len(), 1);
|
||||
assert_eq!(config.network.mitm_hooks[0].host, "api.github.com");
|
||||
assert_eq!(config.network.mitm_hooks[0].matcher.methods, vec!["PUT"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
- an HTTP proxy (default `127.0.0.1:3128`)
|
||||
- a SOCKS5 proxy (default `127.0.0.1:8081`, enabled by default)
|
||||
|
||||
It enforces an allow/deny policy and a "limited" mode intended for read-only network access.
|
||||
It enforces an allow/deny policy, a "limited" mode intended for read-only network access, and
|
||||
host-specific HTTPS MITM hooks for request matching and header injection.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -32,8 +33,9 @@ allow_upstream_proxy = true
|
||||
# By default, non-loopback binds are clamped to loopback for safety.
|
||||
# If you want to expose these listeners beyond localhost, you must opt in explicitly.
|
||||
dangerously_allow_non_loopback_proxy = false
|
||||
mode = "full" # default when unset; use "limited" for read-only mode
|
||||
# When true, HTTPS CONNECT can be terminated so limited-mode method policy still applies.
|
||||
mode = "full" # default when unset; hooks can still clamp specific HTTPS hosts
|
||||
# When true, HTTPS CONNECT can be terminated so limited-mode policy and host-specific MITM hooks
|
||||
# can inspect inner requests.
|
||||
mitm = false
|
||||
# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key).
|
||||
|
||||
@@ -54,6 +56,27 @@ allow_unix_sockets = ["/tmp/example.sock"]
|
||||
# DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any
|
||||
# absolute socket path from `x-unix-socket`.
|
||||
dangerously_allow_all_unix_sockets = false
|
||||
|
||||
[[permissions.workspace.network.mitm_hooks]]
|
||||
host = "api.github.com"
|
||||
|
||||
[permissions.workspace.network.mitm_hooks.match]
|
||||
methods = ["POST", "PUT"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
|
||||
[permissions.workspace.network.mitm_hooks.match.headers]
|
||||
"x-github-api-version" = ["2022-11-28"]
|
||||
|
||||
[permissions.workspace.network.mitm_hooks.actions]
|
||||
strip_request_headers = ["authorization"]
|
||||
|
||||
[[permissions.workspace.network.mitm_hooks.actions.inject_request_headers]]
|
||||
name = "authorization"
|
||||
secret_env_var = "CODEX_GITHUB_TOKEN"
|
||||
prefix = "Bearer "
|
||||
|
||||
# `match.body` is reserved for a future release. Current hooks match only on
|
||||
# method/path/query/headers and can mutate outbound request headers.
|
||||
```
|
||||
|
||||
### 2) Run the proxy
|
||||
@@ -86,12 +109,15 @@ When a request is blocked, the proxy responds with `403` and includes:
|
||||
- `x-proxy-error`: one of:
|
||||
- `blocked-by-allowlist`
|
||||
- `blocked-by-denylist`
|
||||
- `blocked-by-mitm-hook`
|
||||
- `blocked-by-method-policy`
|
||||
- `blocked-by-policy`
|
||||
|
||||
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` requests require
|
||||
MITM to enforce limited-mode method policy; otherwise they are blocked. SOCKS5 remains blocked in
|
||||
limited mode.
|
||||
MITM to enforce limited-mode method policy; otherwise they are blocked. Separately, hosts covered
|
||||
by `mitm_hooks` are authoritative even when `mode = "full"`: hooks are evaluated in order, the
|
||||
first match wins, and if no hook matches the inner HTTPS request is denied with
|
||||
`blocked-by-mitm-hook`. SOCKS5 remains blocked in limited mode.
|
||||
|
||||
Websocket clients typically tunnel `wss://` through HTTPS `CONNECT`; those CONNECT targets still go
|
||||
through the same host allowlist/denylist checks.
|
||||
@@ -195,9 +221,12 @@ what it can reasonably guarantee.
|
||||
and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`)
|
||||
is required to permit them; hostnames that resolve to local/private IPs are still blocked even if
|
||||
allowlisted (best-effort DNS lookup).
|
||||
- Limited mode enforcement:
|
||||
- only `GET`, `HEAD`, and `OPTIONS` are allowed
|
||||
- HTTPS `CONNECT` remains a tunnel; limited-mode method enforcement does not apply to HTTPS
|
||||
- HTTPS enforcement:
|
||||
- in limited mode, only `GET`, `HEAD`, and `OPTIONS` are allowed
|
||||
- when `mitm = true`, HTTPS inner requests can be matched by `mitm_hooks` and have request
|
||||
headers rewritten before forwarding
|
||||
- hooked hosts are authoritative even in `mode = "full"`: if a host has `mitm_hooks` and none
|
||||
match, the request is denied
|
||||
- Listener safety defaults:
|
||||
- the HTTP proxy listener clamps non-loopback binds unless explicitly enabled via
|
||||
`dangerously_allow_non_loopback_proxy`
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::path::Path;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
use crate::mitm_hook::MitmHookConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct NetworkProxyConfig {
|
||||
#[serde(default)]
|
||||
@@ -43,6 +45,8 @@ pub struct NetworkProxySettings {
|
||||
pub allow_local_binding: bool,
|
||||
#[serde(default)]
|
||||
pub mitm: bool,
|
||||
#[serde(default)]
|
||||
pub mitm_hooks: Vec<MitmHookConfig>,
|
||||
}
|
||||
|
||||
impl Default for NetworkProxySettings {
|
||||
@@ -62,6 +66,7 @@ impl Default for NetworkProxySettings {
|
||||
allow_unix_sockets: Vec::new(),
|
||||
allow_local_binding: false,
|
||||
mitm: false,
|
||||
mitm_hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,8 +78,8 @@ pub enum NetworkMode {
|
||||
/// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests.
|
||||
/// SOCKS5 remains blocked in limited mode.
|
||||
Limited,
|
||||
/// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without
|
||||
/// MITM interception.
|
||||
/// Full network access: all HTTP methods are allowed. HTTPS CONNECTs are tunneled directly
|
||||
/// unless MITM is needed for host-specific inner-request hooks.
|
||||
#[default]
|
||||
Full,
|
||||
}
|
||||
@@ -376,6 +381,7 @@ mod tests {
|
||||
allow_unix_sockets: Vec::new(),
|
||||
allow_local_binding: false,
|
||||
mitm: false,
|
||||
mitm_hooks: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ConnectMitmEnabled(bool);
|
||||
|
||||
pub async fn run_http_proxy(
|
||||
state: Arc<NetworkProxyState>,
|
||||
addr: SocketAddr,
|
||||
@@ -249,10 +252,18 @@ async fn http_connect_accept(
|
||||
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
|
||||
}
|
||||
};
|
||||
let host_has_mitm_hooks = match app_state.host_has_mitm_hooks(&host).await {
|
||||
Ok(has_hooks) => has_hooks,
|
||||
Err(err) => {
|
||||
error!("failed to inspect MITM hooks for {host}: {err}");
|
||||
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
|
||||
}
|
||||
};
|
||||
let connect_needs_mitm = mode == NetworkMode::Limited || host_has_mitm_hooks;
|
||||
|
||||
if mode == NetworkMode::Limited && mitm_state.is_none() {
|
||||
// Limited mode is designed to be read-only. Without MITM, a CONNECT tunnel would hide the
|
||||
// inner HTTP method/headers from the proxy, effectively bypassing method policy.
|
||||
if connect_needs_mitm && mitm_state.is_none() {
|
||||
// CONNECT needs MITM whenever HTTPS policy depends on inner-request inspection, either for
|
||||
// limited-mode method enforcement or for host-specific MITM hooks.
|
||||
emit_http_block_decision_audit_event(
|
||||
&app_state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
@@ -279,7 +290,7 @@ async fn http_connect_accept(
|
||||
reason: REASON_MITM_REQUIRED.to_string(),
|
||||
client: client.clone(),
|
||||
method: Some("CONNECT".to_string()),
|
||||
mode: Some(NetworkMode::Limited),
|
||||
mode: Some(mode),
|
||||
protocol: "http-connect".to_string(),
|
||||
decision: Some(details.decision.as_str().to_string()),
|
||||
source: Some(details.source.as_str().to_string()),
|
||||
@@ -288,14 +299,16 @@ async fn http_connect_accept(
|
||||
.await;
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
warn!(
|
||||
"CONNECT blocked; MITM required for read-only HTTPS in limited mode (client={client}, host={host}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)"
|
||||
"CONNECT blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode={mode:?}, hooked_host={host_has_mitm_hooks})"
|
||||
);
|
||||
return Err(blocked_text_with_details(REASON_MITM_REQUIRED, &details));
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(ProxyTarget(authority));
|
||||
req.extensions_mut()
|
||||
.insert(ConnectMitmEnabled(connect_needs_mitm));
|
||||
req.extensions_mut().insert(mode);
|
||||
if let Some(mitm_state) = mitm_state {
|
||||
if connect_needs_mitm && let Some(mitm_state) = mitm_state {
|
||||
req.extensions_mut().insert(mitm_state);
|
||||
}
|
||||
|
||||
@@ -324,7 +337,10 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if mode == NetworkMode::Limited
|
||||
if upgraded
|
||||
.extensions()
|
||||
.get::<ConnectMitmEnabled>()
|
||||
.is_some_and(|enabled| enabled.0)
|
||||
&& upgraded
|
||||
.extensions()
|
||||
.get::<Arc<mitm::MitmState>>()
|
||||
@@ -1024,6 +1040,40 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_connect_accept_blocks_hooked_host_in_full_mode_without_mitm_state() {
|
||||
let policy = NetworkProxySettings {
|
||||
allowed_domains: vec!["api.github.com".to_string()],
|
||||
mitm: true,
|
||||
mitm_hooks: vec![crate::mitm_hook::MitmHookConfig {
|
||||
host: "api.github.com".to_string(),
|
||||
matcher: crate::mitm_hook::MitmHookMatchConfig {
|
||||
methods: vec!["POST".to_string()],
|
||||
path_prefixes: vec!["/repos/openai/".to_string()],
|
||||
..crate::mitm_hook::MitmHookMatchConfig::default()
|
||||
},
|
||||
actions: crate::mitm_hook::MitmHookActionsConfig::default(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let state = Arc::new(network_proxy_state_for_policy(policy));
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.uri("https://api.github.com:443")
|
||||
.header("host", "api.github.com:443")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(state);
|
||||
|
||||
let response = http_connect_accept(None, req).await.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response.headers().get("x-proxy-error").unwrap(),
|
||||
"blocked-by-mitm-required"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn http_plain_proxy_blocks_unix_socket_when_method_not_allowed() {
|
||||
let state = Arc::new(network_proxy_state_for_policy(
|
||||
|
||||
@@ -4,6 +4,7 @@ mod certs;
|
||||
mod config;
|
||||
mod http_proxy;
|
||||
mod mitm;
|
||||
mod mitm_hook;
|
||||
mod network_policy;
|
||||
mod policy;
|
||||
mod proxy;
|
||||
@@ -17,6 +18,11 @@ mod upstream;
|
||||
pub use config::NetworkMode;
|
||||
pub use config::NetworkProxyConfig;
|
||||
pub use config::host_and_port_from_network_addr;
|
||||
pub use mitm_hook::InjectedHeaderConfig;
|
||||
pub use mitm_hook::MitmHookActionsConfig;
|
||||
pub use mitm_hook::MitmHookBodyConfig;
|
||||
pub use mitm_hook::MitmHookConfig;
|
||||
pub use mitm_hook::MitmHookMatchConfig;
|
||||
pub use network_policy::NetworkDecision;
|
||||
pub use network_policy::NetworkDecisionSource;
|
||||
pub use network_policy::NetworkPolicyDecider;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::certs::ManagedMitmCa;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::mitm_hook::HookEvaluation;
|
||||
use crate::mitm_hook::MitmHookActions;
|
||||
use crate::policy::normalize_host;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_MITM_HOOK_DENIED;
|
||||
use crate::responses::blocked_text_response;
|
||||
use crate::responses::text_response;
|
||||
use crate::runtime::HostBlockDecision;
|
||||
@@ -23,6 +26,7 @@ use rama_core::rt::Executor;
|
||||
use rama_core::service::service_fn;
|
||||
use rama_http::Body;
|
||||
use rama_http::BodyDataStream;
|
||||
use rama_http::HeaderMap;
|
||||
use rama_http::HeaderValue;
|
||||
use rama_http::Request;
|
||||
use rama_http::Response;
|
||||
@@ -66,6 +70,13 @@ struct MitmRequestContext {
|
||||
mitm: Arc<MitmState>,
|
||||
}
|
||||
|
||||
enum MitmPolicyDecision {
|
||||
Allow {
|
||||
hook_actions: Option<MitmHookActions>,
|
||||
},
|
||||
Block(Response),
|
||||
}
|
||||
|
||||
const MITM_INSPECT_BODIES: bool = false;
|
||||
const MITM_MAX_BODY_BYTES: usize = 4096;
|
||||
|
||||
@@ -81,9 +92,10 @@ impl std::fmt::Debug for MitmState {
|
||||
|
||||
impl MitmState {
|
||||
pub(crate) fn new(allow_upstream_proxy: bool) -> Result<Self> {
|
||||
// MITM exists to make limited-mode HTTPS enforceable: once CONNECT is established, plain
|
||||
// proxying would lose visibility into the inner HTTP request. We generate/load a local CA
|
||||
// and issue per-host leaf certs so we can terminate TLS and apply policy.
|
||||
// MITM exists when HTTPS policy depends on the inner request: limited-mode method clamps
|
||||
// and host-specific hooks both need visibility after CONNECT is established. We
|
||||
// generate/load a local CA and issue per-host leaf certs so we can terminate TLS and
|
||||
// apply policy.
|
||||
let ca = ManagedMitmCa::load_or_create()?;
|
||||
|
||||
let upstream = if allow_upstream_proxy {
|
||||
@@ -195,9 +207,10 @@ async fn handle_mitm_request(
|
||||
}
|
||||
|
||||
async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Result<Response> {
|
||||
if let Some(response) = mitm_blocking_response(&req, &request_ctx.policy).await? {
|
||||
return Ok(response);
|
||||
}
|
||||
let hook_actions = match evaluate_mitm_policy(&req, &request_ctx.policy).await? {
|
||||
MitmPolicyDecision::Allow { hook_actions } => hook_actions,
|
||||
MitmPolicyDecision::Block(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let target_host = request_ctx.policy.target_host.clone();
|
||||
let target_port = request_ctx.policy.target_port;
|
||||
@@ -208,6 +221,7 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu
|
||||
let log_path = path_for_log(req.uri());
|
||||
|
||||
let (mut parts, body) = req.into_parts();
|
||||
apply_mitm_hook_actions(&mut parts.headers, hook_actions.as_ref());
|
||||
let authority = authority_header_value(&target_host, target_port);
|
||||
parts.uri = build_https_uri(&authority, &path)?;
|
||||
parts
|
||||
@@ -242,12 +256,23 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
async fn mitm_blocking_response(
|
||||
req: &Request,
|
||||
policy: &MitmPolicyContext,
|
||||
) -> Result<Option<Response>> {
|
||||
match evaluate_mitm_policy(req, policy).await? {
|
||||
MitmPolicyDecision::Allow { .. } => Ok(None),
|
||||
MitmPolicyDecision::Block(response) => Ok(Some(response)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn evaluate_mitm_policy(
|
||||
req: &Request,
|
||||
policy: &MitmPolicyContext,
|
||||
) -> Result<MitmPolicyDecision> {
|
||||
if req.method().as_str() == "CONNECT" {
|
||||
return Ok(Some(text_response(
|
||||
return Ok(MitmPolicyDecision::Block(text_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"CONNECT not supported inside MITM",
|
||||
)));
|
||||
@@ -267,7 +292,7 @@ async fn mitm_blocking_response(
|
||||
"MITM host mismatch (target={}, request_host={normalized})",
|
||||
policy.target_host
|
||||
);
|
||||
return Ok(Some(text_response(
|
||||
return Ok(MitmPolicyDecision::Block(text_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"host mismatch",
|
||||
)));
|
||||
@@ -302,7 +327,43 @@ async fn mitm_blocking_response(
|
||||
"MITM blocked local/private target after CONNECT (host={}, port={}, method={method}, path={log_path})",
|
||||
policy.target_host, policy.target_port
|
||||
);
|
||||
return Ok(Some(blocked_text_response(reason)));
|
||||
return Ok(MitmPolicyDecision::Block(blocked_text_response(reason)));
|
||||
}
|
||||
|
||||
match policy
|
||||
.app_state
|
||||
.evaluate_mitm_hook_request(&policy.target_host, req)
|
||||
.await?
|
||||
{
|
||||
HookEvaluation::Matched { actions } => {
|
||||
return Ok(MitmPolicyDecision::Allow {
|
||||
hook_actions: Some(actions),
|
||||
});
|
||||
}
|
||||
HookEvaluation::HookedHostNoMatch => {
|
||||
let _ = policy
|
||||
.app_state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
host: policy.target_host.clone(),
|
||||
reason: REASON_MITM_HOOK_DENIED.to_string(),
|
||||
client: client.clone(),
|
||||
method: Some(method.clone()),
|
||||
mode: Some(policy.mode),
|
||||
protocol: "https".to_string(),
|
||||
decision: None,
|
||||
source: None,
|
||||
port: Some(policy.target_port),
|
||||
}))
|
||||
.await;
|
||||
warn!(
|
||||
"MITM blocked by hook policy (host={}, method={method}, mode={:?})",
|
||||
policy.target_host, policy.mode
|
||||
);
|
||||
return Ok(MitmPolicyDecision::Block(blocked_text_response(
|
||||
REASON_MITM_HOOK_DENIED,
|
||||
)));
|
||||
}
|
||||
HookEvaluation::NoHooksForHost => {}
|
||||
}
|
||||
|
||||
if !policy.mode.allows_method(&method) {
|
||||
@@ -324,10 +385,25 @@ async fn mitm_blocking_response(
|
||||
"MITM blocked by method policy (host={}, method={method}, path={log_path}, mode={:?}, allowed_methods=GET, HEAD, OPTIONS)",
|
||||
policy.target_host, policy.mode
|
||||
);
|
||||
return Ok(Some(blocked_text_response(REASON_METHOD_NOT_ALLOWED)));
|
||||
return Ok(MitmPolicyDecision::Block(blocked_text_response(
|
||||
REASON_METHOD_NOT_ALLOWED,
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
Ok(MitmPolicyDecision::Allow { hook_actions: None })
|
||||
}
|
||||
|
||||
fn apply_mitm_hook_actions(headers: &mut HeaderMap, actions: Option<&MitmHookActions>) {
|
||||
let Some(actions) = actions else {
|
||||
return;
|
||||
};
|
||||
|
||||
for header_name in &actions.strip_request_headers {
|
||||
headers.remove(header_name);
|
||||
}
|
||||
for injected_header in &actions.inject_request_headers {
|
||||
headers.insert(injected_header.name.clone(), injected_header.value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn respond_with_inspection(
|
||||
|
||||
770
codex-rs/network-proxy/src/mitm_hook.rs
Normal file
770
codex-rs/network-proxy/src/mitm_hook.rs
Normal file
@@ -0,0 +1,770 @@
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::policy::normalize_host;
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use rama_http::HeaderValue;
|
||||
use rama_http::Request;
|
||||
use rama_http::header::HeaderName;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use url::form_urlencoded;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct MitmHookConfig {
|
||||
pub host: String,
|
||||
#[serde(rename = "match", default)]
|
||||
pub matcher: MitmHookMatchConfig,
|
||||
#[serde(default)]
|
||||
pub actions: MitmHookActionsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct MitmHookMatchConfig {
|
||||
pub methods: Vec<String>,
|
||||
pub path_prefixes: Vec<String>,
|
||||
pub query: BTreeMap<String, Vec<String>>,
|
||||
pub headers: BTreeMap<String, Vec<String>>,
|
||||
pub body: Option<MitmHookBodyConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct MitmHookActionsConfig {
|
||||
pub strip_request_headers: Vec<String>,
|
||||
pub inject_request_headers: Vec<InjectedHeaderConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct InjectedHeaderConfig {
|
||||
pub name: String,
|
||||
pub secret_env_var: Option<String>,
|
||||
pub secret_file: Option<String>,
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(transparent)]
|
||||
pub struct MitmHookBodyConfig(pub serde_json::Value);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MitmHook {
|
||||
pub host: String,
|
||||
pub matcher: MitmHookMatcher,
|
||||
pub actions: MitmHookActions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MitmHookMatcher {
|
||||
pub methods: Vec<String>,
|
||||
pub path_prefixes: Vec<String>,
|
||||
pub query: Vec<QueryConstraint>,
|
||||
pub headers: Vec<HeaderConstraint>,
|
||||
pub body: Option<MitmHookBodyMatcher>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueryConstraint {
|
||||
pub name: String,
|
||||
pub allowed_values: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HeaderConstraint {
|
||||
pub name: HeaderName,
|
||||
pub allowed_values: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MitmHookActions {
|
||||
pub strip_request_headers: Vec<HeaderName>,
|
||||
pub inject_request_headers: Vec<ResolvedInjectedHeader>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedInjectedHeader {
|
||||
pub name: HeaderName,
|
||||
pub value: HeaderValue,
|
||||
pub source: SecretSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SecretSource {
|
||||
EnvVar(String),
|
||||
File(AbsolutePathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MitmHookBodyMatcher {
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
pub type MitmHooksByHost = BTreeMap<String, Vec<MitmHook>>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HookEvaluation {
|
||||
NoHooksForHost,
|
||||
Matched { actions: MitmHookActions },
|
||||
HookedHostNoMatch,
|
||||
}
|
||||
|
||||
pub(crate) fn validate_mitm_hook_config(config: &NetworkProxyConfig) -> Result<()> {
|
||||
let hooks = &config.network.mitm_hooks;
|
||||
if hooks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !config.network.mitm {
|
||||
return Err(anyhow!("network.mitm_hooks requires network.mitm = true"));
|
||||
}
|
||||
|
||||
for (hook_index, hook) in hooks.iter().enumerate() {
|
||||
let host = normalize_hook_host(&hook.host)
|
||||
.with_context(|| format!("invalid network.mitm_hooks[{hook_index}].host"))?;
|
||||
|
||||
let methods = normalize_methods(&hook.matcher.methods)
|
||||
.with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.methods"))?;
|
||||
if methods.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"network.mitm_hooks[{hook_index}].match.methods must not be empty"
|
||||
));
|
||||
}
|
||||
|
||||
let path_prefixes =
|
||||
normalize_path_prefixes(&hook.matcher.path_prefixes).with_context(|| {
|
||||
format!("invalid network.mitm_hooks[{hook_index}].match.path_prefixes")
|
||||
})?;
|
||||
if path_prefixes.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"network.mitm_hooks[{hook_index}].match.path_prefixes must not be empty"
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(body) = hook.matcher.body.as_ref() {
|
||||
let _ = body;
|
||||
return Err(anyhow!(
|
||||
"network.mitm_hooks[{hook_index}].match.body is reserved for a future release and is not yet supported"
|
||||
));
|
||||
}
|
||||
|
||||
validate_query_constraints(&hook.matcher.query)
|
||||
.with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.query"))?;
|
||||
validate_header_constraints(&hook.matcher.headers)
|
||||
.with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.headers"))?;
|
||||
validate_strip_request_headers(&hook.actions.strip_request_headers).with_context(|| {
|
||||
format!("invalid network.mitm_hooks[{hook_index}].actions.strip_request_headers")
|
||||
})?;
|
||||
validate_injected_headers(&hook.actions.inject_request_headers).with_context(|| {
|
||||
format!("invalid network.mitm_hooks[{hook_index}].actions.inject_request_headers")
|
||||
})?;
|
||||
|
||||
if host.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"network.mitm_hooks[{hook_index}].host must not be empty"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn compile_mitm_hooks(config: &NetworkProxyConfig) -> Result<MitmHooksByHost> {
|
||||
compile_mitm_hooks_with_resolvers(
|
||||
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())
|
||||
})?;
|
||||
Ok(value.trim().to_string())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn evaluate_mitm_hooks(
|
||||
hooks_by_host: &MitmHooksByHost,
|
||||
host: &str,
|
||||
req: &Request,
|
||||
) -> HookEvaluation {
|
||||
let normalized_host = normalize_host(host);
|
||||
let Some(hooks) = hooks_by_host.get(&normalized_host) else {
|
||||
return HookEvaluation::NoHooksForHost;
|
||||
};
|
||||
|
||||
for hook in hooks {
|
||||
if hook_matches(hook, req) {
|
||||
return HookEvaluation::Matched {
|
||||
actions: hook.actions.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
HookEvaluation::HookedHostNoMatch
|
||||
}
|
||||
|
||||
fn compile_mitm_hooks_with_resolvers<EnvFn, FileFn>(
|
||||
config: &NetworkProxyConfig,
|
||||
resolve_env_var: EnvFn,
|
||||
read_secret_file: FileFn,
|
||||
) -> Result<MitmHooksByHost>
|
||||
where
|
||||
EnvFn: Fn(&str) -> Option<String>,
|
||||
FileFn: Fn(&AbsolutePathBuf) -> Result<String>,
|
||||
{
|
||||
validate_mitm_hook_config(config)?;
|
||||
|
||||
let mut hooks_by_host = MitmHooksByHost::new();
|
||||
for hook in &config.network.mitm_hooks {
|
||||
let host = normalize_hook_host(&hook.host)?;
|
||||
let methods = normalize_methods(&hook.matcher.methods)?;
|
||||
let path_prefixes = normalize_path_prefixes(&hook.matcher.path_prefixes)?;
|
||||
let query = hook
|
||||
.matcher
|
||||
.query
|
||||
.iter()
|
||||
.map(|(name, values)| {
|
||||
Ok(QueryConstraint {
|
||||
name: normalize_query_name(name)?,
|
||||
allowed_values: values.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let headers = hook
|
||||
.matcher
|
||||
.headers
|
||||
.iter()
|
||||
.map(|(name, values)| {
|
||||
Ok(HeaderConstraint {
|
||||
name: parse_header_name(name)?,
|
||||
allowed_values: values.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let strip_request_headers = hook
|
||||
.actions
|
||||
.strip_request_headers
|
||||
.iter()
|
||||
.map(|name| parse_header_name(name))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let inject_request_headers = hook
|
||||
.actions
|
||||
.inject_request_headers
|
||||
.iter()
|
||||
.map(|header| {
|
||||
compile_injected_header(header, &resolve_env_var, &read_secret_file)
|
||||
.with_context(|| format!("failed to compile injected header {}", header.name))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
hooks_by_host
|
||||
.entry(host.clone())
|
||||
.or_default()
|
||||
.push(MitmHook {
|
||||
host,
|
||||
matcher: MitmHookMatcher {
|
||||
methods,
|
||||
path_prefixes,
|
||||
query,
|
||||
headers,
|
||||
body: None,
|
||||
},
|
||||
actions: MitmHookActions {
|
||||
strip_request_headers,
|
||||
inject_request_headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Ok(hooks_by_host)
|
||||
}
|
||||
|
||||
fn compile_injected_header<EnvFn, FileFn>(
|
||||
header: &InjectedHeaderConfig,
|
||||
resolve_env_var: &EnvFn,
|
||||
read_secret_file: &FileFn,
|
||||
) -> Result<ResolvedInjectedHeader>
|
||||
where
|
||||
EnvFn: Fn(&str) -> Option<String>,
|
||||
FileFn: Fn(&AbsolutePathBuf) -> Result<String>,
|
||||
{
|
||||
let name = parse_header_name(&header.name)?;
|
||||
let (secret, source) = match (
|
||||
header.secret_env_var.as_deref(),
|
||||
header.secret_file.as_deref(),
|
||||
) {
|
||||
(Some(env_var), None) => {
|
||||
let value = resolve_env_var(env_var)
|
||||
.ok_or_else(|| anyhow!("missing required environment variable {env_var}"))?;
|
||||
(value, SecretSource::EnvVar(env_var.to_string()))
|
||||
}
|
||||
(None, Some(secret_file)) => {
|
||||
let path = parse_secret_file(secret_file)?;
|
||||
let value = read_secret_file(&path)?;
|
||||
(value, SecretSource::File(path))
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"expected exactly one of secret_env_var or secret_file"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let prefix = header.prefix.clone().unwrap_or_default();
|
||||
let value = HeaderValue::from_str(&format!("{prefix}{secret}"))
|
||||
.with_context(|| format!("invalid value for injected header {}", header.name))?;
|
||||
|
||||
Ok(ResolvedInjectedHeader {
|
||||
name,
|
||||
value,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn hook_matches(hook: &MitmHook, req: &Request) -> bool {
|
||||
let method = req.method().as_str().to_ascii_uppercase();
|
||||
if !hook
|
||||
.matcher
|
||||
.methods
|
||||
.iter()
|
||||
.any(|allowed| allowed == &method)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let path = req.uri().path();
|
||||
if !hook
|
||||
.matcher
|
||||
.path_prefixes
|
||||
.iter()
|
||||
.any(|prefix| path.starts_with(prefix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !query_matches(&hook.matcher.query, req) {
|
||||
return false;
|
||||
}
|
||||
|
||||
headers_match(&hook.matcher.headers, req)
|
||||
}
|
||||
|
||||
fn query_matches(query_constraints: &[QueryConstraint], req: &Request) -> bool {
|
||||
if query_constraints.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let actual_query = req.uri().query().unwrap_or_default();
|
||||
let mut actual_values: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for (name, value) in form_urlencoded::parse(actual_query.as_bytes()) {
|
||||
actual_values
|
||||
.entry(name.into_owned())
|
||||
.or_default()
|
||||
.push(value.into_owned());
|
||||
}
|
||||
|
||||
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 == 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() {
|
||||
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 == candidate)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_hook_host(host: &str) -> Result<String> {
|
||||
let normalized = normalize_host(host);
|
||||
if normalized.is_empty() {
|
||||
return Err(anyhow!("host must not be empty"));
|
||||
}
|
||||
if normalized.contains('*') {
|
||||
return Err(anyhow!(
|
||||
"MITM hook hosts must be exact hosts and cannot contain wildcards"
|
||||
));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_methods(methods: &[String]) -> Result<Vec<String>> {
|
||||
methods
|
||||
.iter()
|
||||
.map(|method| {
|
||||
let normalized = method.trim().to_ascii_uppercase();
|
||||
if normalized.is_empty() {
|
||||
return Err(anyhow!("methods must not contain empty entries"));
|
||||
}
|
||||
Ok(normalized)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_path_prefixes(path_prefixes: &[String]) -> Result<Vec<String>> {
|
||||
path_prefixes
|
||||
.iter()
|
||||
.map(|prefix| {
|
||||
if prefix.is_empty() {
|
||||
return Err(anyhow!("path_prefixes must not contain empty entries"));
|
||||
}
|
||||
Ok(prefix.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_query_constraints(query: &BTreeMap<String, Vec<String>>) -> Result<()> {
|
||||
for (name, values) in query {
|
||||
let normalized = normalize_query_name(name)?;
|
||||
if normalized.is_empty() {
|
||||
return Err(anyhow!("query keys must not be empty"));
|
||||
}
|
||||
if values.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"query key {name:?} must list at least one allowed value"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_query_name(name: &str) -> Result<String> {
|
||||
if name.is_empty() {
|
||||
return Err(anyhow!("query keys must not be empty"));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn validate_header_constraints(headers: &BTreeMap<String, Vec<String>>) -> Result<()> {
|
||||
for name in headers.keys() {
|
||||
let _ = parse_header_name(name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_strip_request_headers(header_names: &[String]) -> Result<()> {
|
||||
for name in header_names {
|
||||
let _ = parse_header_name(name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_injected_headers(headers: &[InjectedHeaderConfig]) -> Result<()> {
|
||||
for header in headers {
|
||||
let _ = parse_header_name(&header.name)?;
|
||||
match (
|
||||
header.secret_env_var.as_deref(),
|
||||
header.secret_file.as_deref(),
|
||||
) {
|
||||
(Some(secret_env_var), None) => {
|
||||
if secret_env_var.trim().is_empty() {
|
||||
return Err(anyhow!("secret_env_var must not be empty"));
|
||||
}
|
||||
}
|
||||
(None, Some(secret_file)) => {
|
||||
let _ = parse_secret_file(secret_file)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"expected exactly one of secret_env_var or secret_file"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_header_name(name: &str) -> Result<HeaderName> {
|
||||
HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|err| anyhow!("invalid header name {name:?}: {err}"))
|
||||
}
|
||||
|
||||
fn parse_secret_file(path: &str) -> Result<AbsolutePathBuf> {
|
||||
if path.trim().is_empty() {
|
||||
return Err(anyhow!("secret_file must not be empty"));
|
||||
}
|
||||
let path = Path::new(path);
|
||||
if !path.is_absolute() {
|
||||
return Err(anyhow!("secret_file must be an absolute path: {path:?}"));
|
||||
}
|
||||
AbsolutePathBuf::from_absolute_path(path)
|
||||
.with_context(|| format!("secret_file must be an absolute path: {path:?}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::NetworkMode;
|
||||
use crate::config::NetworkProxySettings;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rama_http::Body;
|
||||
use rama_http::Method;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn base_config() -> NetworkProxyConfig {
|
||||
NetworkProxyConfig {
|
||||
network: NetworkProxySettings {
|
||||
mitm: true,
|
||||
mode: NetworkMode::Limited,
|
||||
..NetworkProxySettings::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn github_hook() -> MitmHookConfig {
|
||||
MitmHookConfig {
|
||||
host: "api.github.com".to_string(),
|
||||
matcher: MitmHookMatchConfig {
|
||||
methods: vec!["POST".to_string(), "PUT".to_string()],
|
||||
path_prefixes: vec!["/repos/openai/".to_string()],
|
||||
..MitmHookMatchConfig::default()
|
||||
},
|
||||
actions: MitmHookActionsConfig {
|
||||
strip_request_headers: vec!["authorization".to_string()],
|
||||
inject_request_headers: vec![InjectedHeaderConfig {
|
||||
name: "authorization".to_string(),
|
||||
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
|
||||
secret_file: None,
|
||||
prefix: Some("Bearer ".to_string()),
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_requires_mitm_for_hooks() {
|
||||
let mut config = base_config();
|
||||
config.network.mitm = false;
|
||||
config.network.mitm_hooks = vec![github_hook()];
|
||||
|
||||
let err = validate_mitm_hook_config(&config).expect_err("hooks require mitm");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("network.mitm_hooks requires network.mitm = true")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_allows_hooks_in_full_mode() {
|
||||
let mut config = base_config();
|
||||
config.network.mode = NetworkMode::Full;
|
||||
config.network.mitm_hooks = vec![github_hook()];
|
||||
|
||||
validate_mitm_hook_config(&config).expect("hooks should be allowed in full mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_body_matchers_for_now() {
|
||||
let mut config = base_config();
|
||||
let mut hook = github_hook();
|
||||
hook.matcher.body = Some(MitmHookBodyConfig(serde_json::json!({
|
||||
"repository": "openai/codex"
|
||||
})));
|
||||
config.network.mitm_hooks = vec![hook];
|
||||
|
||||
let err = validate_mitm_hook_config(&config).expect_err("body matchers are reserved");
|
||||
assert!(err.to_string().contains("match.body is reserved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_relative_secret_file() {
|
||||
let mut config = base_config();
|
||||
let mut hook = github_hook();
|
||||
hook.actions.inject_request_headers[0].secret_env_var = None;
|
||||
hook.actions.inject_request_headers[0].secret_file = Some("token.txt".to_string());
|
||||
config.network.mitm_hooks = vec![hook];
|
||||
|
||||
let err = validate_mitm_hook_config(&config).expect_err("secret file must be absolute");
|
||||
assert!(format!("{err:#}").contains("secret_file must be an absolute path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_dual_secret_sources() {
|
||||
let mut config = base_config();
|
||||
let mut hook = github_hook();
|
||||
hook.actions.inject_request_headers[0].secret_file = Some("/tmp/github-token".to_string());
|
||||
config.network.mitm_hooks = vec![hook];
|
||||
|
||||
let err = validate_mitm_hook_config(&config).expect_err("dual secret sources invalid");
|
||||
assert!(format!("{err:#}").contains("exactly one of secret_env_var or secret_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_resolves_env_backed_injected_headers() {
|
||||
let mut config = base_config();
|
||||
config.network.mitm_hooks = vec![github_hook()];
|
||||
|
||||
let hooks = compile_mitm_hooks_with_resolvers(
|
||||
&config,
|
||||
|name| (name == "CODEX_GITHUB_TOKEN").then(|| "ghp-secret".to_string()),
|
||||
|_| Err(anyhow!("unexpected file lookup")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let compiled = hooks.get("api.github.com").unwrap();
|
||||
assert_eq!(compiled.len(), 1);
|
||||
assert_eq!(
|
||||
compiled[0].actions.inject_request_headers[0].source,
|
||||
SecretSource::EnvVar("CODEX_GITHUB_TOKEN".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
compiled[0].actions.inject_request_headers[0].value,
|
||||
HeaderValue::from_static("Bearer ghp-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_resolves_file_backed_injected_headers() {
|
||||
let secret_file = NamedTempFile::new().unwrap();
|
||||
std::fs::write(secret_file.path(), "ghp-file-secret\n").unwrap();
|
||||
|
||||
let mut config = base_config();
|
||||
let mut hook = github_hook();
|
||||
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());
|
||||
config.network.mitm_hooks = vec![hook];
|
||||
|
||||
let hooks = compile_mitm_hooks(&config).unwrap();
|
||||
let compiled = hooks.get("api.github.com").unwrap();
|
||||
assert_eq!(
|
||||
compiled[0].actions.inject_request_headers[0].value,
|
||||
HeaderValue::from_static("Bearer ghp-file-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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()];
|
||||
let mut second = github_hook();
|
||||
second.actions.inject_request_headers[0].prefix = Some("Token ".to_string());
|
||||
config.network.mitm_hooks = vec![first, second];
|
||||
|
||||
let hooks = compile_mitm_hooks_with_resolvers(
|
||||
&config,
|
||||
|_| Some("abc".to_string()),
|
||||
|_| Err(anyhow!("unexpected file lookup")),
|
||||
)
|
||||
.unwrap();
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/repos/openai/codex/issues")
|
||||
.header("x-trace", "1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let evaluation = evaluate_mitm_hooks(&hooks, "api.github.com", &req);
|
||||
let HookEvaluation::Matched { actions } = evaluation else {
|
||||
panic!("expected a matching hook");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
actions.inject_request_headers[0].value,
|
||||
HeaderValue::from_static("Bearer abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_matches_query_and_header_constraints() {
|
||||
let mut config = base_config();
|
||||
let mut hook = github_hook();
|
||||
hook.matcher.query = BTreeMap::from([(
|
||||
"state".to_string(),
|
||||
vec!["open".to_string(), "triage".to_string()],
|
||||
)]);
|
||||
hook.matcher.headers = BTreeMap::from([(
|
||||
"x-github-api-version".to_string(),
|
||||
vec!["2022-11-28".to_string()],
|
||||
)]);
|
||||
config.network.mitm_hooks = vec![hook];
|
||||
|
||||
let hooks = compile_mitm_hooks_with_resolvers(
|
||||
&config,
|
||||
|_| Some("abc".to_string()),
|
||||
|_| Err(anyhow!("unexpected file lookup")),
|
||||
)
|
||||
.unwrap();
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/repos/openai/codex/issues?state=open&per_page=10")
|
||||
.header("x-github-api-version", "2022-11-28")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
evaluate_mitm_hooks(&hooks, "api.github.com", &req),
|
||||
HookEvaluation::Matched {
|
||||
actions: hooks.get("api.github.com").unwrap()[0].actions.clone(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_returns_hooked_host_no_match_when_query_constraint_fails() {
|
||||
let mut config = base_config();
|
||||
let mut hook = github_hook();
|
||||
hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["open".to_string()])]);
|
||||
config.network.mitm_hooks = vec![hook];
|
||||
|
||||
let hooks = compile_mitm_hooks_with_resolvers(
|
||||
&config,
|
||||
|_| Some("abc".to_string()),
|
||||
|_| Err(anyhow!("unexpected file lookup")),
|
||||
)
|
||||
.unwrap();
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/repos/openai/codex/issues?state=closed")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
evaluate_mitm_hooks(&hooks, "api.github.com", &req),
|
||||
HookEvaluation::HookedHostNoMatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_returns_no_hooks_for_unconfigured_host() {
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/repos/openai/codex/issues")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
evaluate_mitm_hooks(&MitmHooksByHost::new(), "api.github.com", &req),
|
||||
HookEvaluation::NoHooksForHost
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,39 @@ use super::*;
|
||||
|
||||
use crate::config::NetworkProxySettings;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_MITM_HOOK_DENIED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
use crate::runtime::network_proxy_state_for_policy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rama_http::Body;
|
||||
use rama_http::HeaderMap;
|
||||
use rama_http::HeaderValue;
|
||||
use rama_http::Method;
|
||||
use rama_http::Request;
|
||||
use rama_http::StatusCode;
|
||||
use rama_http::header::HeaderName;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn github_write_hook() -> crate::mitm_hook::MitmHookConfig {
|
||||
crate::mitm_hook::MitmHookConfig {
|
||||
host: "api.github.com".to_string(),
|
||||
matcher: crate::mitm_hook::MitmHookMatchConfig {
|
||||
methods: vec!["POST".to_string(), "PUT".to_string()],
|
||||
path_prefixes: vec!["/repos/openai/".to_string()],
|
||||
..crate::mitm_hook::MitmHookMatchConfig::default()
|
||||
},
|
||||
actions: crate::mitm_hook::MitmHookActionsConfig {
|
||||
strip_request_headers: vec!["authorization".to_string()],
|
||||
inject_request_headers: vec![crate::mitm_hook::InjectedHeaderConfig {
|
||||
name: "authorization".to_string(),
|
||||
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
|
||||
secret_file: None,
|
||||
prefix: Some("Bearer ".to_string()),
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_ctx(
|
||||
app_state: Arc<NetworkProxyState>,
|
||||
@@ -108,3 +134,113 @@ async fn mitm_policy_rechecks_local_private_target_after_connect() {
|
||||
assert_eq!(blocked[0].host, "10.0.0.1");
|
||||
assert_eq!(blocked[0].port, Some(443));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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].secret_env_var = None;
|
||||
hook.actions.inject_request_headers[0].secret_file =
|
||||
Some(secret_file.path().display().to_string());
|
||||
let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["api.github.com".to_string()],
|
||||
mitm: true,
|
||||
mitm_hooks: vec![hook],
|
||||
mode: NetworkMode::Full,
|
||||
..NetworkProxySettings::default()
|
||||
}));
|
||||
let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "api.github.com", 443);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/repos/openai/codex/issues")
|
||||
.header(HOST, "api.github.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = mitm_blocking_response(&req, &ctx).await.unwrap();
|
||||
|
||||
assert!(
|
||||
response.is_none(),
|
||||
"matching hook should bypass method clamp"
|
||||
);
|
||||
assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mitm_policy_blocks_hook_miss_for_hooked_host_and_records_telemetry_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].secret_env_var = None;
|
||||
hook.actions.inject_request_headers[0].secret_file =
|
||||
Some(secret_file.path().display().to_string());
|
||||
let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["api.github.com".to_string()],
|
||||
mitm: true,
|
||||
mitm_hooks: vec![hook],
|
||||
mode: NetworkMode::Full,
|
||||
..NetworkProxySettings::default()
|
||||
}));
|
||||
let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "api.github.com", 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();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_mitm_hook_actions_replaces_authorization_header() {
|
||||
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"),
|
||||
);
|
||||
|
||||
let actions = crate::mitm_hook::MitmHookActions {
|
||||
strip_request_headers: vec![HeaderName::from_static("authorization")],
|
||||
inject_request_headers: vec![crate::mitm_hook::ResolvedInjectedHeader {
|
||||
name: HeaderName::from_static("authorization"),
|
||||
value: HeaderValue::from_static("Bearer secret-token"),
|
||||
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"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub(crate) const REASON_DENIED: &str = "denied";
|
||||
pub(crate) const REASON_METHOD_NOT_ALLOWED: &str = "method_not_allowed";
|
||||
pub(crate) const REASON_MITM_HOOK_DENIED: &str = "mitm_hook_denied";
|
||||
pub(crate) const REASON_MITM_REQUIRED: &str = "mitm_required";
|
||||
pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed";
|
||||
pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local";
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::network_policy::NetworkPolicyDecision;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
use crate::reasons::REASON_DENIED;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_MITM_HOOK_DENIED;
|
||||
use crate::reasons::REASON_MITM_REQUIRED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
@@ -52,6 +53,7 @@ pub fn blocked_header_value(reason: &str) -> &'static str {
|
||||
REASON_NOT_ALLOWED | REASON_NOT_ALLOWED_LOCAL => "blocked-by-allowlist",
|
||||
REASON_DENIED => "blocked-by-denylist",
|
||||
REASON_METHOD_NOT_ALLOWED => "blocked-by-method-policy",
|
||||
REASON_MITM_HOOK_DENIED => "blocked-by-mitm-hook",
|
||||
REASON_MITM_REQUIRED => "blocked-by-mitm-required",
|
||||
_ => "blocked-by-policy",
|
||||
}
|
||||
@@ -69,7 +71,12 @@ pub fn blocked_message(reason: &str) -> &'static str {
|
||||
REASON_METHOD_NOT_ALLOWED => {
|
||||
"Codex blocked this request: method not allowed in limited mode."
|
||||
}
|
||||
REASON_MITM_REQUIRED => "Codex blocked this request: MITM required for limited HTTPS.",
|
||||
REASON_MITM_HOOK_DENIED => {
|
||||
"Codex blocked this request: MITM hook policy denied the HTTPS request."
|
||||
}
|
||||
REASON_MITM_REQUIRED => {
|
||||
"Codex blocked this request: MITM required to enforce HTTPS policy."
|
||||
}
|
||||
_ => "Codex blocked this request by network policy.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ use crate::config::NetworkMode;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::config::ValidatedUnixSocketPath;
|
||||
use crate::mitm::MitmState;
|
||||
use crate::mitm_hook::HookEvaluation;
|
||||
use crate::mitm_hook::MitmHooksByHost;
|
||||
use crate::mitm_hook::evaluate_mitm_hooks;
|
||||
use crate::policy::Host;
|
||||
use crate::policy::is_loopback_host;
|
||||
use crate::policy::is_non_public_ip;
|
||||
@@ -156,6 +159,7 @@ pub struct ConfigState {
|
||||
pub allow_set: GlobSet,
|
||||
pub deny_set: GlobSet,
|
||||
pub mitm: Option<Arc<MitmState>>,
|
||||
pub mitm_hooks: MitmHooksByHost,
|
||||
pub constraints: NetworkProxyConstraints,
|
||||
pub blocked: VecDeque<BlockedRequest>,
|
||||
pub blocked_total: u64,
|
||||
@@ -558,6 +562,22 @@ impl NetworkProxyState {
|
||||
Ok(guard.mitm.clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn evaluate_mitm_hook_request(
|
||||
&self,
|
||||
host: &str,
|
||||
req: &rama_http::Request,
|
||||
) -> Result<HookEvaluation> {
|
||||
self.reload_if_needed().await?;
|
||||
let guard = self.state.read().await;
|
||||
Ok(evaluate_mitm_hooks(&guard.mitm_hooks, host, req))
|
||||
}
|
||||
|
||||
pub async fn host_has_mitm_hooks(&self, host: &str) -> Result<bool> {
|
||||
self.reload_if_needed().await?;
|
||||
let guard = self.state.read().await;
|
||||
Ok(guard.mitm_hooks.contains_key(&normalize_host(host)))
|
||||
}
|
||||
|
||||
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
|
||||
self.update_domain_list(host, DomainListKind::Allow).await
|
||||
}
|
||||
@@ -787,9 +807,17 @@ pub(crate) fn network_proxy_state_for_policy(
|
||||
mut network: crate::config::NetworkProxySettings,
|
||||
) -> NetworkProxyState {
|
||||
network.enabled = true;
|
||||
network.mode = NetworkMode::Full;
|
||||
let config = NetworkProxyConfig { network };
|
||||
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
|
||||
let state = ConfigState {
|
||||
allow_set: crate::policy::compile_globset(&config.network.allowed_domains).unwrap(),
|
||||
blocked: VecDeque::new(),
|
||||
blocked_total: 0,
|
||||
config: config.clone(),
|
||||
constraints: NetworkProxyConstraints::default(),
|
||||
deny_set: crate::policy::compile_globset(&config.network.denied_domains).unwrap(),
|
||||
mitm: None,
|
||||
mitm_hooks: crate::mitm_hook::compile_mitm_hooks(&config).unwrap(),
|
||||
};
|
||||
|
||||
NetworkProxyState::with_reloader(state, Arc::new(NoopReloader))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::config::NetworkMode;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::mitm::MitmState;
|
||||
use crate::mitm_hook::MitmHookConfig;
|
||||
use crate::mitm_hook::compile_mitm_hooks;
|
||||
use crate::mitm_hook::validate_mitm_hook_config;
|
||||
use crate::policy::DomainPattern;
|
||||
use crate::policy::compile_globset;
|
||||
use crate::policy::is_global_wildcard_domain_pattern;
|
||||
@@ -52,6 +55,10 @@ pub struct PartialNetworkConfig {
|
||||
pub allow_unix_sockets: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub allow_local_binding: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub mitm: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub mitm_hooks: Option<Vec<MitmHookConfig>>,
|
||||
}
|
||||
|
||||
pub fn build_config_state(
|
||||
@@ -65,6 +72,7 @@ pub fn build_config_state(
|
||||
.map_err(NetworkProxyConstraintError::into_anyhow)?;
|
||||
let deny_set = compile_globset(&config.network.denied_domains)?;
|
||||
let allow_set = compile_globset(&config.network.allowed_domains)?;
|
||||
let mitm_hooks = compile_mitm_hooks(&config)?;
|
||||
let mitm = if config.network.mitm {
|
||||
Some(Arc::new(MitmState::new(
|
||||
config.network.allow_upstream_proxy,
|
||||
@@ -77,6 +85,7 @@ pub fn build_config_state(
|
||||
allow_set,
|
||||
deny_set,
|
||||
mitm,
|
||||
mitm_hooks,
|
||||
constraints,
|
||||
blocked: std::collections::VecDeque::new(),
|
||||
blocked_total: 0,
|
||||
@@ -107,6 +116,7 @@ pub fn validate_policy_against_constraints(
|
||||
}
|
||||
|
||||
let enabled = config.network.enabled;
|
||||
validate_mitm_hook_config(config).map_err(invalid_mitm_hook_configuration)?;
|
||||
validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains)?;
|
||||
validate_domain_patterns("network.denied_domains", &config.network.denied_domains)?;
|
||||
if let Some(max_enabled) = constraints.enabled {
|
||||
@@ -364,6 +374,14 @@ pub fn validate_policy_against_constraints(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invalid_mitm_hook_configuration(err: anyhow::Error) -> NetworkProxyConstraintError {
|
||||
NetworkProxyConstraintError::InvalidValue {
|
||||
field_name: "network.mitm_hooks",
|
||||
candidate: err.to_string(),
|
||||
allowed: "valid MITM hook configuration".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_domain_patterns(
|
||||
field_name: &'static str,
|
||||
patterns: &[String],
|
||||
|
||||
Reference in New Issue
Block a user