mirror of
https://github.com/openai/codex.git
synced 2026-05-04 21:32:21 +03: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:
264
codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs
Normal file
264
codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user