mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
## What changed - Add `body_secret_substitution` actions to version 2 MITM configuration, replacing exact opaque placeholders with secrets from environment variables or files within the matching hook's scope. - Bound body size, expansion, replacement count, and search work. Reject overlapping matches, compressed payloads, trailers, and supplied payload-integrity headers. - Recompute `Content-Length` and substitute before SigV4 signing so signatures cover the transmitted body. ## Testing Add coverage for markers split across chunks, expansion limits, invalid payloads, overlapping bindings, secret redaction, and search limits. Add a TLS forwarding test that verifies hook scope and independently checks the signature of the substituted body. GitOrigin-RevId: fa95c9ae0c8fc8f695e03c0e87a0a4875d8418fd
1321 lines
44 KiB
Rust
1321 lines
44 KiB
Rust
#![cfg_attr(not(test), allow(dead_code))]
|
|
|
|
use crate::authorization_path::is_safe_for_authorization;
|
|
use crate::aws_sigv4::AwsSigV4Config;
|
|
use crate::aws_sigv4::AwsSigV4Signer;
|
|
use crate::config::NetworkProxyConfig;
|
|
use crate::policy::normalize_host;
|
|
use crate::secret_substitution::BodySecretSubstitution;
|
|
use crate::secret_substitution::BodySecretSubstitutionConfig;
|
|
use anyhow::Context as _;
|
|
use anyhow::Result;
|
|
use anyhow::anyhow;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use globset::GlobBuilder;
|
|
use globset::GlobMatcher;
|
|
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::io::Read as _;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use url::form_urlencoded;
|
|
|
|
const PATTERN_PREFIX: &str = "pattern:";
|
|
const LITERAL_PREFIX: &str = "literal:";
|
|
|
|
#[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 schemes: Vec<String>,
|
|
pub ports: Vec<u16>,
|
|
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 body_secret_substitution: Option<BodySecretSubstitutionConfig>,
|
|
pub aws_sigv4: Option<AwsSigV4Config>,
|
|
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 {
|
|
/// 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>,
|
|
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 schemes: Vec<String>,
|
|
pub ports: Vec<u16>,
|
|
pub methods: Vec<String>,
|
|
pub path_prefixes: Vec<PathMatcher>,
|
|
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<ValueMatcher>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct HeaderConstraint {
|
|
pub name: HeaderName,
|
|
pub allowed_values: Vec<ValueMatcher>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MitmHookActions {
|
|
pub(crate) body_secret_substitution: Option<Arc<BodySecretSubstitution>>,
|
|
pub(crate) aws_sigv4: Option<Arc<AwsSigV4Signer>>,
|
|
pub strip_request_headers: Vec<HeaderName>,
|
|
pub inject_request_headers: Vec<ResolvedInjectedHeader>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ResolvedInjectedHeader {
|
|
pub placeholder: Option<HeaderValue>,
|
|
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,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PathMatcher {
|
|
Prefix(String),
|
|
Glob(CompiledGlobMatcher),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ValueMatcher {
|
|
Exact(String),
|
|
Glob(CompiledGlobMatcher),
|
|
}
|
|
|
|
enum MatcherPattern<'a> {
|
|
Literal(&'a str),
|
|
Glob(&'a str),
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct CompiledGlobMatcher {
|
|
pattern: String,
|
|
matcher: GlobMatcher,
|
|
}
|
|
|
|
impl std::fmt::Debug for CompiledGlobMatcher {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("CompiledGlobMatcher")
|
|
.field("pattern", &self.pattern)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl PartialEq for CompiledGlobMatcher {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.pattern == other.pattern
|
|
}
|
|
}
|
|
|
|
impl Eq for CompiledGlobMatcher {}
|
|
|
|
impl CompiledGlobMatcher {
|
|
fn is_match(&self, candidate: &str) -> bool {
|
|
self.matcher.is_match(candidate)
|
|
}
|
|
}
|
|
|
|
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,
|
|
Matched { actions: MitmHookActions },
|
|
HookedHostNoMatch,
|
|
}
|
|
|
|
pub(crate) fn validate_mitm_hook_config(config: &NetworkProxyConfig) -> Result<()> {
|
|
let hooks = &config.mitm_hooks;
|
|
if hooks.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
if !config.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"))?;
|
|
|
|
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() {
|
|
return Err(anyhow!(
|
|
"network.mitm_hooks[{hook_index}].match.methods must not be empty"
|
|
));
|
|
}
|
|
|
|
let path_prefixes =
|
|
compile_path_matchers(&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")
|
|
})?;
|
|
if let Some(signing) = &hook.actions.aws_sigv4 {
|
|
signing.validate()?;
|
|
}
|
|
if let Some(substitution) = &hook.actions.body_secret_substitution {
|
|
substitution.validate()?;
|
|
}
|
|
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 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())
|
|
},
|
|
)
|
|
}
|
|
|
|
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 {
|
|
return HookEvaluation::NoHooksForHost;
|
|
};
|
|
|
|
for hook in hooks {
|
|
if hook_matches(hook, req, origin) {
|
|
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.mitm_hooks {
|
|
let host = normalize_hook_host(&hook.host)?;
|
|
let methods = normalize_methods(&hook.matcher.methods)?;
|
|
let path_prefixes = compile_path_matchers(&hook.matcher.path_prefixes)?;
|
|
let query = hook
|
|
.matcher
|
|
.query
|
|
.iter()
|
|
.map(|(name, values)| {
|
|
Ok(QueryConstraint {
|
|
name: normalize_query_name(name)?,
|
|
allowed_values: compile_value_matchers(values)?,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
let headers = hook
|
|
.matcher
|
|
.headers
|
|
.iter()
|
|
.map(|(name, values)| {
|
|
Ok(HeaderConstraint {
|
|
name: parse_header_name(name)?,
|
|
allowed_values: compile_value_matchers(values)?,
|
|
})
|
|
})
|
|
.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 {
|
|
schemes: hook
|
|
.matcher
|
|
.schemes
|
|
.iter()
|
|
.map(|scheme| scheme.to_ascii_lowercase())
|
|
.collect(),
|
|
ports: hook.matcher.ports.clone(),
|
|
methods,
|
|
path_prefixes,
|
|
query,
|
|
headers,
|
|
body: None,
|
|
},
|
|
actions: MitmHookActions {
|
|
body_secret_substitution: hook
|
|
.actions
|
|
.body_secret_substitution
|
|
.as_ref()
|
|
.map(|substitution| {
|
|
substitution
|
|
.compile(&resolve_env_var, &read_secret_file)
|
|
.map(Arc::new)
|
|
})
|
|
.transpose()?,
|
|
aws_sigv4: hook
|
|
.actions
|
|
.aws_sigv4
|
|
.as_ref()
|
|
.map(|signing| {
|
|
signing
|
|
.compile(&resolve_env_var, &read_secret_file)
|
|
.map(Arc::new)
|
|
})
|
|
.transpose()?,
|
|
strip_request_headers,
|
|
inject_request_headers,
|
|
},
|
|
});
|
|
}
|
|
|
|
crate::aws_sigv4::validate_guest_placeholders(
|
|
hooks_by_host
|
|
.values()
|
|
.flatten()
|
|
.filter_map(|hook| hook.actions.aws_sigv4.as_deref()),
|
|
)?;
|
|
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 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, 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.as_str() == req.method().as_str())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let path = req.uri().path();
|
|
if !is_safe_for_authorization(path) || !path_matches(&hook.matcher.path_prefixes, path) {
|
|
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| {
|
|
// 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 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.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)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
fn path_matches(path_prefixes: &[PathMatcher], path: &str) -> bool {
|
|
path_prefixes.iter().any(|matcher| matcher.matches(path))
|
|
}
|
|
|
|
impl PathMatcher {
|
|
fn matches(&self, candidate: &str) -> bool {
|
|
match self {
|
|
Self::Prefix(prefix) => candidate.starts_with(prefix),
|
|
Self::Glob(glob) => glob.is_match(candidate),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ValueMatcher {
|
|
fn matches(&self, candidate: &str) -> bool {
|
|
match self {
|
|
Self::Exact(value) => value == candidate,
|
|
Self::Glob(glob) => glob.is_match(candidate),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compile_path_matchers(path_prefixes: &[String]) -> Result<Vec<PathMatcher>> {
|
|
path_prefixes
|
|
.iter()
|
|
.map(|prefix| {
|
|
match parse_matcher_pattern(prefix)? {
|
|
MatcherPattern::Literal(prefix) => {
|
|
if prefix.is_empty() {
|
|
return Err(anyhow!("path_prefixes must not contain empty entries"));
|
|
}
|
|
Ok(PathMatcher::Prefix(prefix.to_string()))
|
|
}
|
|
MatcherPattern::Glob(glob_pattern) => Ok(PathMatcher::Glob(compile_glob_matcher(
|
|
glob_pattern,
|
|
/*literal_separator*/ true,
|
|
)?)),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn compile_value_matchers(values: &[String]) -> Result<Vec<ValueMatcher>> {
|
|
values
|
|
.iter()
|
|
.map(|value| match parse_matcher_pattern(value)? {
|
|
MatcherPattern::Literal(value) => Ok(ValueMatcher::Exact(value.to_string())),
|
|
MatcherPattern::Glob(glob_pattern) => Ok(ValueMatcher::Glob(compile_glob_matcher(
|
|
glob_pattern,
|
|
/*literal_separator*/ false,
|
|
)?)),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn parse_matcher_pattern(pattern: &str) -> Result<MatcherPattern<'_>> {
|
|
if let Some(literal) = pattern.strip_prefix(LITERAL_PREFIX) {
|
|
return Ok(MatcherPattern::Literal(literal));
|
|
}
|
|
let Some(glob_pattern) = pattern.strip_prefix(PATTERN_PREFIX) else {
|
|
return Ok(MatcherPattern::Literal(pattern));
|
|
};
|
|
if glob_pattern.is_empty() {
|
|
return Err(anyhow!("glob pattern must not be empty"));
|
|
}
|
|
Ok(MatcherPattern::Glob(glob_pattern))
|
|
}
|
|
|
|
fn compile_glob_matcher(pattern: &str, literal_separator: bool) -> Result<CompiledGlobMatcher> {
|
|
let mut builder = GlobBuilder::new(pattern);
|
|
builder
|
|
.backslash_escape(true)
|
|
.literal_separator(literal_separator);
|
|
builder
|
|
.build()
|
|
.map(|glob| CompiledGlobMatcher {
|
|
pattern: pattern.to_string(),
|
|
matcher: glob.compile_matcher(),
|
|
})
|
|
.map_err(|err| anyhow!("invalid glob pattern {pattern:?}: {err}"))
|
|
}
|
|
|
|
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 method = method.trim();
|
|
if method.is_empty() {
|
|
return Err(anyhow!("methods must not contain empty entries"));
|
|
}
|
|
// 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()
|
|
}
|
|
|
|
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"
|
|
));
|
|
}
|
|
let _ = compile_value_matchers(values)
|
|
.with_context(|| format!("invalid matcher for query key {name:?}"))?;
|
|
}
|
|
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, values) in headers {
|
|
let _ = parse_header_name(name)?;
|
|
let _ = compile_value_matchers(values)
|
|
.with_context(|| format!("invalid matcher for header {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 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(),
|
|
) {
|
|
(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 {
|
|
#[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;
|
|
use pretty_assertions::assert_eq;
|
|
use rama_http::Body;
|
|
use rama_http::Method;
|
|
use tempfile::NamedTempFile;
|
|
|
|
fn base_config() -> NetworkProxyConfig {
|
|
NetworkProxyConfig {
|
|
mitm: true,
|
|
mode: NetworkMode::Limited,
|
|
..NetworkProxyConfig::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 {
|
|
body_secret_substitution: None,
|
|
aws_sigv4: None,
|
|
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,
|
|
prefix: Some("Bearer ".to_string()),
|
|
}],
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_requires_mitm_for_hooks() {
|
|
let mut config = base_config();
|
|
config.mitm = false;
|
|
config.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.mode = NetworkMode::Full;
|
|
config.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.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.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.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.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.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.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];
|
|
|
|
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")
|
|
);
|
|
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]
|
|
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.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 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();
|
|
let mut hook = github_hook();
|
|
hook.matcher.path_prefixes = vec!["pattern:/repos/*/codex/issues*".to_string()];
|
|
hook.matcher.query =
|
|
BTreeMap::from([("state".to_string(), vec!["pattern:op*".to_string()])]);
|
|
hook.matcher.headers = BTreeMap::from([(
|
|
"x-github-api-version".to_string(),
|
|
vec!["pattern:2022*preview".to_string()],
|
|
)]);
|
|
config.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")
|
|
.header("x-github-api-version", "2022-11-28-preview")
|
|
.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 validate_rejects_invalid_wildcard_path_pattern() {
|
|
let mut config = base_config();
|
|
let mut hook = github_hook();
|
|
hook.matcher.path_prefixes = vec!["pattern:/repos/[".to_string()];
|
|
config.mitm_hooks = vec![hook];
|
|
|
|
let err = validate_mitm_hook_config(&config).expect_err("invalid glob should fail");
|
|
assert!(format!("{err:#}").contains("invalid glob pattern"));
|
|
}
|
|
|
|
#[test]
|
|
fn evaluate_path_wildcard_does_not_cross_segment_boundaries() {
|
|
let mut config = base_config();
|
|
let mut hook = github_hook();
|
|
hook.matcher.path_prefixes = vec!["pattern:/repos/*/codex/issues*".to_string()];
|
|
config.mitm_hooks = vec![hook];
|
|
|
|
let hooks = compile_mitm_hooks_with_resolvers(
|
|
&config,
|
|
|_| Some("abc".to_string()),
|
|
|_| Err(anyhow!("unexpected file lookup")),
|
|
)
|
|
.unwrap();
|
|
let nested_req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/repos/openai/private/codex/issues")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
evaluate_mitm_hooks(&hooks, "api.github.com", &nested_req),
|
|
HookEvaluation::HookedHostNoMatch
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn evaluate_rejects_paths_that_upstream_may_normalize() {
|
|
let mut config = base_config();
|
|
let mut hook = github_hook();
|
|
hook.matcher.methods = vec!["GET".to_string()];
|
|
hook.matcher.path_prefixes = vec!["pattern:/openai/openai/**".to_string()];
|
|
config.mitm_hooks = vec![hook];
|
|
|
|
let hooks = compile_mitm_hooks_with_resolvers(
|
|
&config,
|
|
|_| Some("abc".to_string()),
|
|
|_| Err(anyhow!("unexpected file lookup")),
|
|
)
|
|
.unwrap();
|
|
let paths = [
|
|
"/openai/openai/../codex",
|
|
"/openai/openai/%2e%2e/codex",
|
|
"/openai/openai/%2E%2E/codex",
|
|
"/openai/openai/.%2e/codex",
|
|
"/openai/openai/%2e./codex",
|
|
"/openai/openai/%252e%252e/codex",
|
|
"/openai/openai/%2f..%2fcodex",
|
|
"/openai/openai/%5c..%5ccodex",
|
|
"/openai/openai/%2e%2e/%2e%2e/microsoft/vscode",
|
|
];
|
|
let actual = paths
|
|
.iter()
|
|
.map(|path| {
|
|
let req = Request::builder()
|
|
.method(Method::GET)
|
|
.uri(*path)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
evaluate_mitm_hooks(&hooks, "api.github.com", &req)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(actual, vec![HookEvaluation::HookedHostNoMatch; paths.len()]);
|
|
}
|
|
|
|
#[test]
|
|
fn evaluate_treats_glob_metacharacters_as_literal_without_glob_prefix() {
|
|
let mut config = base_config();
|
|
let mut hook = github_hook();
|
|
hook.matcher.path_prefixes = vec!["/repos/[draft]/".to_string()];
|
|
hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["op*".to_string()])]);
|
|
hook.matcher.headers = BTreeMap::from([(
|
|
"x-github-api-version".to_string(),
|
|
vec!["2022-11-28[preview]".to_string()],
|
|
)]);
|
|
config.mitm_hooks = vec![hook];
|
|
|
|
let hooks = compile_mitm_hooks_with_resolvers(
|
|
&config,
|
|
|_| Some("abc".to_string()),
|
|
|_| Err(anyhow!("unexpected file lookup")),
|
|
)
|
|
.unwrap();
|
|
let exact_req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/repos/[draft]/codex/issues?state=op*")
|
|
.header("x-github-api-version", "2022-11-28[preview]")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let non_literal_req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/repos/draft/codex/issues?state=open")
|
|
.header("x-github-api-version", "2022-11-28-preview")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
evaluate_mitm_hooks(&hooks, "api.github.com", &exact_req),
|
|
HookEvaluation::Matched {
|
|
actions: hooks.get("api.github.com").unwrap()[0].actions.clone(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
evaluate_mitm_hooks(&hooks, "api.github.com", &non_literal_req),
|
|
HookEvaluation::HookedHostNoMatch
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn evaluate_allows_literal_values_with_reserved_prefixes() {
|
|
let mut config = base_config();
|
|
let mut hook = github_hook();
|
|
hook.matcher.query =
|
|
BTreeMap::from([("state".to_string(), vec!["literal:pattern:*".to_string()])]);
|
|
hook.matcher.headers = BTreeMap::from([(
|
|
"x-github-api-version".to_string(),
|
|
vec!["literal:pattern:*".to_string()],
|
|
)]);
|
|
config.mitm_hooks = vec![hook];
|
|
|
|
let hooks = compile_mitm_hooks_with_resolvers(
|
|
&config,
|
|
|_| Some("abc".to_string()),
|
|
|_| Err(anyhow!("unexpected file lookup")),
|
|
)
|
|
.unwrap();
|
|
let exact_req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/repos/openai/codex/issues?state=pattern%3A%2A")
|
|
.header("x-github-api-version", "pattern:*")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let non_literal_req = Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/repos/openai/codex/issues?state=pattern%3Aopen")
|
|
.header("x-github-api-version", "pattern:preview")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
evaluate_mitm_hooks(&hooks, "api.github.com", &exact_req),
|
|
HookEvaluation::Matched {
|
|
actions: hooks.get("api.github.com").unwrap()[0].actions.clone(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
evaluate_mitm_hooks(&hooks, "api.github.com", &non_literal_req),
|
|
HookEvaluation::HookedHostNoMatch
|
|
);
|
|
}
|
|
|
|
#[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.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
|
|
);
|
|
}
|
|
}
|