mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user