mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
PAC 3 - Add Windows system proxy resolver (#26708)
## Summary Stacked on #26707. Adds the Windows implementation of the shared system-proxy contract. This allows Codex-owned auth clients to use the route Windows selects for each auth URL, including explicit PAC configuration, WPAD auto-detection, static proxies, and bypass rules. The `respect_system_proxy` feature is disabled by default, so existing client behavior remains unchanged unless explicitly enabled. ## Implementation - Adds Windows-only `codex-client` dependencies: - `windows-sys` with `Win32_Foundation` and `Win32_Networking_WinHttp`; - `sha2` for redacted cache keys. - Dispatches system-proxy resolution to `outbound_proxy/windows.rs` on Windows. - Reads the current-user WinHTTP/IE proxy configuration via `WinHttpGetIEProxyConfigForCurrentUser`. - Resolves explicit PAC URLs first, then OS-enabled WPAD auto-detection, then static proxy and bypass settings. - Uses `WinHttpGetProxyForUrl` for PAC/WPAD and maps results into the shared `SystemProxyDecision::{Direct, Proxy, Unavailable}` contract. - Parses `DIRECT`, `PROXY`, `HTTPS`, and keyed static proxy entries. - Treats unsupported schemes such as SOCKS as unavailable so the shared resolver can apply its environment-proxy fallback. - Handles Windows bypass entries, including `<local>` and host, suffix, wildcard, and port matching. - Releases WinHTTP-owned strings with `GlobalFree` and closes sessions with `WinHttpCloseHandle`. - Hashes URL-specific cache keys with SHA-256 so PAC decisions remain URL-specific without retaining raw request URLs or query strings. ## End-user behavior - Disabled/default: existing client behavior is unchanged. - Enabled with `[features.respect_system_proxy]`: - Windows auth clients honor explicit PAC configuration, OS-enabled WPAD, static proxies, and bypass rules; - valid OS/PAC `DIRECT` decisions use a direct connection; - unavailable system resolution falls back to explicit environment proxy variables, then `DIRECT`, through the shared contract from #26707. - Unsupported proxy schemes are not silently translated into a different route. - Custom CA handling remains separate from proxy selection. ## Tests Adds coverage for: - PAC-style proxy tokens such as `PROXY proxy.internal:8080` and `HTTPS proxy.internal:8443`; - static WinHTTP proxy entries keyed by target scheme; - `DIRECT` and unsupported proxy-token behavior; - Windows bypass matching, including `<local>`, wildcard, suffix, and port-qualified entries; - preserving URL-specific PAC cache decisions without retaining the raw URL on Windows.
This commit is contained in:
committed by
GitHub
Unverified
parent
a72433d560
commit
5f129a4703
Generated
+2
@@ -2404,12 +2404,14 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.52.0",
|
||||
"zstd 0.13.3",
|
||||
]
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@ tracing-opentelemetry = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
zstd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
sha2 = { workspace = true }
|
||||
windows-sys = { version = "0.52", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Networking_WinHttp",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -14,12 +14,19 @@ use std::time::Instant;
|
||||
|
||||
use crate::custom_ca::BuildCustomCaTransportError;
|
||||
use crate::custom_ca::build_reqwest_client_with_custom_ca;
|
||||
#[cfg(target_os = "windows")]
|
||||
use sha2::Digest;
|
||||
#[cfg(target_os = "windows")]
|
||||
use sha2::Sha256;
|
||||
use thiserror::Error;
|
||||
|
||||
const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60);
|
||||
const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
/// Coarse semantic bucket for the HTTP or WebSocket client being constructed.
|
||||
///
|
||||
/// This is not the selected proxy route or a concrete endpoint. It labels the
|
||||
@@ -209,11 +216,14 @@ impl RequestOrigin {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "Direct and Proxy are constructed by platform resolvers added in later PRs"
|
||||
#[cfg_attr(
|
||||
not(target_os = "windows"),
|
||||
allow(
|
||||
dead_code,
|
||||
reason = "Direct and Proxy are constructed only by platform-specific resolvers"
|
||||
)
|
||||
)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum SystemProxyDecision {
|
||||
Direct,
|
||||
Proxy { url: String },
|
||||
@@ -230,6 +240,12 @@ fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProx
|
||||
decision
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
|
||||
windows::resolve(request_url, origin)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_platform_system_proxy(
|
||||
_request_url: &str,
|
||||
_origin: &RequestOrigin,
|
||||
@@ -251,24 +267,26 @@ static SYSTEM_PROXY_CACHE: OnceLock<Mutex<HashMap<String, CachedSystemProxyDecis
|
||||
fn cached_system_proxy_decision(request_url: &str) -> Option<SystemProxyDecision> {
|
||||
let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache.lock().ok()?;
|
||||
let cached = cache.get(request_url)?;
|
||||
let key = system_proxy_cache_key(request_url);
|
||||
let cached = cache.get(&key)?;
|
||||
if cached.expires_at > Instant::now() {
|
||||
return Some(cached.decision.clone());
|
||||
}
|
||||
cache.remove(request_url);
|
||||
cache.remove(&key);
|
||||
None
|
||||
}
|
||||
|
||||
fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) {
|
||||
let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Ok(mut cache) = cache.lock() {
|
||||
insert_system_proxy_cache_entry(&mut cache, request_url, decision, Instant::now());
|
||||
let cache_key = system_proxy_cache_key(request_url);
|
||||
insert_system_proxy_cache_entry(&mut cache, &cache_key, decision, Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_system_proxy_cache_entry(
|
||||
cache: &mut HashMap<String, CachedSystemProxyDecision>,
|
||||
request_url: &str,
|
||||
cache_key: &str,
|
||||
decision: SystemProxyDecision,
|
||||
now: Instant,
|
||||
) {
|
||||
@@ -281,16 +299,16 @@ fn insert_system_proxy_cache_entry(
|
||||
|
||||
cache.retain(|_, cached| cached.expires_at > now);
|
||||
if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES
|
||||
&& !cache.contains_key(request_url)
|
||||
&& let Some(request_url_to_evict) = cache
|
||||
&& !cache.contains_key(cache_key)
|
||||
&& let Some(cache_key_to_evict) = cache
|
||||
.iter()
|
||||
.min_by_key(|(_, cached)| cached.expires_at)
|
||||
.map(|(request_url, _)| request_url.clone())
|
||||
.map(|(cache_key, _)| cache_key.clone())
|
||||
{
|
||||
cache.remove(&request_url_to_evict);
|
||||
cache.remove(&cache_key_to_evict);
|
||||
}
|
||||
cache.insert(
|
||||
request_url.to_string(),
|
||||
cache_key.to_string(),
|
||||
CachedSystemProxyDecision {
|
||||
decision,
|
||||
expires_at: now + ttl,
|
||||
@@ -298,6 +316,206 @@ fn insert_system_proxy_cache_entry(
|
||||
);
|
||||
}
|
||||
|
||||
fn system_proxy_cache_key(request_url: &str) -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Keep URL-specific PAC decisions without retaining the raw routed URL.
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"system-proxy-cache-v1\0");
|
||||
hasher.update(request_url.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
request_url.to_string()
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn no_proxy_matches_origin(no_proxy: &str, origin: &RequestOrigin) -> bool {
|
||||
no_proxy
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.any(|entry| no_proxy_entry_matches_origin(entry, origin))
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn no_proxy_entry_matches_origin(entry: &str, origin: &RequestOrigin) -> bool {
|
||||
if entry == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut entry = entry
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| entry.strip_prefix("https://"))
|
||||
.unwrap_or(entry)
|
||||
.trim_matches(['[', ']'])
|
||||
.to_ascii_lowercase();
|
||||
let mut port = None;
|
||||
let parsed_host_port = entry.rsplit_once(':').and_then(|(host, candidate_port)| {
|
||||
if host.contains(':') {
|
||||
return None;
|
||||
}
|
||||
candidate_port
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.map(|parsed_port| (host.to_string(), parsed_port))
|
||||
});
|
||||
if let Some((host, parsed_port)) = parsed_host_port {
|
||||
entry = host;
|
||||
port = Some(parsed_port);
|
||||
}
|
||||
if port.is_some_and(|port| port != origin.port) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(suffix) = entry.strip_prefix('.') {
|
||||
return origin.host == suffix || origin.host.ends_with(&format!(".{suffix}"));
|
||||
}
|
||||
|
||||
if entry.contains('*') {
|
||||
return wildcard_host_match(&entry, &origin.host);
|
||||
}
|
||||
|
||||
origin.host == entry
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn wildcard_host_match(pattern: &str, host: &str) -> bool {
|
||||
let mut remaining = host;
|
||||
let mut first = true;
|
||||
for part in pattern.split('*') {
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if first && !pattern.starts_with('*') {
|
||||
let Some(stripped) = remaining.strip_prefix(part) else {
|
||||
return false;
|
||||
};
|
||||
remaining = stripped;
|
||||
} else {
|
||||
let Some(index) = remaining.find(part) else {
|
||||
return false;
|
||||
};
|
||||
remaining = &remaining[index + part.len()..];
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
pattern.ends_with('*') || remaining.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum ParsedProxyListDecision {
|
||||
Direct,
|
||||
Proxy(String),
|
||||
UnsupportedScheme,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn parse_proxy_list(input: &str, target_scheme: &str) -> ParsedProxyListDecision {
|
||||
let mut saw_unsupported = false;
|
||||
|
||||
{
|
||||
let mut process_token = |token: &str| {
|
||||
let decision = parse_proxy_token(token, target_scheme);
|
||||
match decision {
|
||||
ParsedProxyListDecision::Direct => Some(ParsedProxyListDecision::Direct),
|
||||
ParsedProxyListDecision::Proxy(url) => Some(ParsedProxyListDecision::Proxy(url)),
|
||||
ParsedProxyListDecision::UnsupportedScheme => {
|
||||
saw_unsupported = true;
|
||||
None
|
||||
}
|
||||
ParsedProxyListDecision::Unavailable => None,
|
||||
}
|
||||
};
|
||||
|
||||
for segment in input
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|segment| !segment.is_empty())
|
||||
{
|
||||
let mut parts = segment.split_whitespace();
|
||||
let directive = parts.next();
|
||||
let hostport = parts.next();
|
||||
let extra = parts.next();
|
||||
let is_proxy_directive = matches!(
|
||||
directive.map(str::to_ascii_lowercase).as_deref(),
|
||||
Some("proxy" | "http" | "https" | "socks" | "socks4" | "socks5")
|
||||
) && hostport.is_some()
|
||||
&& extra.is_none();
|
||||
|
||||
if is_proxy_directive {
|
||||
if let Some(decision) = process_token(segment) {
|
||||
return decision;
|
||||
}
|
||||
} else {
|
||||
for token in segment.split_whitespace() {
|
||||
if let Some(decision) = process_token(token) {
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if saw_unsupported {
|
||||
ParsedProxyListDecision::UnsupportedScheme
|
||||
} else {
|
||||
ParsedProxyListDecision::Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn parse_proxy_token(token: &str, target_scheme: &str) -> ParsedProxyListDecision {
|
||||
if token.eq_ignore_ascii_case("DIRECT") {
|
||||
return ParsedProxyListDecision::Direct;
|
||||
}
|
||||
|
||||
if let Some(decision) = parse_proxy_key_token(token, target_scheme) {
|
||||
return decision;
|
||||
}
|
||||
if token.contains('=') {
|
||||
return ParsedProxyListDecision::Unavailable;
|
||||
}
|
||||
|
||||
let mut parts = token.split_whitespace();
|
||||
let directive = parts.next();
|
||||
let hostport = parts.next();
|
||||
if let (Some(directive), Some(hostport), None) = (directive, hostport, parts.next()) {
|
||||
return match directive.to_ascii_lowercase().as_str() {
|
||||
"proxy" | "http" => proxy_url_from_hostport("http", hostport),
|
||||
"https" => proxy_url_from_hostport("https", hostport),
|
||||
"socks" | "socks4" | "socks5" => ParsedProxyListDecision::UnsupportedScheme,
|
||||
_ => ParsedProxyListDecision::Unavailable,
|
||||
};
|
||||
}
|
||||
|
||||
proxy_url_from_hostport("http", token)
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn parse_proxy_key_token(token: &str, target_scheme: &str) -> Option<ParsedProxyListDecision> {
|
||||
let (key, value) = token.split_once('=')?;
|
||||
if key.trim().eq_ignore_ascii_case(target_scheme) {
|
||||
Some(proxy_url_from_hostport("http", value.trim()))
|
||||
} else {
|
||||
Some(ParsedProxyListDecision::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn proxy_url_from_hostport(proxy_scheme: &str, hostport: &str) -> ParsedProxyListDecision {
|
||||
if hostport.is_empty() {
|
||||
return ParsedProxyListDecision::Unavailable;
|
||||
}
|
||||
if hostport.contains("://") {
|
||||
return ParsedProxyListDecision::Proxy(hostport.to_string());
|
||||
}
|
||||
ParsedProxyListDecision::Proxy(format!("{proxy_scheme}://{hostport}"))
|
||||
}
|
||||
|
||||
trait EnvSource {
|
||||
fn var(&self, key: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
use std::ffi::c_void;
|
||||
use std::ptr;
|
||||
|
||||
use super::ParsedProxyListDecision;
|
||||
use super::RequestOrigin;
|
||||
use super::RouteFailureClass;
|
||||
use super::SystemProxyDecision;
|
||||
use super::no_proxy_matches_origin;
|
||||
use super::parse_proxy_list;
|
||||
use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND;
|
||||
use windows_sys::Win32::Foundation::FALSE;
|
||||
use windows_sys::Win32::Foundation::GetLastError;
|
||||
use windows_sys::Win32::Foundation::GlobalFree;
|
||||
use windows_sys::Win32::Foundation::TRUE;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_AUTODETECTION_FAILED;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CANNOT_CONNECT;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CONNECTION_ERROR;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_INVALID_URL;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_LOGIN_FAILURE;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_NAME_NOT_RESOLVED;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_CN_INVALID;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_DATE_INVALID;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REV_FAILED;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REVOKED;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CHANNEL_ERROR;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_FAILURE;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CA;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CERT;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_TIMEOUT;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE;
|
||||
use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNRECOGNIZED_SCHEME;
|
||||
use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NAMED_PROXY;
|
||||
use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NO_PROXY;
|
||||
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DHCP;
|
||||
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DNS_A;
|
||||
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_AUTO_DETECT;
|
||||
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_CONFIG_URL;
|
||||
use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_OPTIONS;
|
||||
use windows_sys::Win32::Networking::WinHttp::WINHTTP_CURRENT_USER_IE_PROXY_CONFIG;
|
||||
use windows_sys::Win32::Networking::WinHttp::WINHTTP_PROXY_INFO;
|
||||
use windows_sys::Win32::Networking::WinHttp::WinHttpCloseHandle;
|
||||
use windows_sys::Win32::Networking::WinHttp::WinHttpGetIEProxyConfigForCurrentUser;
|
||||
use windows_sys::Win32::Networking::WinHttp::WinHttpGetProxyForUrl;
|
||||
use windows_sys::Win32::Networking::WinHttp::WinHttpOpen;
|
||||
use windows_sys::core::PWSTR;
|
||||
|
||||
pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
|
||||
let ie_config = match current_user_ie_proxy_config() {
|
||||
Ok(config) => config,
|
||||
Err(failure) => {
|
||||
return SystemProxyDecision::Unavailable { failure };
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(pac_url) = ie_config.auto_config_url.as_deref() {
|
||||
let decision = resolve_with_pac_url(request_url, origin, pac_url);
|
||||
if !matches!(decision, SystemProxyDecision::Unavailable { .. }) {
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
if ie_config.auto_detect {
|
||||
let decision = resolve_with_auto_detect(request_url, origin);
|
||||
if !matches!(decision, SystemProxyDecision::Unavailable { .. }) {
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(proxy) = ie_config.static_proxy.as_deref() {
|
||||
if ie_config
|
||||
.proxy_bypass
|
||||
.as_deref()
|
||||
.is_some_and(|bypass| proxy_bypass_matches_origin(bypass, origin))
|
||||
{
|
||||
return SystemProxyDecision::Direct;
|
||||
}
|
||||
return proxy_list_decision(proxy, origin);
|
||||
}
|
||||
|
||||
if ie_config.auto_config_url.is_some() || ie_config.auto_detect {
|
||||
SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
||||
}
|
||||
} else {
|
||||
SystemProxyDecision::Direct
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_with_pac_url(
|
||||
request_url: &str,
|
||||
origin: &RequestOrigin,
|
||||
pac_url: &str,
|
||||
) -> SystemProxyDecision {
|
||||
let pac_url = wide_null(pac_url);
|
||||
let options = WINHTTP_AUTOPROXY_OPTIONS {
|
||||
dwFlags: WINHTTP_AUTOPROXY_CONFIG_URL,
|
||||
dwAutoDetectFlags: 0,
|
||||
lpszAutoConfigUrl: pac_url.as_ptr(),
|
||||
lpvReserved: ptr::null_mut(),
|
||||
dwReserved: 0,
|
||||
fAutoLogonIfChallenged: TRUE,
|
||||
};
|
||||
resolve_with_winhttp_options(request_url, origin, options)
|
||||
}
|
||||
|
||||
fn resolve_with_auto_detect(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
|
||||
let options = WINHTTP_AUTOPROXY_OPTIONS {
|
||||
dwFlags: WINHTTP_AUTOPROXY_AUTO_DETECT,
|
||||
dwAutoDetectFlags: WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A,
|
||||
lpszAutoConfigUrl: ptr::null(),
|
||||
lpvReserved: ptr::null_mut(),
|
||||
dwReserved: 0,
|
||||
fAutoLogonIfChallenged: FALSE,
|
||||
};
|
||||
resolve_with_winhttp_options(request_url, origin, options)
|
||||
}
|
||||
|
||||
fn resolve_with_winhttp_options(
|
||||
request_url: &str,
|
||||
origin: &RequestOrigin,
|
||||
mut options: WINHTTP_AUTOPROXY_OPTIONS,
|
||||
) -> SystemProxyDecision {
|
||||
let Some(session) = WinHttpSession::open() else {
|
||||
return SystemProxyDecision::Unavailable {
|
||||
failure: classify_winhttp_error(last_error()),
|
||||
};
|
||||
};
|
||||
|
||||
let request_url = wide_null(request_url);
|
||||
let mut proxy_info = WINHTTP_PROXY_INFO {
|
||||
dwAccessType: WINHTTP_ACCESS_TYPE_NO_PROXY,
|
||||
lpszProxy: ptr::null_mut(),
|
||||
lpszProxyBypass: ptr::null_mut(),
|
||||
};
|
||||
let ok = unsafe {
|
||||
WinHttpGetProxyForUrl(
|
||||
session.0,
|
||||
request_url.as_ptr(),
|
||||
&mut options,
|
||||
&mut proxy_info,
|
||||
)
|
||||
};
|
||||
if ok == FALSE {
|
||||
return SystemProxyDecision::Unavailable {
|
||||
failure: classify_winhttp_error(last_error()),
|
||||
};
|
||||
}
|
||||
|
||||
let proxy_info = ProxyInfo::from_raw(proxy_info);
|
||||
if proxy_info.access_type == WINHTTP_ACCESS_TYPE_NO_PROXY {
|
||||
return SystemProxyDecision::Direct;
|
||||
}
|
||||
if proxy_info.access_type != WINHTTP_ACCESS_TYPE_NAMED_PROXY {
|
||||
return SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
||||
};
|
||||
}
|
||||
let Some(proxy) = proxy_info.proxy.as_deref() else {
|
||||
return SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
||||
};
|
||||
};
|
||||
proxy_list_decision(proxy, origin)
|
||||
}
|
||||
|
||||
fn proxy_list_decision(proxy_list: &str, origin: &RequestOrigin) -> SystemProxyDecision {
|
||||
match parse_proxy_list(proxy_list, &origin.scheme) {
|
||||
ParsedProxyListDecision::Direct => SystemProxyDecision::Direct,
|
||||
ParsedProxyListDecision::Proxy(url) => SystemProxyDecision::Proxy { url },
|
||||
ParsedProxyListDecision::UnsupportedScheme => SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::UnsupportedProxyScheme,
|
||||
},
|
||||
ParsedProxyListDecision::Unavailable => SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn current_user_ie_proxy_config() -> Result<IeProxyConfig, RouteFailureClass> {
|
||||
let mut raw = WINHTTP_CURRENT_USER_IE_PROXY_CONFIG {
|
||||
fAutoDetect: FALSE,
|
||||
lpszAutoConfigUrl: ptr::null_mut(),
|
||||
lpszProxy: ptr::null_mut(),
|
||||
lpszProxyBypass: ptr::null_mut(),
|
||||
};
|
||||
let ok = unsafe { WinHttpGetIEProxyConfigForCurrentUser(&mut raw) };
|
||||
if ok == FALSE {
|
||||
let error = last_error();
|
||||
if error == ERROR_FILE_NOT_FOUND {
|
||||
// Match WinHTTP's fallback by attempting WPAD when no IE proxy settings exist.
|
||||
return Ok(IeProxyConfig {
|
||||
auto_detect: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
return Err(classify_winhttp_error(error));
|
||||
}
|
||||
|
||||
let auto_config_url = GlobalWideString::from_raw(raw.lpszAutoConfigUrl).into_string();
|
||||
let static_proxy = GlobalWideString::from_raw(raw.lpszProxy).into_string();
|
||||
let proxy_bypass = GlobalWideString::from_raw(raw.lpszProxyBypass).into_string();
|
||||
|
||||
Ok(IeProxyConfig {
|
||||
auto_detect: raw.fAutoDetect != FALSE,
|
||||
auto_config_url,
|
||||
static_proxy,
|
||||
proxy_bypass,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct IeProxyConfig {
|
||||
auto_detect: bool,
|
||||
auto_config_url: Option<String>,
|
||||
static_proxy: Option<String>,
|
||||
proxy_bypass: Option<String>,
|
||||
}
|
||||
|
||||
struct ProxyInfo {
|
||||
access_type: u32,
|
||||
proxy: Option<String>,
|
||||
_proxy_bypass: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxyInfo {
|
||||
fn from_raw(raw: WINHTTP_PROXY_INFO) -> Self {
|
||||
Self {
|
||||
access_type: raw.dwAccessType,
|
||||
proxy: GlobalWideString::from_raw(raw.lpszProxy).into_string(),
|
||||
_proxy_bypass: GlobalWideString::from_raw(raw.lpszProxyBypass).into_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobalWideString(PWSTR);
|
||||
|
||||
impl GlobalWideString {
|
||||
fn from_raw(ptr: PWSTR) -> Self {
|
||||
Self(ptr)
|
||||
}
|
||||
|
||||
fn into_string(self) -> Option<String> {
|
||||
if self.0.is_null() {
|
||||
return None;
|
||||
}
|
||||
let string = unsafe { wide_ptr_to_string(self.0) };
|
||||
if string.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GlobalWideString {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe {
|
||||
GlobalFree(self.0.cast::<c_void>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WinHttpSession(*mut c_void);
|
||||
|
||||
impl WinHttpSession {
|
||||
fn open() -> Option<Self> {
|
||||
let agent = wide_null("Codex");
|
||||
let handle = unsafe {
|
||||
WinHttpOpen(
|
||||
agent.as_ptr(),
|
||||
WINHTTP_ACCESS_TYPE_NO_PROXY,
|
||||
ptr::null(),
|
||||
ptr::null(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if handle.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Self(handle))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WinHttpSession {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe {
|
||||
WinHttpCloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_bypass_matches_origin(proxy_bypass: &str, origin: &RequestOrigin) -> bool {
|
||||
proxy_bypass
|
||||
.split(|ch: char| ch == ';' || ch == ',' || ch.is_whitespace())
|
||||
.map(str::trim)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.any(|entry| {
|
||||
if entry.eq_ignore_ascii_case("<local>") {
|
||||
!origin.host.contains('.')
|
||||
} else {
|
||||
no_proxy_matches_origin(entry, origin)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "windows_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
fn wide_null(value: &str) -> Vec<u16> {
|
||||
value.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
unsafe fn wide_ptr_to_string(ptr: PWSTR) -> String {
|
||||
let mut len = 0;
|
||||
while unsafe { *ptr.add(len) } != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
|
||||
String::from_utf16_lossy(slice)
|
||||
}
|
||||
|
||||
fn last_error() -> u32 {
|
||||
unsafe { GetLastError() }
|
||||
}
|
||||
|
||||
fn classify_winhttp_error(code: u32) -> RouteFailureClass {
|
||||
match code {
|
||||
ERROR_WINHTTP_TIMEOUT => RouteFailureClass::ConnectTimeout,
|
||||
ERROR_WINHTTP_LOGIN_FAILURE => RouteFailureClass::ProxyAuthenticationRequired,
|
||||
ERROR_WINHTTP_AUTODETECTION_FAILED
|
||||
| ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT
|
||||
| ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR
|
||||
| ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT
|
||||
| ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE => RouteFailureClass::ProxyResolutionUnavailable,
|
||||
ERROR_WINHTTP_SECURE_CERT_CN_INVALID
|
||||
| ERROR_WINHTTP_SECURE_CERT_DATE_INVALID
|
||||
| ERROR_WINHTTP_SECURE_CERT_REVOKED
|
||||
| ERROR_WINHTTP_SECURE_CERT_REV_FAILED
|
||||
| ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE
|
||||
| ERROR_WINHTTP_SECURE_CHANNEL_ERROR
|
||||
| ERROR_WINHTTP_SECURE_FAILURE
|
||||
| ERROR_WINHTTP_SECURE_INVALID_CA
|
||||
| ERROR_WINHTTP_SECURE_INVALID_CERT => RouteFailureClass::TlsError,
|
||||
ERROR_WINHTTP_INVALID_URL | ERROR_WINHTTP_UNRECOGNIZED_SCHEME => {
|
||||
RouteFailureClass::InvalidProxyConfig
|
||||
}
|
||||
ERROR_WINHTTP_CANNOT_CONNECT
|
||||
| ERROR_WINHTTP_CONNECTION_ERROR
|
||||
| ERROR_WINHTTP_NAME_NOT_RESOLVED => RouteFailureClass::ResolverError,
|
||||
_ => RouteFailureClass::ResolverError,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn proxy_bypass_matches_whitespace_separated_winhttp_entries() {
|
||||
let local_origin = RequestOrigin {
|
||||
scheme: "https".to_string(),
|
||||
host: "intranet".to_string(),
|
||||
port: 443,
|
||||
};
|
||||
assert!(proxy_bypass_matches_origin("<local> *.corp", &local_origin));
|
||||
|
||||
let corp_origin = RequestOrigin {
|
||||
scheme: "https".to_string(),
|
||||
host: "service.corp".to_string(),
|
||||
port: 443,
|
||||
};
|
||||
assert!(proxy_bypass_matches_origin("<local> *.corp", &corp_origin));
|
||||
}
|
||||
@@ -103,6 +103,18 @@ async fn enabled_environment_proxy_routes_request_through_proxy() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_pac_proxy_tokens() {
|
||||
assert_eq!(
|
||||
parse_proxy_list("PROXY proxy.internal:8080; DIRECT", "https"),
|
||||
ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_list("HTTPS proxy.internal:8443", "https"),
|
||||
ParsedProxyListDecision::Proxy("https://proxy.internal:8443".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_system_proxy_decision_is_cached() {
|
||||
let request_url = "https://unavailable-cache.test/oauth/token";
|
||||
@@ -131,3 +143,67 @@ fn system_proxy_cache_is_bounded() {
|
||||
|
||||
assert_eq!(cache.len(), SYSTEM_PROXY_CACHE_MAX_ENTRIES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_static_winhttp_proxy_entries_for_target_scheme() {
|
||||
assert_eq!(
|
||||
parse_proxy_list("http=web-proxy:8080;https=secure-proxy:8443", "https"),
|
||||
ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_list("http=web-proxy:8080 https=secure-proxy:8443", "https"),
|
||||
ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_list("http=web-proxy:8080", "https"),
|
||||
ParsedProxyListDecision::Unavailable
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_list("proxy.internal:8080", "https"),
|
||||
ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_direct_and_unsupported_proxy_tokens() {
|
||||
assert_eq!(
|
||||
parse_proxy_list("DIRECT; PROXY proxy.internal:8080", "https"),
|
||||
ParsedProxyListDecision::Direct
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_list("DIRECT", "https"),
|
||||
ParsedProxyListDecision::Direct
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_list("SOCKS proxy.internal:1080", "https"),
|
||||
ParsedProxyListDecision::UnsupportedScheme
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_matches_exact_suffix_wildcard_and_port() {
|
||||
let origin = RequestOrigin {
|
||||
scheme: "https".to_string(),
|
||||
host: "auth.openai.com".to_string(),
|
||||
port: 443,
|
||||
};
|
||||
assert!(no_proxy_matches_origin("auth.openai.com", &origin));
|
||||
assert!(!no_proxy_matches_origin("openai.com", &origin));
|
||||
assert!(no_proxy_matches_origin(".openai.com", &origin));
|
||||
assert!(no_proxy_matches_origin("*.openai.com", &origin));
|
||||
assert!(no_proxy_matches_origin("auth.openai.com:443", &origin));
|
||||
assert!(!no_proxy_matches_origin("auth.openai.com:8443", &origin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_proxy_cache_key_preserves_url_specific_pac_decisions() {
|
||||
let request_url = "https://auth.openai.com/oauth/token?access_token=secret";
|
||||
let cache_key = system_proxy_cache_key(request_url);
|
||||
|
||||
assert_ne!(
|
||||
cache_key,
|
||||
system_proxy_cache_key("https://auth.openai.com/oauth/revoke")
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(!cache_key.contains(request_url));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user