fix(network-proxy): normalize network proxy host matching (#19995)

## Why
The proxy matches allow and deny rules against normalized host strings.
Scoped IPv6 literals can arrive in equivalent forms, such as
`fd00::1%eth0`, `[fd00::1%eth0]`, or `[fd00::1%25eth0]`. Policy should
canonicalize those spellings without erasing scope granularity: an
unscoped rule like `fd00::1` should still cover scoped requests for that
address, while a scoped rule like `fd00::1%eth0` should remain exact to
that scope.

## What changed
- preserve IPv6 scope IDs during host normalization and canonicalize
`%25scope` to `%scope`
- match policy against the exact normalized host plus the unscoped IP
base for scoped literals
- keep local-address explicit allow checks aligned with the same
scoped/unscoped semantics
- add focused coverage for scoped IPv6 normalization, scoped allow
rules, and scoped deny rules in `network-proxy`

## Security impact
A request cannot bypass a broad deny rule by adding an IPv6 scope
suffix. At the same time, scoped policy remains precise:
`deny=fd00::1%eth0` affects that scoped spelling without collapsing
`fd00::1%eth1` onto the same key, and `allow=fe80::1%eth0` does not
implicitly allow other scopes.

## Verification
- `just fmt`
- `cargo test -p codex-network-proxy`
- `just fix -p codex-network-proxy`
- `git diff --check`

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: evawong-oai <evawong@openai.com>
This commit is contained in:
viyatb-oai
2026-04-28 15:50:00 -07:00
committed by GitHub
co-authored by Codex evawong-oai
parent 3291463ff1
commit 2dbde94aa9
4 changed files with 136 additions and 25 deletions
+11 -10
View File
@@ -790,9 +790,16 @@ mod tests {
#[tokio::test]
async fn managed_proxy_builder_uses_loopback_ports() {
let http_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
let http_addr = http_listener.local_addr().unwrap();
let socks_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
let socks_addr = socks_listener.local_addr().unwrap();
drop(http_listener);
drop(socks_listener);
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
proxy_url: "http://127.0.0.1:43128".to_string(),
socks_url: "http://127.0.0.1:48081".to_string(),
proxy_url: format!("http://{http_addr}"),
socks_url: format!("http://{socks_addr}"),
..NetworkProxySettings::default()
}));
let proxy = match NetworkProxy::builder().state(state).build().await {
@@ -812,14 +819,8 @@ mod tests {
assert!(proxy.socks_addr.ip().is_loopback());
#[cfg(target_os = "windows")]
{
assert_eq!(
proxy.http_addr,
"127.0.0.1:43128".parse::<SocketAddr>().unwrap()
);
assert_eq!(
proxy.socks_addr,
"127.0.0.1:48081".parse::<SocketAddr>().unwrap()
);
assert_eq!(proxy.http_addr, http_addr);
assert_eq!(proxy.socks_addr, socks_addr);
}
#[cfg(not(target_os = "windows"))]
{