Skip to main content

codex_network_proxy/
config.rs

1use anyhow::Context;
2use anyhow::Result;
3use anyhow::bail;
4use codex_utils_absolute_path::AbsolutePathBuf;
5use serde::Deserialize;
6use serde::Deserializer;
7use serde::Serialize;
8use serde::Serializer;
9use std::collections::BTreeMap;
10use std::net::IpAddr;
11use std::net::SocketAddr;
12use std::path::Path;
13use tracing::warn;
14use url::Url;
15
16use crate::mitm_hook::MitmHookConfig;
17
18/// Variant order encodes effective precedence for duplicate patterns:
19/// `None < Allow < Deny`, so deny wins over allow when entries conflict.
20#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
21#[serde(rename_all = "lowercase")]
22pub enum NetworkDomainPermission {
23    None,
24    Allow,
25    Deny,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct NetworkDomainPermissionEntry {
30    pub pattern: String,
31    pub permission: NetworkDomainPermission,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct NetworkDomainPermissions {
36    pub entries: Vec<NetworkDomainPermissionEntry>,
37}
38
39impl Serialize for NetworkDomainPermissions {
40    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
41    where
42        S: Serializer,
43    {
44        self.effective_entries()
45            .into_iter()
46            .map(|entry| (entry.pattern, entry.permission))
47            .collect::<BTreeMap<_, _>>()
48            .serialize(serializer)
49    }
50}
51
52impl<'de> Deserialize<'de> for NetworkDomainPermissions {
53    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
54    where
55        D: Deserializer<'de>,
56    {
57        let entries = BTreeMap::<String, NetworkDomainPermission>::deserialize(deserializer)?
58            .into_iter()
59            .map(|(pattern, permission)| NetworkDomainPermissionEntry {
60                pattern,
61                permission,
62            })
63            .collect();
64        Ok(Self { entries })
65    }
66}
67
68impl NetworkDomainPermissions {
69    fn effective_entries(&self) -> Vec<NetworkDomainPermissionEntry> {
70        let mut order = Vec::new();
71        let mut effective_permissions = BTreeMap::new();
72
73        for entry in &self.entries {
74            if !effective_permissions.contains_key(&entry.pattern) {
75                order.push(entry.pattern.clone());
76            }
77
78            let permission = effective_permissions
79                .entry(entry.pattern.clone())
80                .or_insert(entry.permission);
81            if entry.permission > *permission {
82                *permission = entry.permission;
83            }
84        }
85
86        order
87            .into_iter()
88            .filter_map(|pattern| {
89                effective_permissions.remove(&pattern).map(|permission| {
90                    NetworkDomainPermissionEntry {
91                        pattern,
92                        permission,
93                    }
94                })
95            })
96            .collect()
97    }
98}
99
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "lowercase")]
102pub enum NetworkUnixSocketPermission {
103    Allow,
104    Deny,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
108pub struct NetworkUnixSocketPermissions {
109    #[serde(flatten)]
110    pub entries: BTreeMap<String, NetworkUnixSocketPermission>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(default)]
115pub struct NetworkProxyConfig {
116    #[serde(default)]
117    pub enabled: bool,
118    #[serde(default = "default_proxy_url")]
119    pub proxy_url: String,
120    pub enable_socks5: bool,
121    #[serde(default = "default_socks_url")]
122    pub socks_url: String,
123    pub enable_socks5_udp: bool,
124    pub allow_upstream_proxy: bool,
125    #[serde(default)]
126    pub dangerously_allow_non_loopback_proxy: bool,
127    #[serde(default)]
128    pub dangerously_allow_all_unix_sockets: bool,
129    #[serde(default)]
130    pub mode: NetworkMode,
131    #[serde(default)]
132    pub domains: Option<NetworkDomainPermissions>,
133    #[serde(default)]
134    pub unix_sockets: Option<NetworkUnixSocketPermissions>,
135    pub allow_local_binding: bool,
136    #[serde(default)]
137    pub mitm: bool,
138    #[serde(default)]
139    pub credential_broker: bool,
140    #[serde(default)]
141    pub dangerously_allow_plaintext_credential_injection: bool,
142    #[serde(default)]
143    pub mitm_hooks: Vec<MitmHookConfig>,
144}
145
146impl Default for NetworkProxyConfig {
147    fn default() -> Self {
148        Self {
149            enabled: false,
150            proxy_url: default_proxy_url(),
151            enable_socks5: true,
152            socks_url: default_socks_url(),
153            enable_socks5_udp: true,
154            allow_upstream_proxy: true,
155            dangerously_allow_non_loopback_proxy: false,
156            dangerously_allow_all_unix_sockets: false,
157            mode: NetworkMode::default(),
158            domains: None,
159            unix_sockets: None,
160            allow_local_binding: false,
161            mitm: false,
162            credential_broker: false,
163            dangerously_allow_plaintext_credential_injection: false,
164            mitm_hooks: Vec::new(),
165        }
166    }
167}
168
169impl NetworkProxyConfig {
170    pub fn set_credential_broker_enabled(&mut self, enabled: bool) {
171        self.credential_broker = enabled;
172        self.mitm |= enabled;
173    }
174
175    pub fn allowed_domains(&self) -> Option<Vec<String>> {
176        self.domain_entries(NetworkDomainPermission::Allow)
177    }
178
179    pub fn denied_domains(&self) -> Option<Vec<String>> {
180        self.domain_entries(NetworkDomainPermission::Deny)
181    }
182
183    fn domain_entries(&self, permission: NetworkDomainPermission) -> Option<Vec<String>> {
184        self.domains
185            .as_ref()
186            .map(|domains| {
187                domains
188                    .effective_entries()
189                    .iter()
190                    .filter(|entry| entry.permission == permission)
191                    .map(|entry| entry.pattern.clone())
192                    .collect()
193            })
194            .filter(|entries: &Vec<String>| !entries.is_empty())
195    }
196
197    pub fn allow_unix_sockets(&self) -> Vec<String> {
198        self.unix_sockets
199            .as_ref()
200            .map(|unix_sockets| {
201                unix_sockets
202                    .entries
203                    .iter()
204                    .filter(|(_, permission)| {
205                        matches!(permission, NetworkUnixSocketPermission::Allow)
206                    })
207                    .map(|(path, _)| path.clone())
208                    .collect()
209            })
210            .unwrap_or_default()
211    }
212
213    pub fn set_allowed_domains(&mut self, allowed_domains: Vec<String>) {
214        self.set_domain_entries(allowed_domains, NetworkDomainPermission::Allow);
215    }
216
217    pub fn set_denied_domains(&mut self, denied_domains: Vec<String>) {
218        self.set_domain_entries(denied_domains, NetworkDomainPermission::Deny);
219    }
220
221    pub fn upsert_domain_permission(
222        &mut self,
223        host: String,
224        permission: NetworkDomainPermission,
225        normalize: impl Fn(&str) -> String,
226    ) {
227        let mut domains = self.domains.take().unwrap_or_default();
228        let normalized_host = normalize(&host);
229        domains
230            .entries
231            .retain(|entry| normalize(&entry.pattern) != normalized_host);
232        domains.entries.push(NetworkDomainPermissionEntry {
233            pattern: host,
234            permission,
235        });
236        self.domains = (!domains.entries.is_empty()).then_some(domains);
237    }
238
239    pub fn set_allow_unix_sockets(&mut self, allow_unix_sockets: Vec<String>) {
240        self.set_unix_socket_entries(allow_unix_sockets, NetworkUnixSocketPermission::Allow);
241    }
242
243    fn set_domain_entries(&mut self, entries: Vec<String>, permission: NetworkDomainPermission) {
244        let mut domains = self.domains.take().unwrap_or_default();
245        domains
246            .entries
247            .retain(|entry| entry.permission != permission);
248        for entry in entries {
249            if !domains
250                .entries
251                .iter()
252                .any(|existing| existing.pattern == entry && existing.permission == permission)
253            {
254                domains.entries.push(NetworkDomainPermissionEntry {
255                    pattern: entry,
256                    permission,
257                });
258            }
259        }
260        self.domains = (!domains.entries.is_empty()).then_some(domains);
261    }
262
263    fn set_unix_socket_entries(
264        &mut self,
265        entries: Vec<String>,
266        permission: NetworkUnixSocketPermission,
267    ) {
268        let mut unix_sockets = self.unix_sockets.take().unwrap_or_default();
269        unix_sockets
270            .entries
271            .retain(|_, existing| *existing != permission);
272        for entry in entries {
273            unix_sockets.entries.insert(entry, permission);
274        }
275        self.unix_sockets = (!unix_sockets.entries.is_empty()).then_some(unix_sockets);
276    }
277}
278
279#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
280#[serde(rename_all = "lowercase")]
281pub enum NetworkMode {
282    /// Limited (read-only) access: only GET/HEAD/OPTIONS are allowed for HTTP. HTTPS CONNECT is
283    /// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests.
284    /// SOCKS5 UDP and non-HTTPS SOCKS5 TCP remain blocked in limited mode.
285    Limited,
286    /// Full network access: all HTTP methods are allowed. HTTPS CONNECTs are tunneled directly.
287    /// MITM hooks do not currently make full mode enter MITM.
288    #[default]
289    Full,
290}
291
292impl NetworkMode {
293    pub fn allows_method(self, method: &str) -> bool {
294        match self {
295            Self::Full => true,
296            Self::Limited => matches!(method, "GET" | "HEAD" | "OPTIONS"),
297        }
298    }
299}
300
301fn default_proxy_url() -> String {
302    "http://127.0.0.1:3128".to_string()
303}
304
305fn default_socks_url() -> String {
306    "http://127.0.0.1:8081".to_string()
307}
308
309/// Clamp non-loopback bind addresses to loopback unless explicitly allowed.
310fn clamp_non_loopback(
311    addr: SocketAddr,
312    allow_non_loopback: bool,
313    name: &str,
314    override_setting_name: &str,
315) -> SocketAddr {
316    if addr.ip().is_loopback() {
317        return addr;
318    }
319
320    if allow_non_loopback {
321        warn!("DANGEROUS: {name} listening on non-loopback address {addr}");
322        return addr;
323    }
324
325    warn!(
326        "{name} requested non-loopback bind ({addr}); clamping to 127.0.0.1:{port} (set {override_setting_name} to override)",
327        port = addr.port()
328    );
329    SocketAddr::from(([127, 0, 0, 1], addr.port()))
330}
331
332pub(crate) fn clamp_bind_addrs(
333    http_addr: SocketAddr,
334    socks_addr: SocketAddr,
335    cfg: &NetworkProxyConfig,
336) -> (SocketAddr, SocketAddr) {
337    let http_addr = clamp_non_loopback(
338        http_addr,
339        cfg.dangerously_allow_non_loopback_proxy,
340        "HTTP proxy",
341        "dangerously_allow_non_loopback_proxy",
342    );
343    let socks_addr = clamp_non_loopback(
344        socks_addr,
345        cfg.dangerously_allow_non_loopback_proxy,
346        "SOCKS5 proxy",
347        "dangerously_allow_non_loopback_proxy",
348    );
349    if cfg.allow_unix_sockets().is_empty() && !cfg.dangerously_allow_all_unix_sockets {
350        return (http_addr, socks_addr);
351    }
352
353    // `x-unix-socket` is intentionally a local escape hatch. If the proxy is reachable from
354    // outside the machine, it can become a remote bridge into local daemons
355    // (e.g. docker.sock). To avoid footguns, enforce loopback binding whenever unix sockets
356    // are enabled.
357    if cfg.dangerously_allow_non_loopback_proxy && !http_addr.ip().is_loopback() {
358        warn!(
359            "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_proxy and clamping HTTP proxy to loopback"
360        );
361    }
362    if cfg.dangerously_allow_non_loopback_proxy && !socks_addr.ip().is_loopback() {
363        warn!(
364            "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_proxy and clamping SOCKS5 proxy to loopback"
365        );
366    }
367    (
368        SocketAddr::from(([127, 0, 0, 1], http_addr.port())),
369        SocketAddr::from(([127, 0, 0, 1], socks_addr.port())),
370    )
371}
372
373pub struct RuntimeConfig {
374    pub http_addr: SocketAddr,
375    pub socks_addr: SocketAddr,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub(crate) struct UnixStyleAbsolutePath(String);
380
381impl UnixStyleAbsolutePath {
382    fn parse(value: &str) -> Option<Self> {
383        value.starts_with('/').then(|| Self(value.to_string()))
384    }
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub(crate) enum ValidatedUnixSocketPath {
389    Native(AbsolutePathBuf),
390    UnixStyleAbsolute(UnixStyleAbsolutePath),
391}
392
393impl ValidatedUnixSocketPath {
394    pub(crate) fn parse(socket_path: &str) -> Result<Self> {
395        let path = Path::new(socket_path);
396        if path.is_absolute() {
397            let path = AbsolutePathBuf::from_absolute_path(path)
398                .with_context(|| format!("failed to normalize unix socket path {socket_path:?}"))?;
399            return Ok(Self::Native(path));
400        }
401
402        if let Some(path) = UnixStyleAbsolutePath::parse(socket_path) {
403            return Ok(Self::UnixStyleAbsolute(path));
404        }
405
406        bail!("expected an absolute path, got {socket_path:?}");
407    }
408}
409
410pub(crate) fn validate_unix_socket_allowlist_paths(cfg: &NetworkProxyConfig) -> Result<()> {
411    for (index, socket_path) in cfg.allow_unix_sockets().iter().enumerate() {
412        ValidatedUnixSocketPath::parse(socket_path)
413            .with_context(|| format!("invalid network.allow_unix_sockets[{index}]"))?;
414    }
415    Ok(())
416}
417
418pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result<RuntimeConfig> {
419    validate_unix_socket_allowlist_paths(cfg)?;
420
421    let http_addr = resolve_addr(&cfg.proxy_url, /*default_port*/ 3128)
422        .with_context(|| format!("invalid network.proxy_url: {}", cfg.proxy_url))?;
423    let socks_addr = resolve_addr(&cfg.socks_url, /*default_port*/ 8081)
424        .with_context(|| format!("invalid network.socks_url: {}", cfg.socks_url))?;
425    let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, cfg);
426
427    Ok(RuntimeConfig {
428        http_addr,
429        socks_addr,
430    })
431}
432
433/// Returns the sorted loopback ports used by the configured managed proxy listeners.
434pub fn managed_proxy_ports(cfg: &NetworkProxyConfig) -> Result<Vec<u16>> {
435    let runtime = resolve_runtime(cfg)?;
436    if runtime.http_addr.port() == 0 {
437        bail!("network.proxy_url must use a fixed non-zero port for managed proxy provisioning");
438    }
439    let mut ports = vec![runtime.http_addr.port()];
440    if cfg.enable_socks5 {
441        if runtime.socks_addr.port() == 0 {
442            bail!(
443                "network.socks_url must use a fixed non-zero port for managed proxy provisioning"
444            );
445        }
446        ports.push(runtime.socks_addr.port());
447    }
448    ports.sort_unstable();
449    ports.dedup();
450    Ok(ports)
451}
452
453fn resolve_addr(url: &str, default_port: u16) -> Result<SocketAddr> {
454    let addr_parts = parse_host_port(url, default_port)?;
455    let host = if addr_parts.host.eq_ignore_ascii_case("localhost") {
456        "127.0.0.1".to_string()
457    } else {
458        addr_parts.host
459    };
460    match host.parse::<IpAddr>() {
461        Ok(ip) => Ok(SocketAddr::new(ip, addr_parts.port)),
462        Err(_) => Ok(SocketAddr::from(([127, 0, 0, 1], addr_parts.port))),
463    }
464}
465
466pub fn host_and_port_from_network_addr(value: &str, default_port: u16) -> String {
467    let trimmed = value.trim();
468    if trimmed.is_empty() {
469        return "<missing>".to_string();
470    }
471
472    let parts = match parse_host_port(trimmed, default_port) {
473        Ok(parts) => parts,
474        Err(_) => {
475            return format_host_and_port(trimmed, default_port);
476        }
477    };
478
479    format_host_and_port(&parts.host, parts.port)
480}
481
482fn format_host_and_port(host: &str, port: u16) -> String {
483    if host.contains(':') {
484        format!("[{host}]:{port}")
485    } else {
486        format!("{host}:{port}")
487    }
488}
489
490#[derive(Debug, Clone, PartialEq, Eq)]
491struct SocketAddressParts {
492    host: String,
493    port: u16,
494}
495
496fn parse_host_port(url: &str, default_port: u16) -> Result<SocketAddressParts> {
497    let trimmed = url.trim();
498    if trimmed.is_empty() {
499        bail!("missing host in network proxy address: {url}");
500    }
501
502    // Avoid treating unbracketed IPv6 literals like "2001:db8::1" as scheme-prefixed URLs.
503    if matches!(trimmed.parse::<IpAddr>(), Ok(IpAddr::V6(_))) && !trimmed.starts_with('[') {
504        return Ok(SocketAddressParts {
505            host: trimmed.to_string(),
506            port: default_port,
507        });
508    }
509
510    // Prefer the standard URL parser when the input is URL-like. Prefix a scheme when absent so
511    // we still accept loose host:port inputs.
512    let candidate = if trimmed.contains("://") {
513        trimmed.to_string()
514    } else {
515        format!("http://{trimmed}")
516    };
517    if let Ok(parsed) = Url::parse(&candidate)
518        && let Some(host) = parsed.host_str()
519    {
520        let host = host.trim_matches(|c| c == '[' || c == ']');
521        if host.is_empty() {
522            bail!("missing host in network proxy address: {url}");
523        }
524        return Ok(SocketAddressParts {
525            host: host.to_string(),
526            port: parsed.port().unwrap_or(default_port),
527        });
528    }
529
530    parse_host_port_fallback(trimmed, default_port)
531}
532
533fn parse_host_port_fallback(input: &str, default_port: u16) -> Result<SocketAddressParts> {
534    let without_scheme = input
535        .split_once("://")
536        .map(|(_, rest)| rest)
537        .unwrap_or(input);
538    let host_port = without_scheme.split('/').next().unwrap_or(without_scheme);
539    let host_port = host_port
540        .rsplit_once('@')
541        .map(|(_, rest)| rest)
542        .unwrap_or(host_port);
543
544    if host_port.starts_with('[')
545        && let Some(end) = host_port.find(']')
546    {
547        let host = &host_port[1..end];
548        let port = host_port[end + 1..]
549            .strip_prefix(':')
550            .and_then(|port| port.parse::<u16>().ok())
551            .unwrap_or(default_port);
552        if host.is_empty() {
553            bail!("missing host in network proxy address: {input}");
554        }
555        return Ok(SocketAddressParts {
556            host: host.to_string(),
557            port,
558        });
559    }
560
561    // Only treat `host:port` as such when there's a single `:`. This avoids
562    // accidentally interpreting unbracketed IPv6 addresses as `host:port`.
563    if host_port.bytes().filter(|b| *b == b':').count() == 1
564        && let Some((host, port)) = host_port.rsplit_once(':')
565    {
566        if host.is_empty() {
567            bail!("missing host in network proxy address: {input}");
568        }
569        return Ok(SocketAddressParts {
570            host: host.to_string(),
571            port: port.parse::<u16>().ok().unwrap_or(default_port),
572        });
573    }
574
575    if host_port.is_empty() {
576        bail!("missing host in network proxy address: {input}");
577    }
578    Ok(SocketAddressParts {
579        host: host_port.to_string(),
580        port: default_port,
581    })
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    use pretty_assertions::assert_eq;
589
590    fn settings_with_unix_sockets(unix_sockets: &[&str]) -> NetworkProxyConfig {
591        let mut settings = NetworkProxyConfig::default();
592        if !unix_sockets.is_empty() {
593            settings.set_allow_unix_sockets(
594                unix_sockets
595                    .iter()
596                    .map(|path| (*path).to_string())
597                    .collect(),
598            );
599        }
600        settings
601    }
602
603    #[test]
604    fn network_proxy_settings_default_matches_local_use_baseline() {
605        assert_eq!(
606            NetworkProxyConfig::default(),
607            NetworkProxyConfig {
608                enabled: false,
609                proxy_url: "http://127.0.0.1:3128".to_string(),
610                enable_socks5: true,
611                socks_url: "http://127.0.0.1:8081".to_string(),
612                enable_socks5_udp: true,
613                allow_upstream_proxy: true,
614                dangerously_allow_non_loopback_proxy: false,
615                dangerously_allow_all_unix_sockets: false,
616                mode: NetworkMode::Full,
617                domains: None,
618                unix_sockets: None,
619                allow_local_binding: false,
620                mitm: false,
621                credential_broker: false,
622                dangerously_allow_plaintext_credential_injection: false,
623                mitm_hooks: Vec::new(),
624            }
625        );
626    }
627
628    #[test]
629    fn managed_proxy_ports_reject_ephemeral_ports() {
630        let mut config = NetworkProxyConfig {
631            proxy_url: "http://127.0.0.1:0".to_string(),
632            ..Default::default()
633        };
634
635        assert_eq!(
636            managed_proxy_ports(&config).unwrap_err().to_string(),
637            "network.proxy_url must use a fixed non-zero port for managed proxy provisioning"
638        );
639
640        config.proxy_url = "http://127.0.0.1:3128".to_string();
641        config.socks_url = "socks5h://127.0.0.1:48081".to_string();
642        assert_eq!(managed_proxy_ports(&config).unwrap(), vec![3128, 48081]);
643
644        config.socks_url = "socks5h://127.0.0.1:0".to_string();
645        assert_eq!(
646            managed_proxy_ports(&config).unwrap_err().to_string(),
647            "network.socks_url must use a fixed non-zero port for managed proxy provisioning"
648        );
649
650        config.enable_socks5 = false;
651        assert_eq!(managed_proxy_ports(&config).unwrap(), vec![3128]);
652    }
653
654    #[test]
655    fn network_proxy_config_uses_struct_defaults_for_missing_fields() {
656        let config: NetworkProxyConfig = serde_json::from_str(r#"{ "enabled": true }"#).unwrap();
657        let expected = NetworkProxyConfig {
658            enabled: true,
659            ..NetworkProxyConfig::default()
660        };
661
662        assert_eq!(config, expected);
663    }
664
665    #[test]
666    fn set_allowed_domains_preserves_existing_deny_for_same_pattern() {
667        let mut settings = NetworkProxyConfig::default();
668        settings.set_denied_domains(vec!["example.com".to_string()]);
669
670        settings.set_allowed_domains(vec!["example.com".to_string()]);
671
672        assert_eq!(settings.allowed_domains(), None);
673        assert_eq!(
674            settings.denied_domains(),
675            Some(vec!["example.com".to_string()])
676        );
677    }
678
679    #[test]
680    fn network_domain_permissions_serialize_to_effective_map_shape() {
681        let mut settings = NetworkProxyConfig::default();
682        settings.set_denied_domains(vec!["example.com".to_string()]);
683        settings.set_allowed_domains(vec!["example.com".to_string()]);
684        let config = settings;
685
686        let value = serde_json::to_value(&config).unwrap();
687
688        assert_eq!(
689            value,
690            serde_json::json!({
691                "enabled": false,
692                "proxy_url": "http://127.0.0.1:3128",
693                "enable_socks5": true,
694                "socks_url": "http://127.0.0.1:8081",
695                "enable_socks5_udp": true,
696                "allow_upstream_proxy": true,
697                "dangerously_allow_non_loopback_proxy": false,
698                "dangerously_allow_all_unix_sockets": false,
699                "mode": "full",
700                "domains": {
701                    "example.com": "deny",
702                },
703                "unix_sockets": null,
704                "allow_local_binding": false,
705                "mitm": false,
706                "credential_broker": false,
707                "dangerously_allow_plaintext_credential_injection": false,
708                "mitm_hooks": [],
709            })
710        );
711    }
712
713    #[test]
714    fn parse_host_port_defaults_for_empty_string() {
715        assert!(parse_host_port("", /*default_port*/ 1234).is_err());
716    }
717
718    #[test]
719    fn parse_host_port_defaults_for_whitespace() {
720        assert!(parse_host_port("   ", /*default_port*/ 5555).is_err());
721    }
722
723    #[test]
724    fn parse_host_port_parses_host_port_without_scheme() {
725        assert_eq!(
726            parse_host_port("127.0.0.1:8080", /*default_port*/ 3128).unwrap(),
727            SocketAddressParts {
728                host: "127.0.0.1".to_string(),
729                port: 8080,
730            }
731        );
732    }
733
734    #[test]
735    fn parse_host_port_parses_host_port_with_scheme_and_path() {
736        assert_eq!(
737            parse_host_port(
738                "http://example.com:8080/some/path",
739                /*default_port*/ 3128
740            )
741            .unwrap(),
742            SocketAddressParts {
743                host: "example.com".to_string(),
744                port: 8080,
745            }
746        );
747    }
748
749    #[test]
750    fn parse_host_port_strips_userinfo() {
751        assert_eq!(
752            parse_host_port(
753                "http://user:pass@host.example:5555",
754                /*default_port*/ 3128
755            )
756            .unwrap(),
757            SocketAddressParts {
758                host: "host.example".to_string(),
759                port: 5555,
760            }
761        );
762    }
763
764    #[test]
765    fn parse_host_port_parses_ipv6_with_brackets() {
766        assert_eq!(
767            parse_host_port("http://[::1]:9999", /*default_port*/ 3128).unwrap(),
768            SocketAddressParts {
769                host: "::1".to_string(),
770                port: 9999,
771            }
772        );
773    }
774
775    #[test]
776    fn parse_host_port_does_not_treat_unbracketed_ipv6_as_host_port() {
777        assert_eq!(
778            parse_host_port("2001:db8::1", /*default_port*/ 3128).unwrap(),
779            SocketAddressParts {
780                host: "2001:db8::1".to_string(),
781                port: 3128,
782            }
783        );
784    }
785
786    #[test]
787    fn parse_host_port_falls_back_to_default_port_when_port_is_invalid() {
788        assert_eq!(
789            parse_host_port("example.com:notaport", /*default_port*/ 3128).unwrap(),
790            SocketAddressParts {
791                host: "example.com".to_string(),
792                port: 3128,
793            }
794        );
795    }
796
797    #[test]
798    fn host_and_port_from_network_addr_defaults_for_empty_string() {
799        assert_eq!(
800            host_and_port_from_network_addr("", /*default_port*/ 1234),
801            "<missing>"
802        );
803    }
804
805    #[test]
806    fn host_and_port_from_network_addr_formats_ipv6() {
807        assert_eq!(
808            host_and_port_from_network_addr("http://[::1]:8080", /*default_port*/ 3128),
809            "[::1]:8080"
810        );
811    }
812
813    #[test]
814    fn resolve_addr_maps_localhost_to_loopback() {
815        assert_eq!(
816            resolve_addr("localhost", /*default_port*/ 3128).unwrap(),
817            "127.0.0.1:3128".parse::<SocketAddr>().unwrap()
818        );
819    }
820
821    #[test]
822    fn resolve_addr_parses_ip_literals() {
823        assert_eq!(
824            resolve_addr("1.2.3.4", /*default_port*/ 80).unwrap(),
825            "1.2.3.4:80".parse::<SocketAddr>().unwrap()
826        );
827    }
828
829    #[test]
830    fn resolve_addr_parses_ipv6_literals() {
831        assert_eq!(
832            resolve_addr("http://[::1]:8080", /*default_port*/ 3128).unwrap(),
833            "[::1]:8080".parse::<SocketAddr>().unwrap()
834        );
835    }
836
837    #[test]
838    fn resolve_addr_falls_back_to_loopback_for_hostnames() {
839        assert_eq!(
840            resolve_addr("http://example.com:5555", /*default_port*/ 3128).unwrap(),
841            "127.0.0.1:5555".parse::<SocketAddr>().unwrap()
842        );
843    }
844
845    #[test]
846    fn clamp_bind_addrs_allows_non_loopback_when_enabled() {
847        let cfg = NetworkProxyConfig {
848            dangerously_allow_non_loopback_proxy: true,
849            ..Default::default()
850        };
851        let http_addr = "0.0.0.0:3128".parse::<SocketAddr>().unwrap();
852        let socks_addr = "0.0.0.0:8081".parse::<SocketAddr>().unwrap();
853
854        let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg);
855
856        assert_eq!(http_addr, "0.0.0.0:3128".parse::<SocketAddr>().unwrap());
857        assert_eq!(socks_addr, "0.0.0.0:8081".parse::<SocketAddr>().unwrap());
858    }
859
860    #[test]
861    fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() {
862        let cfg = {
863            let mut settings = settings_with_unix_sockets(&["/tmp/docker.sock"]);
864            settings.dangerously_allow_non_loopback_proxy = true;
865            settings
866        };
867        let http_addr = "0.0.0.0:3128".parse::<SocketAddr>().unwrap();
868        let socks_addr = "0.0.0.0:8081".parse::<SocketAddr>().unwrap();
869
870        let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg);
871
872        assert_eq!(http_addr, "127.0.0.1:3128".parse::<SocketAddr>().unwrap());
873        assert_eq!(socks_addr, "127.0.0.1:8081".parse::<SocketAddr>().unwrap());
874    }
875
876    #[test]
877    fn clamp_bind_addrs_forces_loopback_when_all_unix_sockets_enabled() {
878        let cfg = NetworkProxyConfig {
879            dangerously_allow_non_loopback_proxy: true,
880            dangerously_allow_all_unix_sockets: true,
881            ..Default::default()
882        };
883        let http_addr = "0.0.0.0:3128".parse::<SocketAddr>().unwrap();
884        let socks_addr = "0.0.0.0:8081".parse::<SocketAddr>().unwrap();
885
886        let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg);
887
888        assert_eq!(http_addr, "127.0.0.1:3128".parse::<SocketAddr>().unwrap());
889        assert_eq!(socks_addr, "127.0.0.1:8081".parse::<SocketAddr>().unwrap());
890    }
891
892    #[test]
893    fn resolve_runtime_rejects_relative_allow_unix_sockets_entries() {
894        let cfg = settings_with_unix_sockets(&["relative.sock"]);
895
896        let err = match resolve_runtime(&cfg) {
897            Ok(runtime) => panic!(
898                "relative allow_unix_sockets should fail, but resolve_runtime succeeded: {:?}",
899                runtime.http_addr
900            ),
901            Err(err) => err,
902        };
903        assert!(
904            err.to_string().contains("network.allow_unix_sockets[0]"),
905            "error should point at the invalid allow_unix_sockets entry: {err:#}"
906        );
907    }
908
909    #[test]
910    fn resolve_runtime_accepts_unix_style_absolute_allow_unix_sockets_entries() {
911        let cfg = settings_with_unix_sockets(&["/private/tmp/example.sock"]);
912
913        assert!(
914            resolve_runtime(&cfg).is_ok(),
915            "unix-style absolute allow_unix_sockets entry should be accepted"
916        );
917    }
918}