Add configurable credential providers to the network proxy (#44056)

## What changed

- Add `credential_providers` configuration for environment variables, credential patterns, static or environment-derived URL prefixes, and bearer, token, Basic, or custom header authentication.
- Generate matching dummy credentials and restrict replacement to authorized schemes, hosts, ports, and path prefixes. Scope credentials and destination history to each environment.
- Preserve real credentials for destinations that bypass the proxy with `allow_local_binding`, while providing snapshot redaction and alias matching.
- Validate provider definitions and preserve unchanged providers across configuration reloads.

## Testing

Add coverage for dummy generation, authentication translation, URL restrictions, environment isolation, configuration reloads, and credential restoration for local proxy bypass.

GitOrigin-RevId: 5d60ecc684cf4e9aa7637af33af12179aa0c672a
This commit is contained in:
Winston Howes
2026-09-09 04:59:01 +00:00
committed by copyberry
parent 96c2b4377f
commit 1bfd383890
25 changed files with 3540 additions and 299 deletions
+3 -1
View File
@@ -53,7 +53,9 @@ where
"unknown network proxy attribution token",
)
})?,
None => self.state.as_ref().clone(),
None => self
.state
.for_environment_id(self.environment_id.as_deref()),
};
if let Some(expected_environment_id) = self.environment_id.as_deref()
&& state
+4
View File
@@ -138,6 +138,8 @@ pub struct NetworkProxyConfig {
pub mitm: bool,
#[serde(default)]
pub credential_broker: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub credential_providers: BTreeMap<String, crate::CredentialProviderConfig>,
/// Trusted OpenAI endpoint derived from local configuration, never sent to remote executors.
#[serde(skip)]
pub credential_broker_openai_host: Option<String>,
@@ -164,6 +166,7 @@ impl Default for NetworkProxyConfig {
allow_local_binding: false,
mitm: false,
credential_broker: false,
credential_providers: BTreeMap::new(),
credential_broker_openai_host: None,
dangerously_allow_plaintext_credential_injection: false,
mitm_hooks: Vec::new(),
@@ -637,6 +640,7 @@ mod tests {
allow_local_binding: false,
mitm: false,
credential_broker: false,
credential_providers: BTreeMap::new(),
credential_broker_openai_host: None,
dangerously_allow_plaintext_credential_injection: false,
mitm_hooks: Vec::new(),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,417 @@
use super::CredentialAuthMethod;
use super::CredentialProviderConfig;
use super::destination::CredentialDestination;
use super::env_value;
use super::providers;
use super::providers::CredentialHostBinding;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use base64::Engine as _;
use rama_http::HeaderMap;
use rama_http::HeaderName;
use rama_http::HeaderValue;
use rama_http::header::AUTHORIZATION;
use rand::Rng as _;
use regex::RegexBuilder;
use regex_syntax::hir::Class;
use regex_syntax::hir::ClassBytes;
use regex_syntax::hir::ClassBytesRange;
use regex_syntax::hir::ClassUnicode;
use regex_syntax::hir::ClassUnicodeRange;
use regex_syntax::hir::Hir;
use regex_syntax::hir::HirKind;
use regex_syntax::hir::Look;
use std::collections::HashMap;
const MAX_REGEX_BYTES: usize = 2048;
const MAX_REGEX_REPEAT: u32 = 64;
const MAX_DUMMY_ATTEMPTS: usize = 64;
const MAX_DUMMY_VALUE_BYTES: usize = 2048;
pub(super) struct ConfiguredCredentialProvider {
pub(super) id: String,
pub(super) config: CredentialProviderConfig,
patterns: Vec<ConfiguredCredentialPattern>,
header: Option<HeaderName>,
}
struct ConfiguredCredentialPattern {
full_matcher: regex::Regex,
ascii_generator: Option<rand_regex::Regex>,
generator: rand_regex::Regex,
}
impl ConfiguredCredentialPattern {
fn candidates(&self) -> impl Iterator<Item = String> + '_ {
self.ascii_generator
.iter()
.chain(std::iter::once(&self.generator))
.flat_map(|generator| {
(0..MAX_DUMMY_ATTEMPTS).map(move |_| rand::rng().sample::<String, _>(generator))
})
.filter(move |candidate| {
candidate.len() <= MAX_DUMMY_VALUE_BYTES
&& !candidate.contains('\0')
&& self.full_matcher.is_match(candidate)
})
}
}
#[derive(Clone, Copy)]
enum DummyAlphabet {
Ascii,
Unicode,
}
impl ConfiguredCredentialProvider {
pub(super) fn compile(id: &str, config: &CredentialProviderConfig) -> Result<Self> {
ensure!(!id.is_empty(), "credential provider name must not be empty");
ensure!(
!config.env.is_empty(),
"credential provider `{id}` has no environment keys"
);
ensure!(
!config.patterns.is_empty(),
"credential provider `{id}` has no credential patterns"
);
ensure!(
!config.url_prefixes.is_empty() || config.url_prefix_from_env.is_some(),
"credential provider `{id}` has no destination URL prefixes"
);
ensure!(
config.env.iter().all(|key| valid_environment_key(key)),
"credential provider `{id}` has an invalid environment key"
);
ensure!(
!config
.env
.iter()
.any(|key| super::is_credential_broker_provider_env_key(key)),
"credential provider `{id}` overlaps a built-in credential source"
);
if let Some(key) = config.url_prefix_from_env.as_deref() {
ensure!(
valid_environment_key(key)
&& !config.env.iter().any(|source| {
source.as_str() == key || cfg!(windows) && source.eq_ignore_ascii_case(key)
}),
"credential provider `{id}` has an invalid host environment key"
);
}
for destination in &config.url_prefixes {
CredentialDestination::parse(destination)
.with_context(|| format!("invalid destination for credential provider `{id}`"))?;
}
let methods = auth_methods(config);
let header = config
.header
.as_deref()
.map(|header| HeaderName::from_bytes(header.as_bytes()))
.transpose()
.with_context(|| format!("invalid header for credential provider `{id}`"))?;
ensure!(
methods.contains(&CredentialAuthMethod::Header) == header.is_some(),
"credential provider `{id}` requires a header name exactly when using header authentication"
);
ensure!(
config.prefix.is_none() || header.is_some(),
"credential provider `{id}` requires header authentication to use a header prefix"
);
let patterns = config
.patterns
.iter()
.map(|pattern| {
ensure!(
pattern.len() <= MAX_REGEX_BYTES,
"credential pattern exceeds {MAX_REGEX_BYTES} bytes"
);
let parsed = regex_syntax::parse(pattern)?;
let ascii_generator = rand_regex::Regex::with_hir(
dummy_credential_pattern(parsed.clone(), DummyAlphabet::Ascii),
MAX_REGEX_REPEAT,
)
.ok();
let generator = rand_regex::Regex::with_hir(
dummy_credential_pattern(parsed.clone(), DummyAlphabet::Unicode),
MAX_REGEX_REPEAT,
)?;
let full_pattern =
Hir::concat(vec![Hir::look(Look::Start), parsed, Hir::look(Look::End)])
.to_string();
let full_matcher = RegexBuilder::new(&full_pattern)
.size_limit(1 << 20)
.dfa_size_limit(1 << 20)
.build()?;
ensure!(
!full_matcher.is_match(""),
"credential pattern must not match an empty value"
);
ensure!(
generator.is_utf8() && generator.capacity() <= MAX_REGEX_BYTES,
"credential pattern must generate bounded UTF-8 values"
);
let compiled = ConfiguredCredentialPattern {
full_matcher,
ascii_generator,
generator,
};
ensure!(
compiled.candidates().next().is_some(),
"credential pattern could not independently generate a matching dummy"
);
Ok(compiled)
})
.collect::<Result<Vec<_>>>()
.with_context(|| format!("invalid credential pattern for provider `{id}`"))?;
Ok(Self {
id: id.to_string(),
config: config.clone(),
patterns,
header,
})
}
pub(super) fn matches_value(&self, value: &str) -> bool {
self.patterns
.iter()
.any(|pattern| pattern.full_matcher.is_match(value))
}
pub(super) fn host_binding(
&self,
env: &HashMap<String, String>,
) -> Option<CredentialHostBinding> {
let mut destinations = self
.config
.url_prefixes
.iter()
.filter_map(|destination| CredentialDestination::parse(destination).ok())
.collect::<Vec<_>>();
if let Some(destination) = self.dynamic_destination(env)
&& !destinations.contains(&destination)
{
destinations.push(destination);
}
(!destinations.is_empty()).then_some(CredentialHostBinding::ConfiguredHosts(destinations))
}
pub(super) fn dynamic_destination(
&self,
env: &HashMap<String, String>,
) -> Option<CredentialDestination> {
let key = self.config.url_prefix_from_env.as_deref()?;
CredentialDestination::parse(env_value(env, key)?)
.ok()
.filter(|destination| !destination.is_wildcard())
}
pub(super) fn dummy_value(&self, real_value: &str) -> Option<String> {
let pattern = self
.patterns
.iter()
.find(|pattern| pattern.full_matcher.is_match(real_value))?;
pattern.candidates().find(|candidate| {
candidate != real_value && self.preserves_usable_auth_methods(real_value, candidate)
})
}
pub(super) fn request_header_value(&self, value: &str) -> Option<HeaderValue> {
auth_methods(&self.config)
.iter()
.find_map(|method| self.request_header_value_for_method(*method, value))
}
fn preserves_usable_auth_methods(&self, real_value: &str, dummy_value: &str) -> bool {
auth_methods(&self.config).iter().all(|method| {
// Preserve whether clients serialize a whole Basic pair or one component.
if *method == CredentialAuthMethod::Basic
&& real_value.contains(':') != dummy_value.contains(':')
{
return false;
}
let Some(real_header) = self.request_header_value_for_method(*method, real_value)
else {
return true;
};
let Some(dummy_header) = self.request_header_value_for_method(*method, dummy_value)
else {
return false;
};
if dummy_header.as_bytes().trim_ascii() != dummy_header.as_bytes() {
return false;
}
let header = match method {
CredentialAuthMethod::Bearer
| CredentialAuthMethod::Token
| CredentialAuthMethod::Basic => AUTHORIZATION,
CredentialAuthMethod::Header => {
let Some(header) = self.header.as_ref() else {
return false;
};
header.clone()
}
};
self.translate_request_headers(
&HeaderMap::from_iter([(header.clone(), dummy_header)]),
dummy_value,
real_value,
)
.contains(&(header, real_header))
})
}
fn request_header_value_for_method(
&self,
method: CredentialAuthMethod,
value: &str,
) -> Option<HeaderValue> {
let value = match method {
CredentialAuthMethod::Bearer => format!("Bearer {value}"),
CredentialAuthMethod::Token => format!("token {value}"),
CredentialAuthMethod::Basic => format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(value)
),
CredentialAuthMethod::Header => {
format!(
"{}{value}",
self.config.prefix.as_deref().unwrap_or_default()
)
}
};
HeaderValue::from_str(&value)
.ok()
.filter(|header| header.to_str().is_ok())
}
pub(super) fn translate_request_headers(
&self,
headers: &HeaderMap,
expected_value: &str,
replacement_value: &str,
) -> Vec<(HeaderName, HeaderValue)> {
let mut translated = Vec::new();
for method in auth_methods(&self.config) {
match method {
CredentialAuthMethod::Bearer
| CredentialAuthMethod::Token
| CredentialAuthMethod::Basic => {
let Some(header) = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
else {
continue;
};
let Some((scheme, _)) = header.split_once(' ') else {
continue;
};
let expected_scheme = match method {
CredentialAuthMethod::Bearer => "bearer",
CredentialAuthMethod::Token => "token",
CredentialAuthMethod::Basic => "basic",
CredentialAuthMethod::Header => continue,
};
if !scheme.eq_ignore_ascii_case(expected_scheme) {
continue;
}
if let Some(value) = providers::translate_standard_request_header(
headers,
expected_value,
replacement_value,
) && !translated.iter().any(|(header, _)| header == AUTHORIZATION)
{
translated.push((AUTHORIZATION, value));
}
}
CredentialAuthMethod::Header => {
let Some(header) = self.header.as_ref() else {
continue;
};
let Some(value) = headers.get(header).and_then(|value| value.to_str().ok())
else {
continue;
};
let prefix = self.config.prefix.as_deref().unwrap_or_default();
if value == format!("{prefix}{expected_value}")
&& !translated.iter().any(|(name, _)| name == header)
&& let Ok(value) =
HeaderValue::from_str(&format!("{prefix}{replacement_value}"))
{
translated.push((header.clone(), value));
}
}
}
}
translated
}
}
fn dummy_credential_pattern(hir: Hir, alphabet: DummyAlphabet) -> Hir {
match hir.into_kind() {
HirKind::Empty => Hir::empty(),
HirKind::Literal(literal) => {
if matches!(alphabet, DummyAlphabet::Ascii) && !literal.0.is_ascii() {
Hir::fail()
} else {
Hir::literal(literal.0)
}
}
HirKind::Class(mut class) => {
if matches!(alphabet, DummyAlphabet::Ascii) {
match &mut class {
Class::Unicode(class) => {
class.intersect(&ClassUnicode::new([ClassUnicodeRange::new(' ', '~')]))
}
Class::Bytes(class) => {
class.intersect(&ClassBytes::new([ClassBytesRange::new(b' ', b'~')]))
}
}
}
Hir::class(class)
}
HirKind::Look(_) => Hir::empty(),
HirKind::Repetition(mut repetition) => {
repetition.sub = Box::new(dummy_credential_pattern(*repetition.sub, alphabet));
Hir::repetition(repetition)
}
HirKind::Capture(mut capture) => {
capture.sub = Box::new(dummy_credential_pattern(*capture.sub, alphabet));
Hir::capture(capture)
}
HirKind::Concat(expressions) => Hir::concat(
expressions
.into_iter()
.map(|expression| dummy_credential_pattern(expression, alphabet))
.collect(),
),
HirKind::Alternation(expressions) => {
let mut expressions = expressions
.into_iter()
.map(|expression| dummy_credential_pattern(expression, alphabet))
.filter(|expression| expression.properties().minimum_len().is_some())
.collect::<Vec<_>>();
expressions.sort_by_key(|expression| {
std::cmp::Reverse(expression.properties().maximum_len().unwrap_or(usize::MAX))
});
Hir::alternation(expressions)
}
}
}
fn auth_methods(config: &CredentialProviderConfig) -> &[CredentialAuthMethod] {
if config.auth.is_empty() {
&[CredentialAuthMethod::Bearer]
} else {
&config.auth
}
}
pub(super) fn valid_environment_key(key: &str) -> bool {
let mut chars = key.chars();
chars
.next()
.is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
&& chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
use crate::authorization_path::is_safe_for_authorization;
use crate::policy::Host;
use crate::policy::is_loopback_host;
use crate::policy::normalize_host;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use anyhow::ensure;
use url::Url;
#[derive(Clone, PartialEq, Eq)]
pub(super) struct CredentialDestination {
scheme: CredentialDestinationScheme,
host: String,
wildcard: bool,
port: u16,
path_prefix: String,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CredentialDestinationScheme {
Http,
Https,
}
impl CredentialDestinationScheme {
fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Https => "https",
}
}
fn default_port(self) -> u16 {
match self {
Self::Http => 80,
Self::Https => 443,
}
}
}
impl CredentialDestination {
pub(super) fn parse(value: &str) -> Result<Self> {
let value = value.trim();
let (explicit_scheme, authority) = value
.split_once("://")
.map_or((None, value), |(scheme, authority)| {
(Some(scheme), authority)
});
let (wildcard, authority) = authority
.strip_prefix("*.")
.map_or((false, authority), |suffix| (true, suffix));
let authority = if wildcard {
format!("wildcard.{authority}")
} else {
authority.to_string()
};
let url = Url::parse(&format!("https://{authority}"))?;
ensure!(
url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
"credential destination cannot include user information, a query, or a fragment"
);
let host = url
.host_str()
.context("credential destination has no hostname")?;
let host = if wildcard {
host.strip_prefix("wildcard.")
.context("credential destination has an invalid wildcard")?
} else {
host
};
let host = normalize_host(host);
ensure!(
!host.is_empty() && !host.contains('*') && !host.contains('/'),
"credential destination must have an exact hostname or scoped wildcard"
);
let loopback = !wildcard && is_loopback_host(&Host::parse(&host)?);
let scheme = match explicit_scheme {
None if loopback => CredentialDestinationScheme::Http,
None => CredentialDestinationScheme::Https,
Some(scheme) if scheme.eq_ignore_ascii_case("https") => {
CredentialDestinationScheme::Https
}
Some(scheme) if scheme.eq_ignore_ascii_case("http") && loopback => {
CredentialDestinationScheme::Http
}
Some(_) => {
bail!("credential destination must use HTTPS unless it targets loopback over HTTP")
}
};
// HTTPS parsing removes an explicit :443, which HTTP must retain.
let url = match scheme {
CredentialDestinationScheme::Http => Url::parse(&format!("http://{authority}"))?,
CredentialDestinationScheme::Https => url,
};
Ok(Self {
scheme,
host,
wildcard,
port: url.port().unwrap_or_else(|| scheme.default_port()),
path_prefix: url.path().to_string(),
})
}
pub(super) fn is_wildcard(&self) -> bool {
self.wildcard
}
pub(super) fn bypasses_proxy_with_local_binding(&self) -> bool {
(!self.wildcard || self.host.parse::<std::net::IpAddr>().is_err())
&& crate::policy::is_default_proxy_bypass_host(&self.host)
}
pub(super) fn matches_host(&self, host: &str, port: u16) -> bool {
if self.port != port {
return false;
}
if self.wildcard {
host.strip_suffix(&self.host)
.is_some_and(|prefix| prefix.ends_with('.'))
} else {
host == self.host
}
}
pub(super) fn requires_mitm(&self, host: &str, port: u16) -> bool {
self.scheme == CredentialDestinationScheme::Https && self.matches_host(host, port)
}
pub(super) fn matches_request(&self, host: &str, request: Option<&Url>) -> bool {
let Some(request) = request else {
return false;
};
if request.scheme() != self.scheme.as_str()
|| !self.matches_host(
host,
request
.port_or_known_default()
.unwrap_or_else(|| self.scheme.default_port()),
)
{
return false;
}
let path = request.path();
if !is_safe_for_authorization(path) {
return false;
}
self.path_prefix == "/"
|| path == self.path_prefix
|| path
.strip_prefix(&self.path_prefix)
.is_some_and(|suffix| self.path_prefix.ends_with('/') || suffix.starts_with('/'))
}
}
@@ -3,6 +3,7 @@ use super::BROKERED_CREDENTIALS_ENV_KEY;
use super::CREDENTIAL_BROKER_ACTIVE_ENV_KEY;
use super::CredentialBrokerState;
use super::MIN_EMBEDDED_CREDENTIAL_LENGTH;
use super::configured::valid_environment_key;
use super::env_entry;
use super::env_key_matches;
use super::env_value;
@@ -99,7 +100,7 @@ pub fn brokered_credential_dummy_env_keys(env: &HashMap<String, String>) -> Vec<
}
/// Returns canonical credential and known alias keys recorded for an active brokered child,
/// including keys that are currently absent.
/// including configured provider keys and keys that are currently absent.
pub fn brokered_credential_marker_env_keys(env: &HashMap<String, String>) -> Vec<String> {
if env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) != Some("1") {
return Vec::new();
@@ -109,16 +110,14 @@ pub fn brokered_credential_marker_env_keys(env: &HashMap<String, String>) -> Vec
.unwrap_or_default();
let dummy_values = entries
.iter()
.filter(|(key, _)| {
providers::credential_env_keys().any(|candidate| env_key_matches(key, candidate))
})
.filter(|(key, value)| valid_environment_key(key) && !value.is_empty())
.map(|(_, dummy_value)| dummy_value.clone())
.collect::<Vec<_>>();
let mut keys = entries
.into_iter()
.filter_map(|(key, value)| {
if providers::credential_env_keys().any(|candidate| env_key_matches(&key, candidate)) {
return Some(key);
if valid_environment_key(&key) {
return (!value.is_empty()).then_some(key);
}
let alias_key = key.strip_prefix(BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX)?;
let known_alias = dummy_values.iter().any(|dummy_value| {
@@ -126,7 +125,7 @@ pub fn brokered_credential_marker_env_keys(env: &HashMap<String, String>) -> Vec
|| dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH
&& value.contains(dummy_value)
});
known_alias.then(|| alias_key.to_string())
(known_alias && valid_environment_key(alias_key)).then(|| alias_key.to_string())
})
.collect::<Vec<_>>();
keys.sort_unstable();
@@ -145,9 +144,7 @@ pub fn brokered_credential_value_env_keys(env: &HashMap<String, String>) -> Vec<
.unwrap_or_default()
.into_iter()
.filter_map(|(key, dummy_value)| {
providers::credential_env_keys()
.any(|candidate| env_key_matches(&key, candidate))
.then_some(dummy_value)
(valid_environment_key(&key) && !dummy_value.is_empty()).then_some(dummy_value)
})
.collect::<Vec<_>>();
let mut keys = env
@@ -34,7 +34,7 @@ pub(super) fn virtualize_text(
}
let replacement = credentials.iter().copied().find(|candidate| {
std::ptr::eq(candidate.provider, credential.provider)
candidate.provider.same_provider(&credential.provider)
&& candidate.real_value == credential.real_value
&& (allowed_keys.iter().any(|key| {
env_key_matches(key, &candidate.env_var)
@@ -0,0 +1,32 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
/// Declarative description of an environment-backed credential family.
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct CredentialProviderConfig {
pub env: Vec<String>,
pub patterns: Vec<String>,
/// URL prefixes authorized for injection. Bare loopback hosts imply HTTP; other bare hosts
/// imply HTTPS.
pub url_prefixes: Vec<String>,
/// Environment variable containing an additional URL prefix or hostname.
#[serde(skip_serializing_if = "Option::is_none")]
pub url_prefix_from_env: Option<String>,
pub auth: Vec<CredentialAuthMethod>,
#[serde(skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
}
/// Authentication formats supported by declarative credential providers.
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CredentialAuthMethod {
Bearer,
Token,
Basic,
Header,
}
@@ -1,10 +1,12 @@
mod github;
mod openai;
use super::destination::CredentialDestination;
use rama_http::HeaderMap;
use rama_http::HeaderValue;
use rand::Rng as _;
use std::collections::HashMap;
use url::Url;
const DUMMY_ALPHANUMERIC: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@@ -36,11 +38,14 @@ pub(super) enum CredentialHostBinding {
exact_hosts: &'static [&'static str],
suffixes: &'static [&'static str],
},
ConfiguredHosts(Vec<CredentialDestination>),
}
pub(super) struct CredentialSource {
pub(super) env_vars: &'static [&'static str],
pub(super) binding_env_vars: &'static [&'static str],
// An explicit invalid hint can clear dynamic destinations while leaving a static fallback.
pub(super) invalidates_host_binding: fn(&HashMap<String, String>) -> bool,
pub(super) host_binding:
fn(&HashMap<String, String>, Option<&str>) -> Option<CredentialHostBinding>,
}
@@ -75,7 +80,22 @@ impl CredentialProvider {
}
impl CredentialHostBinding {
pub(super) fn matches_host(&self, host: &str) -> bool {
pub(super) fn bypasses_proxy_with_local_binding(&self) -> bool {
use crate::policy::is_default_proxy_bypass_host;
match self {
Self::ExactHost(host) => is_default_proxy_bypass_host(host),
Self::ExactHosts(hosts) => hosts.iter().any(|host| is_default_proxy_bypass_host(host)),
Self::HostPattern { exact_hosts, .. } => exact_hosts
.iter()
.any(|host| is_default_proxy_bypass_host(host)),
Self::ConfiguredHosts(destinations) => destinations
.iter()
.any(CredentialDestination::bypasses_proxy_with_local_binding),
}
}
pub(super) fn matches_host(&self, host: &str, port: u16) -> bool {
match self {
Self::ExactHost(expected_host) => host == expected_host,
Self::ExactHosts(expected_hosts) => {
@@ -87,6 +107,35 @@ impl CredentialHostBinding {
} => {
exact_hosts.contains(&host) || suffixes.iter().any(|suffix| host.ends_with(suffix))
}
Self::ConfiguredHosts(destinations) => destinations
.iter()
.any(|destination| destination.matches_host(host, port)),
}
}
pub(super) fn requires_mitm(&self, host: &str, port: u16) -> bool {
match self {
Self::ConfiguredHosts(destinations) => destinations
.iter()
.any(|destination| destination.requires_mitm(host, port)),
Self::ExactHost(_) | Self::ExactHosts(_) | Self::HostPattern { .. } => {
self.matches_host(host, port)
}
}
}
pub(super) fn matches_request(&self, host: &str, request: Option<&Url>) -> bool {
match self {
Self::ConfiguredHosts(destinations) => destinations
.iter()
.any(|destination| destination.matches_request(host, request)),
Self::ExactHost(_) | Self::ExactHosts(_) | Self::HostPattern { .. } => {
request.is_none_or(|request| request.scheme() == "https")
&& self.matches_host(
host,
request.and_then(Url::port_or_known_default).unwrap_or(443),
)
}
}
}
}
@@ -132,6 +181,14 @@ pub(super) fn credential_providers() -> impl Iterator<Item = &'static Credential
CREDENTIAL_PROVIDERS.iter().copied()
}
pub(super) fn translate_standard_request_header(
headers: &HeaderMap,
expected_value: &str,
replacement_value: &str,
) -> Option<HeaderValue> {
github::translate_request_header(headers, expected_value, replacement_value)
}
fn shaped_dummy_value(real_value: &str, prefix: &str, minimum_len: usize) -> String {
let target_len = real_value.len().max(minimum_len).max(prefix.len() + 16);
let mut rng = rand::rng();
@@ -29,11 +29,16 @@ pub(super) static PROVIDER: CredentialProvider = CredentialProvider {
CredentialSource {
env_vars: GITHUB_CLOUD_TOKEN_ENV_VARS,
binding_env_vars: &[],
invalidates_host_binding: |_| false,
host_binding: github_cloud_binding,
},
CredentialSource {
env_vars: GITHUB_ENTERPRISE_TOKEN_ENV_VARS,
binding_env_vars: &[GH_HOST_ENV_VAR],
invalidates_host_binding: |env| {
env_value(env, GH_HOST_ENV_VAR).is_some()
&& github_host_hint(env).is_none_or(|host| github_cloud_host(&host))
},
host_binding: github_enterprise_binding,
},
],
@@ -60,7 +65,7 @@ fn insert_request_header(headers: &mut HeaderMap, value: HeaderValue) {
headers.insert(AUTHORIZATION, value);
}
fn translate_request_header(
pub(super) fn translate_request_header(
headers: &HeaderMap,
expected_value: &str,
replacement_value: &str,
@@ -72,16 +77,27 @@ fn translate_request_header(
let decoded = base64::engine::general_purpose::STANDARD
.decode(value)
.ok()?;
let separator = decoded.iter().position(|byte| *byte == b':')?;
let mut translated = Vec::with_capacity(decoded.len() + replacement_value.len());
if &decoded[..separator] == expected_value.as_bytes() {
translated.extend_from_slice(replacement_value.as_bytes());
translated.extend_from_slice(&decoded[separator..]);
} else if &decoded[separator + 1..] == expected_value.as_bytes() {
translated.extend_from_slice(&decoded[..=separator]);
if decoded == expected_value.as_bytes() {
translated.extend_from_slice(replacement_value.as_bytes());
} else {
return None;
let separator = decoded.iter().position(|byte| *byte == b':')?;
let username = &decoded[..separator];
let password = &decoded[separator + 1..];
if username != expected_value.as_bytes() && password != expected_value.as_bytes() {
return None;
}
translated.extend_from_slice(if username == expected_value.as_bytes() {
replacement_value.as_bytes()
} else {
username
});
translated.push(b':');
translated.extend_from_slice(if password == expected_value.as_bytes() {
replacement_value.as_bytes()
} else {
password
});
}
let encoded = base64::engine::general_purpose::STANDARD.encode(translated);
HeaderValue::from_str(&format!("{scheme} {encoded}")).ok()
@@ -129,7 +145,27 @@ fn github_token_prefix(value: &str) -> &str {
}
fn github_host_hint(env: &HashMap<String, String>) -> Option<String> {
env_value(env, GH_HOST_ENV_VAR)
.map(normalize_host)
.filter(|host| !host.is_empty())
let value = env_value(env, GH_HOST_ENV_VAR)?.trim();
if value.is_empty() || value.contains(['/', '\\', '@', '?', '#', '*']) {
return None;
}
let authority = if value.parse::<std::net::IpAddr>().is_ok()
|| crate::policy::unscoped_ip_literal(value).is_some()
{
format!("[{value}]")
} else {
value.to_string()
};
let parsed = authority.parse::<rama_http::uri::Authority>().ok()?;
let suffix = authority.strip_prefix(parsed.host())?;
if !suffix.is_empty() {
suffix.strip_prefix(':')?.parse::<u16>().ok()?;
}
let host = normalize_host(parsed.host());
let ip_literal = host.parse::<std::net::IpAddr>().is_ok()
|| crate::policy::unscoped_ip_literal(&host).is_some();
if !ip_literal && (parsed.host().starts_with('[') || url::Host::parse(&host).is_err()) {
return None;
}
Some(host)
}
@@ -24,6 +24,10 @@ pub(super) static PROVIDER: CredentialProvider = CredentialProvider {
sources: &[CredentialSource {
env_vars: OPENAI_API_KEY_ENV_VARS,
binding_env_vars: &[OPENAI_BASE_URL_ENV_VAR],
invalidates_host_binding: |env| {
env_value(env, OPENAI_BASE_URL_ENV_VAR)
.is_some_and(|value| trusted_credential_broker_host(value).is_none())
},
host_binding,
}],
reset_on_configuration_change: true,
@@ -1,8 +1,264 @@
use super::CredentialBrokerState;
use super::CredentialRecord;
use super::MIN_EMBEDDED_CREDENTIAL_LENGTH;
use super::configured::ConfiguredCredentialProvider;
use super::env_entry;
use super::env_value;
use super::providers;
use base64::Engine;
use rama_http::HeaderMap;
use rama_http::HeaderName;
use rama_http::HeaderValue;
use rama_http::header::AUTHORIZATION;
use std::collections::HashMap;
use std::sync::Arc;
use url::Url;
#[derive(Clone)]
pub(super) enum BrokeredCredentialProvider {
Builtin(&'static providers::CredentialProvider),
Configured(Arc<ConfiguredCredentialProvider>),
}
pub(super) struct ActiveCredentialSource {
pub(super) provider: BrokeredCredentialProvider,
pub(super) host_binding: providers::CredentialHostBinding,
pub(super) env_vars: Vec<String>,
}
impl BrokeredCredentialProvider {
pub(super) fn same_provider(&self, other: &Self) -> bool {
match (self, other) {
(Self::Builtin(left), Self::Builtin(right)) => std::ptr::eq(*left, *right),
(Self::Configured(left), Self::Configured(right)) => Arc::ptr_eq(left, right),
(Self::Builtin(_), Self::Configured(_)) | (Self::Configured(_), Self::Builtin(_)) => {
false
}
}
}
pub(super) fn dummy_value(&self, real_value: &str) -> Option<String> {
match self {
Self::Builtin(provider) => Some(provider.dummy_value(real_value)),
Self::Configured(provider) => provider.dummy_value(real_value),
}
}
pub(super) fn contains_embedded_value(&self, text: &str, value: &str) -> bool {
value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH && text.contains(value)
}
pub(super) fn replace_embedded_value(
&self,
text: &str,
value: &str,
replacement: &str,
) -> String {
text.replace(value, replacement)
}
pub(super) fn request_header_value(&self, value: &str) -> Option<HeaderValue> {
match self {
Self::Builtin(provider) => provider.request_header_value(value),
Self::Configured(provider) => provider
.matches_value(value)
.then(|| provider.request_header_value(value))
.flatten(),
}
}
pub(super) fn translate_request_headers(
&self,
headers: &HeaderMap,
expected_value: &str,
replacement_value: &str,
) -> Vec<(HeaderName, HeaderValue)> {
match self {
Self::Builtin(provider) => provider
.translate_request_header(headers, expected_value, replacement_value)
.map(|value| (AUTHORIZATION, value)),
Self::Configured(provider) => {
return provider.translate_request_headers(
headers,
expected_value,
replacement_value,
);
}
}
.into_iter()
.collect()
}
pub(super) fn insert_request_header(
&self,
headers: &mut HeaderMap,
name: HeaderName,
value: HeaderValue,
) {
match self {
Self::Builtin(provider) => provider.insert_request_header(headers, value),
Self::Configured(_) => {
headers.insert(name, value);
}
}
}
}
pub(super) fn select_credentials<'a>(
headers: &HeaderMap,
host: &str,
request: Option<&Url>,
credentials: &'a [CredentialRecord],
environment_id: Option<&str>,
) -> Vec<(&'a CredentialRecord, HeaderName, HeaderValue)> {
let mut translated_matches = Vec::<(&CredentialRecord, HeaderName, HeaderValue)>::new();
for credential in credentials.iter().filter(|credential| {
credential.belongs_to_environment(environment_id)
&& credential
.host_bindings()
.any(|binding| binding.matches_request(host, request))
}) {
for candidate in credentials.iter().filter(|candidate| {
candidate.belongs_to_environment(environment_id)
&& candidate.provider.same_provider(&credential.provider)
&& candidate.real_value == credential.real_value
&& candidate
.host_bindings()
.any(|binding| binding.matches_request(host, request))
}) {
for (name, value) in credential.provider.translate_request_headers(
headers,
&candidate.dummy_value,
&credential.real_value,
) {
if !translated_matches
.iter()
.any(|(existing, header, translated)| {
existing.provider.same_provider(&credential.provider)
&& existing.real_value == credential.real_value
&& *header == name
&& *translated == value
})
{
translated_matches.push((credential, name, value));
}
}
}
}
let mut selected = Vec::<(&CredentialRecord, HeaderName, HeaderValue)>::new();
let mut ambiguous_headers = Vec::<HeaderName>::new();
for (credential, header_name, header_value) in translated_matches {
if ambiguous_headers.contains(&header_name) {
continue;
}
let Some(existing) = selected
.iter()
.position(|(_, selected_header, _)| selected_header == header_name)
else {
selected.push((credential, header_name, header_value));
continue;
};
if !credential
.provider
.same_provider(&selected[existing].0.provider)
|| credential.real_value != selected[existing].0.real_value
|| header_value != selected[existing].2
{
if header_name == AUTHORIZATION
&& let Some(merged) = headers.get(AUTHORIZATION).and_then(|original| {
merge_basic_auth_fields(original, &selected[existing].2, &header_value)
})
{
// Keep the caller's configured-provider raw-path validation for either field.
if matches!(
credential.provider,
BrokeredCredentialProvider::Configured(_)
) {
selected[existing].0 = credential;
}
selected[existing].2 = merged;
continue;
}
selected.swap_remove(existing);
ambiguous_headers.push(header_name);
}
}
selected
}
fn merge_basic_auth_fields(
original: &HeaderValue,
left: &HeaderValue,
right: &HeaderValue,
) -> Option<HeaderValue> {
let decode = |header: &HeaderValue| {
let (scheme, value) = header.to_str().ok()?.split_once(' ')?;
scheme.eq_ignore_ascii_case("basic").then_some(())?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(value.trim())
.ok()?;
let separator = decoded.iter().position(|byte| *byte == b':')?;
Some((
decoded[..separator].to_vec(),
decoded[separator + 1..].to_vec(),
))
};
let (original_user, original_password) = decode(original)?;
let (left_user, left_password) = decode(left)?;
let (right_user, right_password) = decode(right)?;
// Only combine independent fields. Overlapping translations remain ambiguous.
let (user, password) = if left_user != original_user
&& left_password == original_password
&& right_user == original_user
&& right_password != original_password
{
(left_user, right_password)
} else if right_user != original_user
&& right_password == original_password
&& left_user == original_user
&& left_password != original_password
{
(right_user, left_password)
} else {
return None;
};
let encoded =
base64::engine::general_purpose::STANDARD.encode([user, vec![b':'], password].concat());
HeaderValue::from_str(&format!("Basic {encoded}")).ok()
}
pub(super) fn active_credential_sources(
state: &CredentialBrokerState,
env: &HashMap<String, String>,
) -> Vec<ActiveCredentialSource> {
let mut sources = Vec::new();
for provider in providers::credential_providers() {
for source in provider.sources() {
if let Some(host_binding) = (source.host_binding)(env, state.openai_api_host.as_deref())
{
sources.push(ActiveCredentialSource {
provider: BrokeredCredentialProvider::Builtin(provider),
host_binding,
env_vars: source
.env_vars
.iter()
.map(|key| (*key).to_string())
.collect(),
});
}
}
}
for provider in &state.configured_providers {
if let Some(host_binding) = provider.host_binding(env) {
sources.push(ActiveCredentialSource {
provider: BrokeredCredentialProvider::Configured(provider.clone()),
host_binding,
env_vars: provider.config.env.clone(),
});
}
}
sources
}
pub(super) fn prioritized_credentials<'a>(
state: &'a CredentialBrokerState,
@@ -12,11 +268,25 @@ pub(super) fn prioritized_credentials<'a>(
credentials.sort_unstable_by_key(|credential| {
let active = env_entry(env, &credential.env_var)
.is_some_and(|(_, value)| value == credential.dummy_value);
let has_matching_host_binding = credential.provider.sources().iter().any(|source| {
!source.binding_env_vars.is_empty()
&& (source.host_binding)(env, state.openai_api_host.as_deref())
.is_some_and(|binding| binding == credential.host_binding)
});
let has_matching_host_binding = match &credential.provider {
BrokeredCredentialProvider::Builtin(provider) => {
provider.sources().iter().any(|source| {
!source.binding_env_vars.is_empty()
&& (source.host_binding)(env, state.openai_api_host.as_deref())
.is_some_and(|binding| binding == credential.host_binding)
})
}
BrokeredCredentialProvider::Configured(provider) => provider
.config
.url_prefix_from_env
.as_deref()
.is_some_and(|key| {
env_value(env, key).is_some()
&& provider
.host_binding(env)
.is_some_and(|binding| binding == credential.host_binding)
}),
};
(
std::cmp::Reverse(credential.real_value.len()),
std::cmp::Reverse(active),
@@ -25,37 +295,3 @@ pub(super) fn prioritized_credentials<'a>(
});
credentials
}
pub(super) fn select_credential<'a>(
headers: &HeaderMap,
host: &str,
credentials: &'a [CredentialRecord],
) -> Option<(&'a CredentialRecord, rama_http::HeaderValue)> {
let mut translated_matches = credentials
.iter()
.filter(|credential| credential.matches_host(host))
.filter_map(|credential| {
credentials
.iter()
.filter(|candidate| {
std::ptr::eq(candidate.provider, credential.provider)
&& candidate.real_value == credential.real_value
&& candidate.matches_host(host)
})
.find_map(|candidate| {
credential.provider.translate_request_header(
headers,
&candidate.dummy_value,
&credential.real_value,
)
})
.map(|header_value| (credential, header_value))
});
let matched = translated_matches.next()?;
translated_matches
.all(|(credential, _)| {
std::ptr::eq(credential.provider, matched.0.provider)
&& credential.real_value == matched.0.real_value
})
.then_some(matched)
}
@@ -783,6 +783,10 @@ fn brokered_credential_value_env_keys_include_dummy_aliases() {
"@alias:AUTH_HEADER".to_string(),
env["OPENAI_API_KEY"].clone()
),
(
"@alias:OPENAI_API_KEY".to_string(),
env["OPENAI_API_KEY"].clone()
),
("OPENAI_API_KEY".to_string(), env["OPENAI_API_KEY"].clone()),
]
);
@@ -904,7 +908,7 @@ fn virtualize_child_env_keeps_unbound_enterprise_token_out_of_persisted_text() {
assert_eq!(env["GH_ENTERPRISE_TOKEN"], token);
assert_eq!(headers, headers_with_bearer(token));
assert!(!broker.host_requires_mitm("attacker.example"));
assert!(!broker.host_requires_mitm("attacker.example", /*port*/ 443));
env.insert("GH_HOST".to_string(), "github.example.com".to_string());
broker.virtualize_child_env(&mut env);
@@ -999,6 +1003,117 @@ fn inject_request_headers_requires_dummy_and_preserves_explicit_authorization()
assert_eq!(authorization(&explicit_headers), Some("Bearer sk-explicit"));
}
#[test]
fn concurrent_commands_preserve_discovered_credential_destinations() {
for (key, host_key, token) in [
(
"GH_ENTERPRISE_TOKEN",
"GH_HOST",
"ghp_abcdefghijklmnopqrstuvwxyz1234567890",
),
(
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
),
(
"PROVIDER_TOKEN",
"PROVIDER_ENDPOINT",
"provider_abcdefghijklmnopqrstuvwx",
),
] {
let broker = CredentialBroker::new(/*enabled*/ true);
broker.configure(&NetworkProxyConfig {
credential_broker: true,
credential_providers: BTreeMap::from([(
"custom".to_string(),
CredentialProviderConfig {
env: vec!["PROVIDER_TOKEN".to_string()],
patterns: vec!["^provider_[a-z]{24}$".to_string()],
url_prefix_from_env: Some("PROVIDER_ENDPOINT".to_string()),
..CredentialProviderConfig::default()
},
)]),
..NetworkProxyConfig::default()
});
let mut commands = ["first.example", "second.example"].map(|host| {
let destination = if host_key == "GH_HOST" {
host.to_string()
} else {
format!("https://{host}/v1")
};
env_map([
(key, token),
(host_key, &destination),
("AUTH_HEADER", &format!("Bearer {token}")),
])
});
for env in &mut commands {
broker.virtualize_child_env_for_environment(env, Some("shared-environment"));
}
let dummy = commands[0][key].clone();
assert_ne!(dummy, token);
assert_eq!(commands[1][key], dummy);
let assert_destinations = || {
for (environment_id, host, injected) in [
("shared-environment", "first.example", true),
("shared-environment", "second.example", true),
("shared-environment", "unrelated.example", false),
("other-environment", "first.example", false),
("other-environment", "second.example", false),
] {
let mut headers = headers_with_bearer(&dummy);
broker.inject_request_headers_for_environment(
&format!("https://{host}/v1/models"),
&mut headers,
Some(environment_id),
);
assert_eq!(
headers,
headers_with_bearer(if injected { token } else { &dummy }),
"{key}, {environment_id}, {host}"
);
assert_eq!(
broker.host_requires_mitm_for_environment(
host,
/*port*/ 443,
Some(environment_id),
),
injected,
"{key}, {environment_id}, {host}"
);
}
};
assert_destinations();
for env in &mut commands {
env.remove(key);
broker.virtualize_child_env_for_environment(env, Some("shared-environment"));
assert_eq!(env["AUTH_HEADER"], format!("Bearer {dummy}"));
assert_destinations();
}
commands[0].insert(key.to_string(), token.to_string());
broker.virtualize_child_env_for_environment(&mut commands[0], Some("shared-environment"));
assert_eq!(commands[0][key], dummy);
assert_destinations();
let mut inherited = env_map([(key, token), (host_key, &commands[0][host_key])]);
broker.virtualize_child_env_for_environment(&mut inherited, Some("parent-environment"));
let parent_dummy = inherited[key].clone();
assert_ne!(parent_dummy, dummy);
broker.virtualize_child_env_for_environment(&mut inherited, Some("shared-environment"));
assert_eq!(inherited[key], dummy);
assert_destinations();
let mut headers = headers_with_bearer(&parent_dummy);
broker.inject_request_headers_for_environment(
"https://second.example/v1/models",
&mut headers,
Some("parent-environment"),
);
assert_eq!(headers, headers_with_bearer(&parent_dummy));
}
}
#[test]
fn openai_credentials_bind_only_to_default_and_configured_trusted_hosts() {
let broker = CredentialBroker::new(/*enabled*/ true);
@@ -1077,12 +1192,42 @@ fn github_cloud_credentials_do_not_bind_to_ghes_host_hint() {
authorization(&headers),
Some(expected_authorization.as_str())
);
assert!(!broker.host_requires_mitm("github.example.com"));
assert!(broker.host_requires_mitm("api.github.com"));
assert!(!broker.host_requires_mitm("github.example.com", /*port*/ 443));
assert!(broker.host_requires_mitm("api.github.com", /*port*/ 443));
}
#[test]
fn github_enterprise_credentials_bind_to_gh_host() {
for (hint, host) in [
(" GitHub.Example.Com.:8443 ", "github.example.com"),
("127.0.0.1:8443", "127.0.0.1"),
("[::1]:8443", "::1"),
("::1", "::1"),
("[fe80::1%en0]:8443", "fe80::1%en0"),
("fe80::1%en0", "fe80::1%en0"),
] {
let broker = CredentialBroker::new(/*enabled*/ true);
let mut env = env_map([
("GH_HOST", hint),
("GH_ENTERPRISE_TOKEN", "ghp-enterprise-real"),
]);
broker.virtualize_child_env(&mut env);
let dummy = env["GH_ENTERPRISE_TOKEN"].clone();
assert_ne!(dummy, "ghp-enterprise-real", "{hint}");
env.remove("GH_HOST");
env.insert(
"GH_ENTERPRISE_TOKEN".to_string(),
"ghp-enterprise-real".to_string(),
);
broker.virtualize_child_env(&mut env);
let mut headers = headers_with_bearer(&dummy);
broker.inject_request_headers(host, &mut headers);
assert_eq!(
authorization(&headers),
Some("Bearer ghp-enterprise-real"),
"{hint}"
);
}
let broker = CredentialBroker::new(/*enabled*/ true);
let mut env = env_map([
("GH_HOST", "github.example.com"),
@@ -1130,8 +1275,8 @@ fn github_enterprise_credentials_bind_to_gh_host() {
]);
broker.virtualize_child_env(&mut cloud_only);
assert_eq!(cloud_only["AUTH_HEADER"], format!("Bearer {github_dummy}"));
assert!(broker.host_requires_mitm("github.example.com"));
assert!(broker.host_requires_mitm("api.github.com"));
assert!(broker.host_requires_mitm("github.example.com", /*port*/ 443));
assert!(broker.host_requires_mitm("api.github.com", /*port*/ 443));
env.insert("GH_HOST".to_string(), "attacker.example".to_string());
env.insert("GH_ENTERPRISE_TOKEN".to_string(), github_dummy.clone());
@@ -1139,7 +1284,7 @@ fn github_enterprise_credentials_bind_to_gh_host() {
let mut attacker_headers = headers_with_bearer(&github_dummy);
broker.inject_request_headers("attacker.example", &mut attacker_headers);
assert_eq!(attacker_headers, headers_with_bearer(&github_dummy));
assert!(!broker.host_requires_mitm("attacker.example"));
assert!(!broker.host_requires_mitm("attacker.example", /*port*/ 443));
let mut alternate_enterprise_key = env_map([
("GH_HOST", "github.alternate.example"),
+133 -61
View File
@@ -300,7 +300,7 @@ async fn http_connect_accept(
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
}
};
let host_mitm_requirement = match app_state.host_mitm_requirement(&host).await {
let host_mitm_requirement = match app_state.host_mitm_requirement(&host, authority.port).await {
Ok(requirement) => requirement,
Err(err) => {
error!("failed to inspect MITM requirements for {host}: {err}");
@@ -785,7 +785,7 @@ async fn http_plain_proxy(
}
}
let host_mitm_requirement = match app_state.host_mitm_requirement(&host).await {
let host_mitm_requirement = match app_state.host_mitm_requirement(&host, port).await {
Ok(requirement) => requirement,
Err(err) => {
return Ok(internal_error("failed to inspect MITM requirements", err));
@@ -880,7 +880,7 @@ async fn http_plain_proxy(
}
if let Err(err) =
inject_plaintext_credentials_if_enabled(app_state.as_ref(), &host, req.headers_mut()).await
inject_forward_request_credentials(app_state.as_ref(), &request_ctx, &mut req).await
{
return Ok(internal_error(
"failed to read plaintext credential injection config",
@@ -917,13 +917,28 @@ async fn http_plain_proxy(
}
}
async fn inject_plaintext_credentials_if_enabled(
async fn inject_forward_request_credentials(
app_state: &NetworkProxyState,
host: &str,
headers: &mut HeaderMap,
context: &RequestContext,
req: &mut Request,
) -> Result<()> {
if app_state.plaintext_credential_injection_enabled().await? {
app_state.inject_request_credentials(host, headers);
let authority = context.host_with_port();
let scheme = req.uri().scheme_str().unwrap_or("http");
// Server-wide OPTIONS may use root-scoped credentials without changing its wire target.
let request_path = req
.uri()
.path_and_query()
.map(rama_http::uri::PathAndQuery::as_str)
.filter(|path| *path != "*")
.unwrap_or("/");
let destination = format!("{scheme}://{authority}{request_path}");
let unrestricted_plaintext = app_state.plaintext_credential_injection_enabled().await?;
app_state.inject_request_credentials(&destination, req.headers_mut());
if unrestricted_plaintext {
app_state.inject_request_credentials(
&normalize_host(&authority.host.to_string()),
req.headers_mut(),
);
}
Ok(())
}
@@ -1167,12 +1182,14 @@ struct BlockedResponse<'a> {
mod tests {
use super::*;
use crate::CredentialProviderConfig;
use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::runtime::network_proxy_state_for_policy;
use pretty_assertions::assert_eq;
use rama_http::Method;
use rama_http::Request;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::net::TcpListener as StdTcpListener;
@@ -1314,62 +1331,117 @@ mod tests {
#[tokio::test]
async fn plaintext_credential_injection_requires_explicit_opt_in() {
let real_token = "ghp-real";
let mut disabled_network = NetworkProxyConfig {
credential_broker: true,
mitm: true,
..NetworkProxyConfig::default()
};
disabled_network.set_allowed_domains(vec!["api.github.com".to_string()]);
let disabled_state = Arc::new(network_proxy_state_for_policy(disabled_network));
let mut disabled_env = HashMap::from([("GH_TOKEN".to_string(), real_token.to_string())]);
disabled_state.virtualize_child_credentials(&mut disabled_env);
let dummy_token = disabled_env.get("GH_TOKEN").expect("dummy GitHub token");
let mut disabled_headers = HeaderMap::from_iter([(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {dummy_token}"))
.expect("valid authorization header"),
)]);
for enabled in [false, true] {
let state = network_proxy_state_for_policy(NetworkProxyConfig {
credential_broker: true,
dangerously_allow_plaintext_credential_injection: enabled,
mitm: true,
..NetworkProxyConfig::default()
});
let mut env = HashMap::from([("GH_TOKEN".to_string(), real_token.to_string())]);
state.virtualize_child_credentials(&mut env);
let dummy = env.get("GH_TOKEN").expect("dummy GitHub token");
let mut req = Request::builder()
.uri("http://api.github.com/")
.header(header::AUTHORIZATION, format!("Bearer {dummy}"))
.body(Body::empty())
.unwrap();
let context = RequestContext::try_from(&req).unwrap();
inject_plaintext_credentials_if_enabled(
disabled_state.as_ref(),
"api.github.com",
&mut disabled_headers,
)
.await
.expect("disabled plaintext injection check should succeed");
assert_eq!(
disabled_headers.get(header::AUTHORIZATION),
Some(&HeaderValue::from_str(&format!("Bearer {dummy_token}")).unwrap())
);
inject_forward_request_credentials(&state, &context, &mut req)
.await
.unwrap();
let mut enabled_network = NetworkProxyConfig {
credential_broker: true,
dangerously_allow_plaintext_credential_injection: true,
mitm: true,
..NetworkProxyConfig::default()
};
enabled_network.set_allowed_domains(vec!["api.github.com".to_string()]);
let enabled_state = Arc::new(network_proxy_state_for_policy(enabled_network));
let mut enabled_env = HashMap::from([("GH_TOKEN".to_string(), real_token.to_string())]);
enabled_state.virtualize_child_credentials(&mut enabled_env);
let enabled_dummy = enabled_env.get("GH_TOKEN").expect("dummy GitHub token");
let mut enabled_headers = HeaderMap::from_iter([(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {enabled_dummy}"))
.expect("valid authorization header"),
)]);
let expected = if enabled { real_token } else { dummy };
assert_eq!(
req.headers()[header::AUTHORIZATION],
format!("Bearer {expected}")
);
}
}
inject_plaintext_credentials_if_enabled(
enabled_state.as_ref(),
"api.github.com",
&mut enabled_headers,
)
.await
.expect("enabled plaintext injection check should succeed");
assert_eq!(
enabled_headers.get(header::AUTHORIZATION),
Some(&HeaderValue::from_str(&format!("Bearer {real_token}")).unwrap())
);
#[tokio::test]
async fn configured_forward_requests_match_destination_scheme_and_port() {
let real_token = "provider_abcdefghijklmnopqrstuvwx";
for (host, port, prefix, scope) in [
("localhost", 8080, ""),
("127.0.0.1", 8081, ""),
("api.provider.example", 443, "https://"),
("api.provider.example", 8443, "https://"),
]
.into_iter()
.flat_map(|(host, port, prefix)| ["/v1", "/"].map(move |scope| (host, port, prefix, scope)))
{
let state = network_proxy_state_for_policy(NetworkProxyConfig {
credential_broker: true,
dangerously_allow_plaintext_credential_injection: true,
credential_providers: BTreeMap::from([(
"custom".to_string(),
CredentialProviderConfig {
env: vec!["PROVIDER_TOKEN".to_string()],
patterns: vec!["^provider_[a-z]{24}$".to_string()],
url_prefix_from_env: Some("PROVIDER_URL".to_string()),
..CredentialProviderConfig::default()
},
)]),
..NetworkProxyConfig::default()
});
let mut env = HashMap::from([
("PROVIDER_TOKEN".to_string(), real_token.to_string()),
(
"PROVIDER_URL".to_string(),
format!("{prefix}{host}:{port}{scope}"),
),
]);
state.virtualize_child_credentials(&mut env);
let dummy = env.get("PROVIDER_TOKEN").expect("dummy provider token");
assert_ne!(dummy, real_token);
for (uri, inject) in [
(format!("http://{host}:{port}/v1/models"), prefix.is_empty()),
(format!("http://{host}:{}/v1/models", port + 1), false),
(format!("https://{host}:{}/v1/models", port + 1), false),
(
format!("https://{host}:{port}/v1/models"),
!prefix.is_empty(),
),
("/v1/models".to_string(), prefix.is_empty()),
(
format!("https://{host}:{port}/other"),
!prefix.is_empty() && scope == "/",
),
("*".to_string(), prefix.is_empty() && scope == "/"),
] {
let request_uri: rama_http::Uri = uri.parse().unwrap();
let host_header = request_uri
.authority()
.map(|authority| authority.as_str().to_string())
.unwrap_or_else(|| format!("{host}:{port}"));
let mut req = Request::builder()
.method(if uri == "*" {
Method::OPTIONS
} else {
Method::GET
})
.uri(request_uri)
.header(header::HOST, host_header)
.header(header::AUTHORIZATION, format!("Bearer {dummy}"))
.body(Body::empty())
.unwrap();
let context = RequestContext::try_from(&req).unwrap();
inject_forward_request_credentials(&state, &context, &mut req)
.await
.unwrap();
let expected = if inject { real_token } else { dummy };
assert_eq!(
req.headers()[header::AUTHORIZATION],
format!("Bearer {expected}"),
"request: {uri}, configured scope: {scope}"
);
assert_eq!(req.uri().to_string(), uri);
}
}
}
#[tokio::test]
+2
View File
@@ -44,6 +44,8 @@ pub use config::NetworkUnixSocketPermissions;
pub use config::host_and_port_from_network_addr;
pub use config::managed_proxy_ports;
pub use credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY;
pub use credential_broker::CredentialAuthMethod;
pub use credential_broker::CredentialProviderConfig;
pub use credential_broker::brokered_credential_binding_env_keys;
pub use credential_broker::brokered_credential_dummy_env_keys;
pub use credential_broker::brokered_credential_env_keys;
+3 -3
View File
@@ -295,13 +295,13 @@ 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();
let authority = authority_header_value(&target_host, target_port);
parts.uri = build_https_uri(&authority, &path)?;
request_ctx
.policy
.app_state
.inject_request_credentials(&target_host, &mut parts.headers);
.inject_request_credentials(&parts.uri.to_string(), &mut parts.headers);
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
.headers
.insert(HOST, HeaderValue::from_str(&authority)?);
+10
View File
@@ -42,6 +42,16 @@ pub fn is_loopback_host(host: &Host) -> bool {
false
}
/// Matches the destinations in `DEFAULT_NO_PROXY_VALUE`, without resolving DNS.
pub(crate) fn is_default_proxy_bypass_host(host: &str) -> bool {
let host = normalize_host(host);
match host.parse::<IpAddr>() {
Ok(IpAddr::V4(ip)) => ip == Ipv4Addr::LOCALHOST || ip.is_private(),
Ok(IpAddr::V6(ip)) => ip == Ipv6Addr::LOCALHOST,
Err(_) => host == "localhost" || host.ends_with(".localhost"),
}
}
pub fn is_non_public_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_non_public_ipv4(ip),
+55 -4
View File
@@ -894,6 +894,7 @@ impl NetworkProxy {
// Proxy enablement and credential brokerage remain controller-owned.
let mut broker_only_config = config::NetworkProxyConfig {
enabled: config.enabled,
credential_providers: config.credential_providers.clone(),
credential_broker_openai_host: config.credential_broker_openai_host.clone(),
dangerously_allow_plaintext_credential_injection: config
.dangerously_allow_plaintext_credential_injection,
@@ -1031,6 +1032,7 @@ impl NetworkProxy {
addrs: EnvironmentProxyAddrs,
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
client: EnvironmentProxyClient,
environment_id: Option<&str>,
) -> PreparedManagedNetwork {
#[cfg(target_os = "windows")]
let shared_socks_addr = (client == EnvironmentProxyClient::SandboxedProcess)
@@ -1056,7 +1058,13 @@ impl NetworkProxy {
runtime_settings.allow_local_binding,
runtime_settings.mitm_ca_trust_bundle.as_ref(),
);
self.state.virtualize_child_credentials(&mut env);
let credential_environment_id = environment_id.or_else(|| {
self.execution_scope
.as_ref()
.map(|scope| scope.environment_id.as_str())
});
self.state
.virtualize_child_credentials_for_environment(&mut env, credential_environment_id);
if let Some(execution_scope) = self.execution_scope.as_ref() {
env.insert(
PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(),
@@ -1105,11 +1113,13 @@ impl NetworkProxy {
&self,
env: &mut HashMap<String, String>,
addrs: EnvironmentProxyAddrs,
environment_id: Option<&str>,
) {
let prepared = self.prepare_for_addrs(
std::mem::take(env),
addrs,
EnvironmentProxyClient::SandboxedProcess,
environment_id,
);
*env = prepared.env;
}
@@ -1121,9 +1131,40 @@ impl NetworkProxy {
http_addr: self.http_addr,
socks_addr: self.socks_addr,
},
/*environment_id*/ None,
);
}
/// Prepares a snapshot for redaction, retaining dummies even for destinations that bypass
/// the proxy. Actual child execution must use `apply_to_env` instead.
pub fn apply_to_env_for_snapshot(&self, env: &mut HashMap<String, String>) {
self.apply_to_env(env);
let environment_id = self
.execution_scope
.as_ref()
.map(|scope| scope.environment_id.as_str());
self.state
.virtualize_snapshot_credentials(env, environment_id);
}
/// Checks a captured alias against registered child values, including scope-specific dummies
/// and credentials restored for direct destinations. This does not authorize new destinations.
pub fn child_credential_alias_matches(
&self,
key: &str,
value: &str,
snapshot_value: &str,
environment_id: Option<&str>,
) -> bool {
let environment_id = environment_id.or_else(|| {
self.execution_scope
.as_ref()
.map(|scope| scope.environment_id.as_str())
});
self.state
.child_credential_alias_matches(key, value, snapshot_value, environment_id)
}
/// Restores known dummy credentials before a child intentionally leaves managed networking.
pub fn restore_brokered_credentials(
&self,
@@ -1165,7 +1206,7 @@ impl NetworkProxy {
) -> Result<()> {
let addrs =
self.environment_proxy_addrs(environment_id, EnvironmentProxyClient::SandboxedProcess)?;
self.apply_to_env_for_addrs(env, addrs);
self.apply_to_env_for_addrs(env, addrs, Some(environment_id));
Ok(())
}
@@ -1200,7 +1241,12 @@ impl NetworkProxy {
socks_addr: self.socks_addr,
},
};
Ok(self.prepare_for_addrs(env, addrs, EnvironmentProxyClient::SandboxedProcess))
Ok(self.prepare_for_addrs(
env,
addrs,
EnvironmentProxyClient::SandboxedProcess,
environment_id,
))
}
/// Prepares proxy settings for a remote executor whose connection reaches this process through
@@ -1212,7 +1258,12 @@ impl NetworkProxy {
) -> Result<PreparedManagedNetwork> {
let addrs =
self.environment_proxy_addrs(environment_id, EnvironmentProxyClient::TrustedBridge)?;
Ok(self.prepare_for_addrs(env, addrs, EnvironmentProxyClient::TrustedBridge))
Ok(self.prepare_for_addrs(
env,
addrs,
EnvironmentProxyClient::TrustedBridge,
Some(environment_id),
))
}
fn environment_proxy_addrs(
+52 -5
View File
@@ -408,6 +408,15 @@ impl NetworkProxyState {
})
}
pub(crate) fn for_environment_id(&self, environment_id: Option<&str>) -> Self {
Self {
environment_id: environment_id
.map(Arc::from)
.or_else(|| self.environment_id.clone()),
..self.clone()
}
}
pub(crate) fn environment_id(&self) -> Option<&str> {
self.environment_id.as_deref()
}
@@ -444,12 +453,21 @@ impl NetworkProxyState {
}
pub fn virtualize_child_credentials(&self, env: &mut HashMap<String, String>) {
self.virtualize_child_credentials_for_environment(env, self.environment_id());
}
pub(crate) fn virtualize_child_credentials_for_environment(
&self,
env: &mut HashMap<String, String>,
environment_id: Option<&str>,
) {
let parent_env = std::env::vars_os()
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
.collect();
self.credential_broker
.discover_parent_credentials(&parent_env, env);
self.credential_broker.virtualize_child_env(env);
.discover_parent_credentials_for_environment(&parent_env, env, environment_id);
self.credential_broker
.virtualize_child_env_for_environment(env, environment_id);
}
pub(crate) fn restore_child_credentials(
@@ -460,6 +478,26 @@ impl NetworkProxyState {
self.credential_broker.restore_child_env(env, command);
}
pub(crate) fn virtualize_snapshot_credentials(
&self,
env: &mut HashMap<String, String>,
environment_id: Option<&str>,
) {
self.credential_broker
.virtualize_snapshot_env(env, environment_id);
}
pub(crate) fn child_credential_alias_matches(
&self,
key: &str,
value: &str,
snapshot_value: &str,
environment_id: Option<&str>,
) -> bool {
self.credential_broker
.child_alias_matches(key, value, snapshot_value, environment_id)
}
pub(crate) fn restore_and_disable_child_credentials(
&self,
env: &mut HashMap<String, String>,
@@ -482,7 +520,8 @@ impl NetworkProxyState {
}
pub fn inject_request_credentials(&self, host: &str, headers: &mut rama_http::HeaderMap) {
self.credential_broker.inject_request_headers(host, headers);
self.credential_broker
.inject_request_headers_for_environment(host, headers, self.environment_id());
}
pub async fn plaintext_credential_injection_enabled(&self) -> Result<bool> {
@@ -814,7 +853,11 @@ impl NetworkProxyState {
Ok(evaluate_mitm_hooks(&guard.mitm_hooks, host, req))
}
pub(crate) async fn host_mitm_requirement(&self, host: &str) -> Result<HostMitmRequirement> {
pub(crate) async fn host_mitm_requirement(
&self,
host: &str,
port: u16,
) -> Result<HostMitmRequirement> {
self.reload_if_needed().await?;
let normalized_host = normalize_host(host);
let host_has_mitm_hooks = {
@@ -823,7 +866,11 @@ impl NetworkProxyState {
};
Ok(if host_has_mitm_hooks {
HostMitmRequirement::Always
} else if self.credential_broker.host_requires_mitm(&normalized_host) {
} else if self.credential_broker.host_requires_mitm_for_environment(
&normalized_host,
port,
self.environment_id(),
) {
HostMitmRequirement::Tls
} else {
HostMitmRequirement::None
+1 -1
View File
@@ -364,7 +364,7 @@ async fn handle_socks5_tcp(
}
}
let host_mitm_requirement = match app_state.host_mitm_requirement(&host).await {
let host_mitm_requirement = match app_state.host_mitm_requirement(&host, port).await {
Ok(requirement) => requirement,
Err(err) => {
error!("failed to inspect MITM requirements for {host}: {err}");