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
+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]