From 9176718eb74a04098cb4809c1c6395794ec5c091 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Mon, 11 May 2026 20:10:40 +0000 Subject: [PATCH] cloud-tasks: tighten backend request setup Co-authored-by: Codex --- codex-rs/cloud-tasks/src/lib.rs | 27 +++++---- codex-rs/cloud-tasks/src/util.rs | 59 +++++++++++++++++-- .../src/chatgpt_cloudflare_cookies.rs | 13 +--- codex-rs/codex-client/src/chatgpt_hosts.rs | 28 +++++++++ codex-rs/codex-client/src/lib.rs | 1 + 5 files changed, 100 insertions(+), 28 deletions(-) diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index e8d6b545b50d..307db58ca3cf 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -94,10 +94,12 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result std::process::exit(1); } - let auth_provider = codex_model_provider::auth_provider_from_auth(&auth); - http = http.with_auth_provider(auth_provider); - if let Some(acc) = auth.get_account_id() { - append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); + if util::should_attach_chatgpt_auth(&base_url) { + let auth_provider = codex_model_provider::auth_provider_from_auth(&auth); + http = http.with_auth_provider(auth_provider); + if let Some(acc) = auth.get_account_id() { + append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); + } } Ok(BackendContext { @@ -185,7 +187,7 @@ async fn resolve_environment_id(ctx: &BackendContext, requested: &str) -> anyhow return Err(anyhow!("environment id must not be empty")); } let normalized = util::normalize_base_url(&ctx.base_url); - let headers = util::build_chatgpt_headers().await; + let headers = util::build_chatgpt_headers(&normalized).await; let environments = crate::env_detect::list_environments(&normalized, &headers).await?; if environments.is_empty() { return Err(anyhow!( @@ -841,7 +843,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); - let headers = util::build_chatgpt_headers().await; + let headers = util::build_chatgpt_headers(&base_url).await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); }); @@ -857,7 +859,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); // Build headers: UA + ChatGPT auth if available - let headers = util::build_chatgpt_headers().await; + let headers = util::build_chatgpt_headers(&base_url).await; // Run autodetect. If it fails, we keep using "All". let res = crate::env_detect::autodetect_environment_id( @@ -1082,7 +1084,8 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); - let headers = crate::util::build_chatgpt_headers().await; + let headers = + crate::util::build_chatgpt_headers(&base_url).await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); }); @@ -1465,7 +1468,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an let tx = tx.clone(); tokio::spawn(async move { let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); - let headers = crate::util::build_chatgpt_headers().await; + let headers = crate::util::build_chatgpt_headers(&base_url).await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); }); @@ -1654,7 +1657,8 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); - let headers = crate::util::build_chatgpt_headers().await; + let headers = + crate::util::build_chatgpt_headers(&base_url).await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); }); @@ -1830,7 +1834,8 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an let tx = tx.clone(); tokio::spawn(async move { let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); - let headers = crate::util::build_chatgpt_headers().await; + let headers = + crate::util::build_chatgpt_headers(&base_url).await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); }); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 9a5056aa668b..c4c4fe31f0c5 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -1,6 +1,7 @@ use chrono::DateTime; use chrono::Local; use chrono::Utc; +use codex_client::is_allowed_chatgpt_url; use reqwest::header::HeaderMap; use codex_core::config::Config; @@ -32,15 +33,18 @@ pub fn normalize_base_url(input: &str) -> String { while base_url.ends_with('/') { base_url.pop(); } - if (base_url.starts_with("https://chatgpt.com") - || base_url.starts_with("https://chat.openai.com")) - && !base_url.contains("/backend-api") - { + if should_attach_chatgpt_auth(&base_url) && !base_url.contains("/backend-api") { base_url = format!("{base_url}/backend-api"); } base_url } +pub(crate) fn should_attach_chatgpt_auth(base_url: &str) -> bool { + reqwest::Url::parse(base_url) + .ok() + .is_some_and(|url| is_allowed_chatgpt_url(&url)) +} + pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option { // TODO: pass in cli overrides once cloud tasks properly support them. let config = Config::load_with_cli_overrides(Vec::new()).await.ok()?; @@ -57,7 +61,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option HeaderMap { +pub async fn build_chatgpt_headers(base_url: &str) -> HeaderMap { use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; @@ -68,7 +72,8 @@ pub async fn build_chatgpt_headers() -> HeaderMap { USER_AGENT, HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), ); - if let Some(am) = load_auth_manager(/*chatgpt_base_url*/ None).await + if should_attach_chatgpt_auth(base_url) + && let Some(am) = load_auth_manager(/*chatgpt_base_url*/ None).await && let Some(auth) = am.auth().await && auth.uses_codex_backend() { @@ -115,3 +120,45 @@ pub fn format_relative_time(reference: DateTime, ts: DateTime) -> Stri pub fn format_relative_time_now(ts: DateTime) -> String { format_relative_time(Utc::now(), ts) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn normalize_base_url_only_rewrites_allowed_chatgpt_origins() { + assert_eq!( + normalize_base_url("https://chatgpt.com"), + "https://chatgpt.com/backend-api" + ); + assert_eq!( + normalize_base_url("https://chat.openai.com/"), + "https://chat.openai.com/backend-api" + ); + assert_eq!( + normalize_base_url("https://chatgpt.com.fromspeech.ai/"), + "https://chatgpt.com.fromspeech.ai" + ); + assert_eq!( + normalize_base_url("http://chatgpt.com/"), + "http://chatgpt.com" + ); + } + + #[test] + fn allowed_chatgpt_base_urls_require_https_and_exact_first_party_hosts() { + assert!(should_attach_chatgpt_auth( + "https://chatgpt.com/backend-api" + )); + assert!(should_attach_chatgpt_auth( + "https://foo.chatgpt.com/backend-api" + )); + assert!(!should_attach_chatgpt_auth( + "https://chatgpt.com.fromspeech.ai/backend-api" + )); + assert!(!should_attach_chatgpt_auth( + "http://chatgpt.com/backend-api" + )); + } +} diff --git a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs b/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs index c5f4bbd4eb1e..b74ab4dcdca6 100644 --- a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs +++ b/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs @@ -5,7 +5,7 @@ use reqwest::cookie::CookieStore; use reqwest::cookie::Jar; use reqwest::header::HeaderValue; -use crate::chatgpt_hosts::is_allowed_chatgpt_host; +use crate::chatgpt_hosts::is_allowed_chatgpt_url; // WARNING: this store is process-global and may be shared across auth contexts. // It must only ever contain Cloudflare infrastructure cookies. Never extend this @@ -56,16 +56,7 @@ pub fn with_chatgpt_cloudflare_cookie_store( } fn is_chatgpt_cookie_url(url: &reqwest::Url) -> bool { - match url.scheme() { - "https" => {} - _ => return false, - } - - let Some(host) = url.host_str() else { - return false; - }; - - is_allowed_chatgpt_host(host) + is_allowed_chatgpt_url(url) } fn is_allowed_cloudflare_set_cookie_header(header: &HeaderValue) -> bool { diff --git a/codex-rs/codex-client/src/chatgpt_hosts.rs b/codex-rs/codex-client/src/chatgpt_hosts.rs index dd0b99589cad..16d3a46e5d43 100644 --- a/codex-rs/codex-client/src/chatgpt_hosts.rs +++ b/codex-rs/codex-client/src/chatgpt_hosts.rs @@ -10,6 +10,11 @@ pub fn is_allowed_chatgpt_host(host: &str) -> bool { .any(|suffix| host.ends_with(suffix)) } +/// Returns whether `url` is an HTTPS URL targeting a first-party ChatGPT host. +pub fn is_allowed_chatgpt_url(url: &reqwest::Url) -> bool { + url.scheme() == "https" && url.host_str().is_some_and(is_allowed_chatgpt_host) +} + #[cfg(test)] mod tests { use super::*; @@ -36,4 +41,27 @@ mod tests { assert!(!is_allowed_chatgpt_host(host)); } } + + #[test] + fn recognizes_chatgpt_urls_without_origin_confusion() { + for url in [ + "https://chatgpt.com/backend-api", + "https://foo.chatgpt.com/backend-api", + "https://chat.openai.com/backend-api", + "https://api.chatgpt-staging.com/backend-api", + ] { + let parsed = reqwest::Url::parse(url).expect("test URL should parse"); + assert!(is_allowed_chatgpt_url(&parsed)); + } + + for url in [ + "http://chatgpt.com/backend-api", + "https://chatgpt.com.fromspeech.ai/backend-api", + "https://chat.openai.com.evil.example/backend-api", + "https://api.openai.com/v1/responses", + ] { + let parsed = reqwest::Url::parse(url).expect("test URL should parse"); + assert!(!is_allowed_chatgpt_url(&parsed)); + } + } } diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 0f503fb3e219..1564933064ef 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -11,6 +11,7 @@ mod transport; pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store; pub use crate::chatgpt_hosts::is_allowed_chatgpt_host; +pub use crate::chatgpt_hosts::is_allowed_chatgpt_url; pub use crate::custom_ca::BuildCustomCaTransportError; /// Test-only subprocess hook for custom CA coverage. ///