mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Share runtime authentication with model providers and catalogs (#57607)
## Why Model catalog refreshes need to use the same registered actor and credential owner as model requests when runtime authentication is enabled. ## What changed - Add `ProviderAuthContext` to carry credentials and optional runtime authentication into model providers and catalog clients. - Select runtime authentication independently for inference and catalog destinations. Reject unsupported OpenAI origins while preserving provider authentication for other destinations. - Include API organization and project selectors in runtime registration. Reuse registered runtimes across projects within an organization and replace them when the organization changes. - Preserve transport restrictions and account bindings, and strip sensitive headers on cross-origin redirects. - Apply the catalog timeout to authentication and fetching while allowing credential refresh and registration to finish safely. ## Testing Add coverage for runtime-authenticated catalogs, provider isolation, resource selection, gateway authentication, cross-origin redirects, and timeout recovery that preserves registration and refreshed tokens. GitOrigin-RevId: c14f1a02159031ea0af1f9b26ea890f8dca7d6ad
This commit is contained in:
@@ -178,7 +178,9 @@ impl HttpClientBuilder {
|
||||
request_url: &str,
|
||||
route_class: ClientRouteClass,
|
||||
) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
|
||||
if http_client_factory.network_policy().is_managed() {
|
||||
if http_client_factory.network_policy().is_managed()
|
||||
|| http_client_factory.protects_sensitive_headers()
|
||||
{
|
||||
return Ok(crate::RouteAwareClientPool::with_builder(
|
||||
http_client_factory.clone(),
|
||||
route_class,
|
||||
|
||||
@@ -10,6 +10,7 @@ mod custom_ca;
|
||||
mod error;
|
||||
mod native_roots;
|
||||
mod network_policy;
|
||||
mod openai_endpoints;
|
||||
mod openai_headers;
|
||||
mod outbound_proxy;
|
||||
mod request;
|
||||
@@ -47,6 +48,9 @@ pub use crate::network_policy::NetworkPolicy;
|
||||
pub use crate::network_policy::NetworkPolicyController;
|
||||
pub use crate::network_policy::NetworkPolicyDenied;
|
||||
pub use crate::network_policy::NetworkPolicyRevision;
|
||||
pub use crate::openai_endpoints::OpenAiDestination;
|
||||
pub use crate::openai_endpoints::classify_openai_api_url;
|
||||
pub use crate::openai_endpoints::is_openai_api_url;
|
||||
pub use crate::openai_headers::CHATGPT_ACCOUNT_ID_HEADER;
|
||||
pub use crate::openai_headers::OPENAI_ORGANIZATION_HEADER;
|
||||
pub use crate::openai_headers::OPENAI_PROJECT_HEADER;
|
||||
|
||||
@@ -214,6 +214,30 @@ impl NetworkPolicy {
|
||||
self
|
||||
}
|
||||
|
||||
/// Bind a caller's restrictions to a captured credential policy without widening either.
|
||||
/// Different managed controllers or account bindings cannot authorize the same request.
|
||||
pub fn with_account_binding(mut self, credentials: &Self) -> Option<Self> {
|
||||
if let Some(state) = &credentials.state {
|
||||
if let Some(caller) = &self.state {
|
||||
if !Arc::ptr_eq(caller, state) {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
self.state = Some(Arc::clone(state));
|
||||
}
|
||||
}
|
||||
if let (Some(caller), Some(credentials)) = (self.account, credentials.account)
|
||||
&& caller != credentials
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.account = self.account.or(credentials.account);
|
||||
if let Some(endpoints) = &credentials.endpoints {
|
||||
self = self.restrict_to_endpoints(endpoints.as_ref().clone());
|
||||
}
|
||||
Some(self)
|
||||
}
|
||||
|
||||
/// Whether this transport participates in the application policy lifecycle.
|
||||
pub fn is_managed(&self) -> bool {
|
||||
self.state.is_some()
|
||||
|
||||
@@ -7,6 +7,81 @@ use reqwest::Url;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credential_binding_keeps_both_endpoint_restrictions_and_account_revocation() {
|
||||
let controller = NetworkPolicyController::default();
|
||||
let policy = controller.policy();
|
||||
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
|
||||
let a = Url::parse("https://example.com/a").unwrap();
|
||||
let b = Url::parse("https://example.com/b").unwrap();
|
||||
let c = Url::parse("https://example.com/c").unwrap();
|
||||
let credentials = policy
|
||||
.clone()
|
||||
.for_current_account()
|
||||
.restrict_to_endpoints(BTreeSet::from([b.clone(), c.clone()]));
|
||||
let bound = policy
|
||||
.clone()
|
||||
.restrict_to_endpoints(BTreeSet::from([a.clone(), b.clone()]))
|
||||
.with_account_binding(&credentials)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
bound.acquire(&a),
|
||||
Err(NetworkPolicyDenied::Destination)
|
||||
));
|
||||
assert!(matches!(
|
||||
bound.acquire(&c),
|
||||
Err(NetworkPolicyDenied::Destination)
|
||||
));
|
||||
let permit = bound.acquire(&b).unwrap();
|
||||
|
||||
policy.invalidate();
|
||||
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
|
||||
assert!(matches!(
|
||||
bound.acquire(&b),
|
||||
Err(NetworkPolicyDenied::Revoked)
|
||||
));
|
||||
assert_eq!(permit.check(), Err(NetworkPolicyDenied::Revoked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_binding_cannot_replace_a_callers_policy_or_revive_its_account() {
|
||||
let controller = NetworkPolicyController::default();
|
||||
let policy = controller.policy();
|
||||
let allowed = Url::parse("https://example.com/allowed").unwrap();
|
||||
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
|
||||
let caller = policy
|
||||
.clone()
|
||||
.for_current_account()
|
||||
.restrict_to_endpoints(BTreeSet::from([allowed.clone()]));
|
||||
let bound = caller
|
||||
.clone()
|
||||
.with_account_binding(&NetworkPolicy::unmanaged())
|
||||
.unwrap();
|
||||
assert!(bound.acquire(&allowed).is_ok());
|
||||
assert!(matches!(
|
||||
bound.acquire(&Url::parse("https://example.com/other").unwrap()),
|
||||
Err(NetworkPolicyDenied::Destination)
|
||||
));
|
||||
assert!(
|
||||
caller
|
||||
.clone()
|
||||
.with_account_binding(&NetworkPolicyController::default().policy())
|
||||
.is_none()
|
||||
);
|
||||
|
||||
policy.invalidate();
|
||||
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
|
||||
assert!(
|
||||
caller
|
||||
.with_account_binding(&policy.for_current_account())
|
||||
.is_none()
|
||||
);
|
||||
assert!(matches!(
|
||||
bound.acquire(&allowed),
|
||||
Err(NetworkPolicyDenied::Revoked)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_policy_matches_exact_secure_hosts() {
|
||||
let controller = NetworkPolicyController::default();
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Classify OpenAI API destinations without treating an unsupported origin as another provider.
|
||||
|
||||
use crate::is_allowed_chatgpt_host;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OpenAiDestination {
|
||||
Supported,
|
||||
Unsupported,
|
||||
Other,
|
||||
}
|
||||
|
||||
pub fn classify_openai_api_url(destination: &str) -> OpenAiDestination {
|
||||
let Ok(url) = reqwest::Url::parse(destination) else {
|
||||
return OpenAiDestination::Other;
|
||||
};
|
||||
let Some(host) = url.host_str() else {
|
||||
return OpenAiDestination::Other;
|
||||
};
|
||||
let host = host.strip_suffix('.').unwrap_or(host);
|
||||
let openai_owned = [
|
||||
"openai.com",
|
||||
"openai.org",
|
||||
"openai-staging.com",
|
||||
"chatgpt.com",
|
||||
"chatgpt-staging.com",
|
||||
]
|
||||
.iter()
|
||||
.any(|domain| {
|
||||
let host = host.trim_end_matches('.');
|
||||
host == *domain
|
||||
|| host
|
||||
.strip_suffix(*domain)
|
||||
.is_some_and(|prefix| prefix.ends_with('.'))
|
||||
});
|
||||
if !openai_owned {
|
||||
return OpenAiDestination::Other;
|
||||
}
|
||||
let supported_host = matches!(
|
||||
host,
|
||||
"api.openai.com"
|
||||
| "gov.api.openai.com"
|
||||
| "us.api.openai.com"
|
||||
| "eu.api.openai.com"
|
||||
| "au.api.openai.com"
|
||||
| "ca.api.openai.com"
|
||||
| "jp.api.openai.com"
|
||||
| "in.api.openai.com"
|
||||
| "sg.api.openai.com"
|
||||
| "kr.api.openai.com"
|
||||
| "gb.api.openai.com"
|
||||
| "ae.api.openai.com"
|
||||
| "mtls.api.openai.com"
|
||||
| "mtls-us.api.openai.com"
|
||||
| "mtls-eu.api.openai.com"
|
||||
| "api.openai.org"
|
||||
| "api.openai-staging.com"
|
||||
) || is_allowed_chatgpt_host(host);
|
||||
if supported_host
|
||||
&& url.scheme() == "https"
|
||||
&& url.port_or_known_default() == Some(443)
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
{
|
||||
OpenAiDestination::Supported
|
||||
} else {
|
||||
OpenAiDestination::Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_openai_api_url(destination: &str) -> bool {
|
||||
classify_openai_api_url(destination) == OpenAiDestination::Supported
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::OpenAiDestination;
|
||||
use super::classify_openai_api_url;
|
||||
use super::is_openai_api_url;
|
||||
|
||||
#[test]
|
||||
fn unsupported_openai_origins_are_distinct_from_other_providers() {
|
||||
for destination in [
|
||||
"https://unknown.api.openai.com/v1",
|
||||
"https://api.openai.com../v1",
|
||||
"https://api.openai.com:8443/v1",
|
||||
"http://api.openai.com/v1",
|
||||
"https://user@api.openai.com/v1",
|
||||
"https://other.openai.org/v1",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_openai_api_url(destination),
|
||||
OpenAiDestination::Unsupported,
|
||||
"{destination}"
|
||||
);
|
||||
}
|
||||
for destination in [
|
||||
"https://api.openai.com.example.test/v1",
|
||||
"https://example.test/v1",
|
||||
"http://127.0.0.1:8080/v1",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_openai_api_url(destination),
|
||||
OpenAiDestination::Other,
|
||||
"{destination}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_credentials_require_a_trusted_https_origin() {
|
||||
for destination in [
|
||||
"https://api.openai.com/v1",
|
||||
"https://api.openai.com.:443/v1",
|
||||
"https://gov.api.openai.com/v1",
|
||||
"https://eu.api.openai.com/v1",
|
||||
"https://us.api.openai.com/v1",
|
||||
"https://au.api.openai.com/v1",
|
||||
"https://ca.api.openai.com/v1",
|
||||
"https://jp.api.openai.com/v1",
|
||||
"https://in.api.openai.com/v1",
|
||||
"https://sg.api.openai.com/v1",
|
||||
"https://kr.api.openai.com/v1",
|
||||
"https://gb.api.openai.com/v1",
|
||||
"https://ae.api.openai.com/v1",
|
||||
"https://mtls.api.openai.com/v1",
|
||||
"https://mtls-us.api.openai.com/v1",
|
||||
"https://mtls-eu.api.openai.com/v1",
|
||||
"https://api.openai.org/v1",
|
||||
"https://api.openai-staging.com/v1",
|
||||
"https://chatgpt.com/backend-api",
|
||||
"https://chat.openai.com/backend-api",
|
||||
"https://us.chatgpt.com/backend-api",
|
||||
"https://chatgpt-staging.com/backend-api",
|
||||
"https://us.chatgpt-staging.com/backend-api",
|
||||
] {
|
||||
assert!(is_openai_api_url(destination), "{destination}");
|
||||
}
|
||||
for destination in [
|
||||
"http://api.openai.com/v1",
|
||||
"https://api.openai.com:8443/v1",
|
||||
"https://user@api.openai.com/v1",
|
||||
"https://user:password@chatgpt.com/backend-api",
|
||||
"https://api.openai.com.example.test/v1",
|
||||
"https://eu.api.openai.com.example.test/v1",
|
||||
"https://unknown.api.openai.com/v1",
|
||||
"https://evilchatgpt.com/backend-api",
|
||||
"https://chatgpt.com.example.test/backend-api",
|
||||
"https://foo.chat.openai.com/backend-api",
|
||||
"http://127.0.0.1:8080/v1",
|
||||
"https://example.test/v1",
|
||||
] {
|
||||
assert!(!is_openai_api_url(destination), "{destination}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,7 @@ impl fmt::Debug for OutboundProxyRoute {
|
||||
pub struct HttpClientFactory {
|
||||
outbound_proxy_policy: OutboundProxyPolicy,
|
||||
system_proxy_fallback: bool,
|
||||
protect_sensitive_headers: bool,
|
||||
chatgpt_cookie_store: Option<Arc<ChatGptCookieStore>>,
|
||||
network_policy: NetworkPolicy,
|
||||
}
|
||||
@@ -175,6 +176,7 @@ impl PartialEq for HttpClientFactory {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.outbound_proxy_policy == other.outbound_proxy_policy
|
||||
&& self.system_proxy_fallback == other.system_proxy_fallback
|
||||
&& self.protect_sensitive_headers == other.protect_sensitive_headers
|
||||
&& self.network_policy == other.network_policy
|
||||
&& self
|
||||
.chatgpt_cookie_store
|
||||
@@ -194,6 +196,7 @@ impl fmt::Debug for HttpClientFactory {
|
||||
f.debug_struct("HttpClientFactory")
|
||||
.field("outbound_proxy_policy", &self.outbound_proxy_policy)
|
||||
.field("system_proxy_fallback", &self.system_proxy_fallback)
|
||||
.field("protect_sensitive_headers", &self.protect_sensitive_headers)
|
||||
.field("network_policy", &self.network_policy)
|
||||
.finish()
|
||||
}
|
||||
@@ -205,11 +208,24 @@ impl HttpClientFactory {
|
||||
Self {
|
||||
outbound_proxy_policy,
|
||||
system_proxy_fallback: false,
|
||||
protect_sensitive_headers: false,
|
||||
chatgpt_cookie_store: None,
|
||||
network_policy: NetworkPolicy::unmanaged(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows redirects while keeping headers marked sensitive on the original origin.
|
||||
/// Reqwest's default redirect handling only recognizes standard credential headers.
|
||||
pub fn with_sensitive_header_protection(mut self) -> Self {
|
||||
self.protect_sensitive_headers = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether redirect handling removes sensitive headers when the origin changes.
|
||||
pub fn protects_sensitive_headers(&self) -> bool {
|
||||
self.protect_sensitive_headers
|
||||
}
|
||||
|
||||
/// Carries the account/configuration owner's application policy into every transport.
|
||||
pub fn with_network_policy(mut self, policy: NetworkPolicy) -> Self {
|
||||
self.network_policy = policy;
|
||||
|
||||
@@ -564,6 +564,7 @@ impl RouteAwareClientPool {
|
||||
&& (self.http_client_factory.outbound_proxy_policy()
|
||||
== OutboundProxyPolicy::RespectSystemProxy
|
||||
|| self.http_client_factory.network_policy().is_managed()
|
||||
|| self.http_client_factory.protects_sensitive_headers()
|
||||
|| self.rustls_clients.is_some())
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +211,12 @@ impl RouteAwareClientPool {
|
||||
if redirects >= MAX_REDIRECTS {
|
||||
return Err(RouteAwareRequestError::TooManyRedirects);
|
||||
}
|
||||
remove_sensitive_headers(next_request.headers_mut(), ¤t_url, &next_request_url);
|
||||
remove_sensitive_headers(
|
||||
next_request.headers_mut(),
|
||||
¤t_url,
|
||||
&next_request_url,
|
||||
self.http_client_factory.protects_sensitive_headers(),
|
||||
);
|
||||
insert_referer(next_request.headers_mut(), ¤t_url, &next_request_url);
|
||||
request = next_request;
|
||||
redirects += 1;
|
||||
|
||||
@@ -23,6 +23,57 @@ use super::*;
|
||||
use crate::OutboundProxyPolicy;
|
||||
use crate::request_draft::RequestDraft;
|
||||
|
||||
#[tokio::test]
|
||||
async fn redirects_honor_sensitive_header_protection_without_changing_default_behavior() {
|
||||
for policy in [
|
||||
OutboundProxyPolicy::ReqwestDefault,
|
||||
OutboundProxyPolicy::RespectSystemProxy,
|
||||
] {
|
||||
for protect_sensitive_headers in [false, true] {
|
||||
let (target, target_server) = spawn_response_server(vec![
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".into(),
|
||||
]);
|
||||
let (source, source_server) = spawn_response_server(vec![
|
||||
"HTTP/1.1 307 Temporary Redirect\r\nLocation: /same\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(),
|
||||
format!("HTTP/1.1 307 Temporary Redirect\r\nLocation: http://{target}/final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"),
|
||||
]);
|
||||
let url = format!("http://{source}/start");
|
||||
let factory = HttpClientFactory::new(policy);
|
||||
let factory = if protect_sensitive_headers {
|
||||
factory.with_sensitive_header_protection()
|
||||
} else {
|
||||
factory
|
||||
};
|
||||
let client = factory.build_client(&url, ClientRouteClass::Api).unwrap();
|
||||
let mut proof = HeaderValue::from_static("private-proof");
|
||||
proof.set_sensitive(/*val*/ true);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("authorization", "Bearer private-token")
|
||||
.header("x-custom-proof", proof)
|
||||
.header("x-public", "retained")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.text().await.unwrap(), "ok");
|
||||
let source_requests = source_server.join().unwrap();
|
||||
assert_eq!(source_requests.len(), 2);
|
||||
for request in source_requests {
|
||||
assert!(request.contains("authorization: Bearer private-token\r\n"));
|
||||
assert!(request.contains("x-custom-proof: private-proof\r\n"));
|
||||
}
|
||||
let target_requests = target_server.join().unwrap();
|
||||
assert_eq!(target_requests.len(), 1);
|
||||
assert!(!target_requests[0].contains("private-token"));
|
||||
assert_eq!(
|
||||
target_requests[0].contains("private-proof"),
|
||||
!protect_sensitive_headers
|
||||
);
|
||||
assert!(target_requests[0].contains("x-public: retained\r\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_url_auth_is_built_once_after_routing_and_retained_only_on_same_origin_redirects() {
|
||||
let (first_proxy, first_server) = spawn_response_server(vec![
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
//! for the new URL without giving [`crate::RouteAwareClientPool`] an opportunity to resolve the
|
||||
//! redirect destination.
|
||||
//!
|
||||
//! The pool therefore disables reqwest redirects for `RespectSystemProxy`, observes each redirect
|
||||
//! response, and uses these helpers to construct the next request before resolving its route. The
|
||||
//! helpers preserve the relevant reqwest method/body behavior while applying stricter
|
||||
//! The pool disables reqwest redirects when route or sensitive-header policy requires checking
|
||||
//! each hop. It observes each redirect response and uses these helpers to construct the next
|
||||
//! request before resolving its route. The helpers preserve the relevant reqwest
|
||||
//! method/body behavior while applying stricter
|
||||
//! origin-sensitive credential and `Referer` rules at the boundary between requests.
|
||||
|
||||
use std::time::Duration;
|
||||
@@ -99,12 +100,28 @@ pub(super) fn remove_sensitive_headers(
|
||||
headers: &mut HeaderMap,
|
||||
previous: &reqwest::Url,
|
||||
next: &reqwest::Url,
|
||||
protect_sensitive_headers: bool,
|
||||
) {
|
||||
if !same_origin(previous, next) {
|
||||
for header in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION, WWW_AUTHENTICATE] {
|
||||
headers.remove(header);
|
||||
}
|
||||
headers.remove("cookie2");
|
||||
if protect_sensitive_headers {
|
||||
let sensitive: Vec<_> = headers
|
||||
.keys()
|
||||
.filter(|name| {
|
||||
headers
|
||||
.get_all(*name)
|
||||
.iter()
|
||||
.any(http::HeaderValue::is_sensitive)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for name in sensitive {
|
||||
headers.remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,12 @@ fn redirect_credentials_are_retained_only_for_the_same_origin() {
|
||||
(COOKIE, HeaderValue::from_static("session=secret")),
|
||||
]);
|
||||
|
||||
remove_sensitive_headers(&mut headers, &previous, &next);
|
||||
remove_sensitive_headers(
|
||||
&mut headers,
|
||||
&previous,
|
||||
&next,
|
||||
/*protect_sensitive_headers*/ false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user