mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Restrict cloud task credentials to trusted origins (#41403)
## Why Cloud task requests can use saved ChatGPT credentials, so their destination must be constrained before authentication is loaded or any request is made. ## What changed - Validate `CODEX_CLOUD_TASKS_BASE_URL` against trusted ChatGPT HTTPS origins on port 443 and reject URLs containing user information, queries, or fragments. - Disable redirects for cloud task backend and environment-discovery clients so credentials are never forwarded to a redirect destination. - Normalize the validated base URL before configuring authentication and HTTP clients. ## Testing Add a CLI integration test that verifies a custom cloud backend is rejected without receiving a request, while the staging origin continues to use explicit token authentication. GitOrigin-RevId: f0dddf363b4e44ea3530f81d9a817e978c15ead2
This commit is contained in:
@@ -158,7 +158,27 @@ impl fmt::Debug for Client {
|
||||
|
||||
impl Client {
|
||||
pub fn new(base_url: impl Into<String>, http_client_factory: HttpClientFactory) -> Self {
|
||||
let mut base_url = base_url.into();
|
||||
let http = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging(
|
||||
http_client_factory,
|
||||
ClientRouteClass::Api,
|
||||
);
|
||||
Self::with_http(base_url.into(), http)
|
||||
}
|
||||
|
||||
/// Creates a client that never forwards its credentials to a redirect destination.
|
||||
pub fn new_without_redirects(
|
||||
base_url: impl Into<String>,
|
||||
http_client_factory: HttpClientFactory,
|
||||
) -> Self {
|
||||
let http =
|
||||
RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_redirects_or_request_logging(
|
||||
http_client_factory,
|
||||
ClientRouteClass::Api,
|
||||
);
|
||||
Self::with_http(base_url.into(), http)
|
||||
}
|
||||
|
||||
fn with_http(mut base_url: String, http: RouteAwareClientPool) -> Self {
|
||||
// Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths.
|
||||
// Also trim trailing slashes for consistent URL building.
|
||||
while base_url.ends_with('/') {
|
||||
@@ -170,10 +190,6 @@ impl Client {
|
||||
{
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
let http = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging(
|
||||
http_client_factory,
|
||||
ClientRouteClass::Api,
|
||||
);
|
||||
let path_style = PathStyle::from_base_url(&base_url);
|
||||
Self {
|
||||
base_url,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Exercise Cloud credential isolation through the CLI using a synthetic login.
|
||||
|
||||
use anyhow::Result;
|
||||
use predicates::str::contains;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_list_only_allows_trusted_credential_destinations() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"cli_auth_credentials_store = 'file'\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("auth.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"id_token": "eyJhbGciOiJub25lIn0.e30.c2ln",
|
||||
"access_token": "synthetic-cloud-access-token",
|
||||
"refresh_token": "synthetic-cloud-refresh-token",
|
||||
"account_id": "synthetic-cloud-account",
|
||||
},
|
||||
"last_refresh": chrono::Utc::now(),
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
let command = || -> Result<assert_cmd::Command> {
|
||||
let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
command
|
||||
.current_dir(codex_home.path())
|
||||
.env("CODEX_HOME", codex_home.path())
|
||||
.env_remove("CODEX_ACCESS_TOKEN")
|
||||
.env_remove("OPENAI_API_KEY")
|
||||
.env_remove("CODEX_CLOUD_TASKS_MODE")
|
||||
.env("NO_PROXY", "127.0.0.1,localhost")
|
||||
.env("no_proxy", "127.0.0.1,localhost")
|
||||
.timeout(std::time::Duration::from_secs(15));
|
||||
Ok(command)
|
||||
};
|
||||
// Establish that the isolated profile has a usable ChatGPT login.
|
||||
command()?
|
||||
.args(["login", "status"])
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(contains("Logged in using ChatGPT"));
|
||||
|
||||
let output = command()?
|
||||
.env(
|
||||
"CODEX_CLOUD_TASKS_BASE_URL",
|
||||
format!("{}/backend-api", server.uri()),
|
||||
)
|
||||
.args(["cloud", "list", "--limit", "1", "--json"])
|
||||
.assert()
|
||||
.failure()
|
||||
.get_output()
|
||||
.stderr
|
||||
.clone();
|
||||
insta::assert_snapshot!(String::from_utf8(output)?, @"Error: CODEX_CLOUD_TASKS_BASE_URL must use a trusted HTTPS origin on port 443, without user information, a query, or a fragment; custom backends cannot use saved ChatGPT credentials");
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
|
||||
// Staging must reach explicit PAT authentication. Reject the synthetic PAT locally so
|
||||
// this test never sends a request to the real staging backend.
|
||||
let auth_server = MockServer::start().await;
|
||||
Mock::given(path("/v1/user-auth-credential/whoami"))
|
||||
.and(header("authorization", "Bearer at-synthetic-cloud"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&auth_server)
|
||||
.await;
|
||||
command()?
|
||||
.env(
|
||||
"CODEX_CLOUD_TASKS_BASE_URL",
|
||||
"https://chatgpt-staging.com/backend-api",
|
||||
)
|
||||
.env("CODEX_ACCESS_TOKEN", "at-synthetic-cloud")
|
||||
.env("CODEX_AUTHAPI_BASE_URL", auth_server.uri())
|
||||
.args(["cloud", "list", "--limit", "1", "--json"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("Not signed in. Please run 'codex login'"));
|
||||
auth_server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -33,7 +33,7 @@ impl HttpClient {
|
||||
http_client_factory: codex_http_client::HttpClientFactory,
|
||||
) -> Self {
|
||||
let base_url = base_url.into();
|
||||
let backend = backend::Client::new(base_url.clone(), http_client_factory);
|
||||
let backend = backend::Client::new_without_redirects(base_url.clone(), http_client_factory);
|
||||
Self { base_url, backend }
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
|
||||
);
|
||||
let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
|
||||
let base_url = util::validate_chatgpt_base_url(&base_url)?;
|
||||
|
||||
set_user_agent_suffix(user_agent_suffix);
|
||||
|
||||
@@ -62,7 +63,7 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
|
||||
return Ok(BackendContext {
|
||||
backend: Arc::new(codex_cloud_tasks_mock_client::MockClient),
|
||||
base_url,
|
||||
environment_http: RouteAwareClientPool::new_without_request_logging(
|
||||
environment_http: RouteAwareClientPool::new_without_redirects_or_request_logging(
|
||||
http_client_factory,
|
||||
ClientRouteClass::Api,
|
||||
),
|
||||
@@ -71,7 +72,7 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
|
||||
|
||||
let ua = get_codex_user_agent();
|
||||
let (auth_manager, http_client_factory) = util::load_auth_manager(Some(base_url.clone())).await;
|
||||
let environment_http = RouteAwareClientPool::new_without_request_logging(
|
||||
let environment_http = RouteAwareClientPool::new_without_redirects_or_request_logging(
|
||||
http_client_factory.clone(),
|
||||
ClientRouteClass::Api,
|
||||
);
|
||||
|
||||
@@ -44,6 +44,41 @@ pub fn normalize_base_url(input: &str) -> String {
|
||||
base_url
|
||||
}
|
||||
|
||||
/// Validate the destination before loading saved ChatGPT credentials, including in mock mode:
|
||||
/// environment discovery still makes authenticated HTTP requests when the task backend is mocked.
|
||||
pub(crate) fn validate_chatgpt_base_url(input: &str) -> anyhow::Result<String> {
|
||||
let invalid_url = || {
|
||||
anyhow::anyhow!(
|
||||
"CODEX_CLOUD_TASKS_BASE_URL must use a trusted HTTPS origin on port 443, without user information, a query, or a fragment; custom backends cannot use saved ChatGPT credentials"
|
||||
)
|
||||
};
|
||||
let uri = input.parse::<http::Uri>().map_err(|_| invalid_url())?;
|
||||
let authority = uri
|
||||
.authority()
|
||||
.ok_or_else(invalid_url)?
|
||||
.as_str()
|
||||
.to_ascii_lowercase();
|
||||
if uri.scheme_str() != Some("https")
|
||||
|| !matches!(
|
||||
authority.as_str(),
|
||||
"chatgpt.com"
|
||||
| "chatgpt.com:443"
|
||||
| "chat.openai.com"
|
||||
| "chat.openai.com:443"
|
||||
| "chatgpt-staging.com"
|
||||
| "chatgpt-staging.com:443"
|
||||
)
|
||||
|| uri.query().is_some()
|
||||
|| input.contains('#')
|
||||
{
|
||||
return Err(invalid_url());
|
||||
}
|
||||
Ok(normalize_base_url(&format!(
|
||||
"https://{authority}{}",
|
||||
uri.path()
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn load_auth_manager(
|
||||
chatgpt_base_url: Option<String>,
|
||||
) -> (Option<Arc<AuthManager>>, HttpClientFactory) {
|
||||
|
||||
Reference in New Issue
Block a user