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:
Won Park
2026-08-28 19:44:50 +00:00
committed by copyberry
parent d9511fb788
commit 3ae4225b17
5 changed files with 151 additions and 8 deletions
+21 -5
View File
@@ -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,
+91
View File
@@ -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(())
}
+1 -1
View File
@@ -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 }
}
+3 -2
View File
@@ -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,
);
+35
View File
@@ -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) {