mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Preserve Cloudfare HTTP cookies in codex (#17783)
## Summary - Adds a process-local, in-memory cookie store for ChatGPT HTTP clients. - Limits cookie storage and replay to a shared ChatGPT host allowlist. - Wires the shared store into the default Codex reqwest client and backend client. - Shares the ChatGPT host allowlist with remote-control URL validation to avoid drift. - Enables reqwest cookie support and updates lockfiles.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use reqwest::cookie::CookieStore;
|
||||
use reqwest::cookie::Jar;
|
||||
use reqwest::header::HeaderValue;
|
||||
|
||||
use crate::chatgpt_hosts::is_allowed_chatgpt_host;
|
||||
|
||||
// WARNING: this store is process-global and may be shared across auth contexts.
|
||||
// It must only ever contain Cloudflare infrastructure cookies. Never extend this
|
||||
// store to persist ChatGPT account, session, auth, or other user-specific cookie
|
||||
// data.
|
||||
static SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE: LazyLock<Arc<ChatGptCloudflareCookieStore>> =
|
||||
LazyLock::new(|| Arc::new(ChatGptCloudflareCookieStore::default()));
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ChatGptCloudflareCookieStore {
|
||||
jar: Jar,
|
||||
}
|
||||
|
||||
impl CookieStore for ChatGptCloudflareCookieStore {
|
||||
fn set_cookies(
|
||||
&self,
|
||||
cookie_headers: &mut dyn Iterator<Item = &HeaderValue>,
|
||||
url: &reqwest::Url,
|
||||
) {
|
||||
if !is_chatgpt_cookie_url(url) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cloudflare_cookie_headers =
|
||||
cookie_headers.filter(|header| is_allowed_cloudflare_set_cookie_header(header));
|
||||
self.jar.set_cookies(&mut cloudflare_cookie_headers, url);
|
||||
}
|
||||
|
||||
fn cookies(&self, url: &reqwest::Url) -> Option<HeaderValue> {
|
||||
if is_chatgpt_cookie_url(url) {
|
||||
self.jar.cookies(url).and_then(only_cloudflare_cookies)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the process-local ChatGPT Cloudflare cookie jar used by Codex HTTP clients.
|
||||
///
|
||||
/// WARNING: this jar is global within the process. It is only acceptable because it hardcodes a
|
||||
/// small allowlist of Cloudflare cookie names and refuses all other ChatGPT cookies. Do not store
|
||||
/// ChatGPT account, session, auth, or other user-specific cookies here. If a future caller needs
|
||||
/// those cookies, the store must be scoped to the auth/session owner instead of shared globally.
|
||||
pub fn with_chatgpt_cloudflare_cookie_store(
|
||||
builder: reqwest::ClientBuilder,
|
||||
) -> reqwest::ClientBuilder {
|
||||
builder.cookie_provider(Arc::clone(&SHARED_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)
|
||||
}
|
||||
|
||||
fn is_allowed_cloudflare_set_cookie_header(header: &HeaderValue) -> bool {
|
||||
header
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(set_cookie_name)
|
||||
.is_some_and(is_allowed_cloudflare_cookie_name)
|
||||
}
|
||||
|
||||
fn set_cookie_name(header: &str) -> Option<&str> {
|
||||
let (name, _) = header.split_once('=')?;
|
||||
let name = name.trim();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
fn only_cloudflare_cookies(header: HeaderValue) -> Option<HeaderValue> {
|
||||
let header = header.to_str().ok()?;
|
||||
let cookies = header
|
||||
.split(';')
|
||||
.filter_map(|cookie| {
|
||||
let cookie = cookie.trim();
|
||||
let name = cookie.split_once('=')?.0.trim();
|
||||
is_allowed_cloudflare_cookie_name(name).then_some(cookie)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
|
||||
if cookies.is_empty() {
|
||||
None
|
||||
} else {
|
||||
HeaderValue::from_str(&cookies).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed_cloudflare_cookie_name(name: &str) -> bool {
|
||||
// Keep this allowlist aligned with Cloudflare's documented service cookies:
|
||||
// https://developers.cloudflare.com/fundamentals/reference/policies-compliances/cloudflare-cookies/
|
||||
matches!(
|
||||
name,
|
||||
"__cf_bm"
|
||||
| "__cflb"
|
||||
| "__cfruid"
|
||||
| "__cfseq"
|
||||
| "__cfwaitingroom"
|
||||
| "_cfuvid"
|
||||
| "cf_clearance"
|
||||
| "cf_ob_info"
|
||||
| "cf_use_ob"
|
||||
) || name.starts_with("cf_chl_")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::cookie::CookieStore;
|
||||
|
||||
#[test]
|
||||
fn stores_and_returns_cloudflare_cookies_for_chatgpt_hosts() {
|
||||
let store = ChatGptCloudflareCookieStore::default();
|
||||
let url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap();
|
||||
let cfuvid = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
|
||||
let clearance =
|
||||
HeaderValue::from_static("cf_clearance=clearance; Path=/; Secure; HttpOnly");
|
||||
|
||||
store.set_cookies(&mut [&cfuvid, &clearance].into_iter(), &url);
|
||||
|
||||
let mut cookies = store
|
||||
.cookies(&url)
|
||||
.and_then(|value| value.to_str().ok().map(str::to_string))
|
||||
.map(|header| {
|
||||
header
|
||||
.split("; ")
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<String>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
cookies.sort();
|
||||
assert_eq!(
|
||||
cookies,
|
||||
vec![
|
||||
"_cfuvid=visitor".to_string(),
|
||||
"cf_clearance=clearance".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_chatgpt_cookies() {
|
||||
let store = ChatGptCloudflareCookieStore::default();
|
||||
let url = reqwest::Url::parse("https://api.openai.com/v1/responses").unwrap();
|
||||
let set_cookie = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
|
||||
|
||||
store.set_cookies(&mut std::iter::once(&set_cookie), &url);
|
||||
|
||||
assert_eq!(store.cookies(&url), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_cloudflare_cookies_for_chatgpt_hosts() {
|
||||
let store = ChatGptCloudflareCookieStore::default();
|
||||
let url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap();
|
||||
let set_cookie = HeaderValue::from_static(
|
||||
"__Secure-next-auth.session-token=secret; Path=/; Secure; HttpOnly",
|
||||
);
|
||||
|
||||
store.set_cookies(&mut std::iter::once(&set_cookie), &url);
|
||||
|
||||
assert_eq!(store.cookies(&url), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_mixed_non_cloudflare_cookies_for_chatgpt_hosts() {
|
||||
let store = ChatGptCloudflareCookieStore::default();
|
||||
let url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap();
|
||||
let cfuvid = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
|
||||
let account_cookie =
|
||||
HeaderValue::from_static("chatgpt_session=secret; Path=/; Secure; HttpOnly");
|
||||
|
||||
store.set_cookies(&mut [&cfuvid, &account_cookie].into_iter(), &url);
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.cookies(&url)
|
||||
.and_then(|value| value.to_str().ok().map(str::to_string)),
|
||||
Some("_cfuvid=visitor".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_return_chatgpt_cloudflare_cookies_for_other_hosts() {
|
||||
let store = ChatGptCloudflareCookieStore::default();
|
||||
let chatgpt_url =
|
||||
reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap();
|
||||
let api_url = reqwest::Url::parse("https://api.openai.com/v1/responses").unwrap();
|
||||
let set_cookie = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
|
||||
|
||||
store.set_cookies(&mut std::iter::once(&set_cookie), &chatgpt_url);
|
||||
|
||||
assert_eq!(store.cookies(&api_url), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_plain_http_chatgpt_cookie_urls() {
|
||||
let store = ChatGptCloudflareCookieStore::default();
|
||||
let http_url = reqwest::Url::parse("http://chatgpt.com/backend-api/codex/responses")
|
||||
.expect("URL should parse");
|
||||
let https_url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses")
|
||||
.expect("URL should parse");
|
||||
let set_cookie = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly");
|
||||
|
||||
store.set_cookies(&mut std::iter::once(&set_cookie), &http_url);
|
||||
|
||||
assert_eq!(store.cookies(&http_url), None);
|
||||
assert_eq!(store.cookies(&https_url), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_allows_https_urls() {
|
||||
let url = reqwest::Url::parse("http://chatgpt.com/backend-api/codex/responses").unwrap();
|
||||
|
||||
assert!(!is_chatgpt_cookie_url(&url));
|
||||
|
||||
let url = reqwest::Url::parse("wss://chatgpt.com/backend-api/codex/responses").unwrap();
|
||||
|
||||
assert!(!is_chatgpt_cookie_url(&url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_only_known_cloudflare_cookie_names() {
|
||||
for name in [
|
||||
"__cf_bm",
|
||||
"__cflb",
|
||||
"__cfruid",
|
||||
"__cfseq",
|
||||
"__cfwaitingroom",
|
||||
"_cfuvid",
|
||||
"cf_clearance",
|
||||
"cf_ob_info",
|
||||
"cf_use_ob",
|
||||
"cf_chl_rc_i",
|
||||
] {
|
||||
assert!(is_allowed_cloudflare_cookie_name(name));
|
||||
}
|
||||
|
||||
for name in [
|
||||
"__Secure-next-auth.session-token",
|
||||
"chatgpt_session",
|
||||
"oai-auth-token",
|
||||
"not_cf_clearance",
|
||||
] {
|
||||
assert!(!is_allowed_cloudflare_cookie_name(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/// Returns whether `host` is one of the ChatGPT hosts Codex is allowed to treat
|
||||
/// as first-party ChatGPT traffic.
|
||||
pub fn is_allowed_chatgpt_host(host: &str) -> bool {
|
||||
const EXACT_HOSTS: &[&str] = &["chatgpt.com", "chat.openai.com", "chatgpt-staging.com"];
|
||||
const SUBDOMAIN_SUFFIXES: &[&str] = &[".chatgpt.com", ".chatgpt-staging.com"];
|
||||
|
||||
EXACT_HOSTS.contains(&host)
|
||||
|| SUBDOMAIN_SUFFIXES
|
||||
.iter()
|
||||
.any(|suffix| host.ends_with(suffix))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognizes_chatgpt_hosts_without_suffix_tricks() {
|
||||
for host in [
|
||||
"chatgpt.com",
|
||||
"foo.chatgpt.com",
|
||||
"staging.chatgpt.com",
|
||||
"chat.openai.com",
|
||||
"chatgpt-staging.com",
|
||||
"api.chatgpt-staging.com",
|
||||
] {
|
||||
assert!(is_allowed_chatgpt_host(host));
|
||||
}
|
||||
|
||||
for host in [
|
||||
"evilchatgpt.com",
|
||||
"chatgpt.com.evil.example",
|
||||
"api.openai.com",
|
||||
"foo.chat.openai.com",
|
||||
] {
|
||||
assert!(!is_allowed_chatgpt_host(host));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
mod chatgpt_cloudflare_cookies;
|
||||
mod chatgpt_hosts;
|
||||
mod custom_ca;
|
||||
mod default_client;
|
||||
mod error;
|
||||
@@ -7,6 +9,8 @@ mod sse;
|
||||
mod telemetry;
|
||||
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::custom_ca::BuildCustomCaTransportError;
|
||||
/// Test-only subprocess hook for custom CA coverage.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user