From 3ae4225b1761c135c6d3bbc1ea0cfcfc95752cdc Mon Sep 17 00:00:00 2001 From: Won Park Date: Fri, 28 Aug 2026 19:09:49 +0000 Subject: [PATCH] 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 --- codex-rs/backend-client/src/client.rs | 26 +++++-- codex-rs/cli/tests/cloud_auth.rs | 91 +++++++++++++++++++++++++ codex-rs/cloud-tasks-client/src/http.rs | 2 +- codex-rs/cloud-tasks/src/lib.rs | 5 +- codex-rs/cloud-tasks/src/util.rs | 35 ++++++++++ 5 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 codex-rs/cli/tests/cloud_auth.rs diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index c6ebad70ee..2da9be27b4 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -158,7 +158,27 @@ impl fmt::Debug for Client { impl Client { pub fn new(base_url: impl Into, 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, + 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, diff --git a/codex-rs/cli/tests/cloud_auth.rs b/codex-rs/cli/tests/cloud_auth.rs new file mode 100644 index 0000000000..01e7eb87e3 --- /dev/null +++ b/codex-rs/cli/tests/cloud_auth.rs @@ -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 { + 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(()) +} diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 6ee2e5f632..d2edcdbe63 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -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 } } diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index fa3fde91f0..c312a1fb7b 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -53,6 +53,7 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result ); 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 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 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, ); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 03754ad550..812d0b4b9a 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -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 { + 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::().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, ) -> (Option>, HttpClientFactory) {