Skip to main content

chio_egress_contract/
lib.rs

1//! Typed HTTP egress contracts for substrate adapters.
2//!
3//! Kernel, guard, and adapter code paths that initiate outbound HTTP must
4//! declare one of these contracts before a target URL is accepted. Missing or
5//! malformed contract state fails closed.
6
7#![forbid(unsafe_code)]
8
9use std::collections::BTreeSet;
10use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
11
12use serde::{Deserialize, Serialize};
13use url::{Host, Url};
14
15/// Typed egress policy that must be declared before substrate HTTP egress.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct HttpEgressContract {
18    /// Tenant-scoped namespace used by callers to bind egress receipts and
19    /// deployment policy to one authority domain.
20    pub tenant_egress_namespace: String,
21    /// Lowercase HTTP URL schemes allowed by this contract: `http` or `https`.
22    #[serde(default)]
23    pub allowed_schemes: BTreeSet<String>,
24    /// Exact normalized URL authorities allowed by this contract.
25    ///
26    /// Domain authorities are lowercase. IPv6 authorities use brackets, for
27    /// example `[2001:db8::10]:443`.
28    #[serde(default)]
29    pub allowed_authority_set: BTreeSet<String>,
30    /// Reject loopback address literals and localhost names even if an
31    /// authority entry was configured.
32    pub deny_loopback: bool,
33    /// Reject IPv4 and IPv6 link-local address literals.
34    pub deny_link_local: bool,
35    /// Reject IPv6 unique-local address literals.
36    pub deny_ipv6_ula: bool,
37    /// Maximum redirect hop count accepted for a request chain.
38    pub max_redirect_chain: u8,
39    /// Maximum response bytes accepted before the substrate must abort.
40    pub max_response_bytes: u64,
41}
42
43/// A target URL that survived [`HttpEgressContract`] enforcement.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ValidatedHttpEgressTarget {
46    pub tenant_egress_namespace: String,
47    pub scheme: String,
48    pub authority: String,
49}
50
51/// Immutable egress contract that has passed shape validation.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct PreparedHttpEgressContract {
54    contract: HttpEgressContract,
55    tenant_egress_namespace: String,
56}
57
58/// Fail-closed reasons returned by HTTP egress contract enforcement.
59#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
60pub enum HttpEgressError {
61    #[error("outbound HTTP egress requires a declared HttpEgressContract")]
62    MissingContract,
63    #[error("invalid HttpEgressContract: {0}")]
64    InvalidContract(String),
65    #[error("invalid egress URL: {0}")]
66    InvalidUrl(String),
67    #[error("egress URL is missing an authority")]
68    MissingAuthority,
69    #[error("egress URL must not include userinfo")]
70    UserinfoDenied,
71    #[error("scheme {scheme:?} is not allowed by the HttpEgressContract")]
72    SchemeDenied { scheme: String },
73    #[error("authority {authority:?} is not allowed by the HttpEgressContract")]
74    AuthorityDenied { authority: String },
75    #[error("loopback egress target denied: {host}")]
76    LoopbackDenied { host: String },
77    #[error("link-local egress target denied: {host}")]
78    LinkLocalDenied { host: String },
79    #[error("IPv6 unique-local egress target denied: {host}")]
80    Ipv6UlaDenied { host: String },
81    #[error("private network egress target denied: {host}")]
82    PrivateNetworkDenied { host: String },
83    #[error("failed to resolve egress target {host}: {details}")]
84    DnsResolutionFailed { host: String, details: String },
85    #[error("redirect chain length {observed} exceeds maximum {max}")]
86    RedirectLimitExceeded { observed: u8, max: u8 },
87    #[error("response size {observed} exceeds maximum {max}")]
88    ResponseTooLarge { observed: u64, max: u64 },
89}
90
91impl HttpEgressContract {
92    /// Enforce a required contract. `None` is a fail-closed denial.
93    pub fn enforce_required(
94        contract: Option<&Self>,
95        target_url: &str,
96        redirect_chain_len: u8,
97        observed_response_bytes: Option<u64>,
98    ) -> Result<ValidatedHttpEgressTarget, HttpEgressError> {
99        let contract = contract.ok_or(HttpEgressError::MissingContract)?;
100        contract.enforce_attempt(target_url, redirect_chain_len, observed_response_bytes)
101    }
102
103    /// Enforce target, redirect, and optional response-size bounds.
104    pub fn enforce_attempt(
105        &self,
106        target_url: &str,
107        redirect_chain_len: u8,
108        observed_response_bytes: Option<u64>,
109    ) -> Result<ValidatedHttpEgressTarget, HttpEgressError> {
110        self.prepare()?
111            .enforce_attempt(target_url, redirect_chain_len, observed_response_bytes)
112    }
113
114    /// Enforce target URL and redirect hop constraints.
115    pub fn enforce_url(
116        &self,
117        target_url: &str,
118        redirect_chain_len: u8,
119    ) -> Result<ValidatedHttpEgressTarget, HttpEgressError> {
120        self.prepare()?.enforce_url(target_url, redirect_chain_len)
121    }
122
123    /// Enforce target URL, redirect hop constraints, and DNS safety.
124    ///
125    /// Literal IPs and localhost names are enforced directly. Domain names are
126    /// resolved here and every resolved IP is checked against the address-class
127    /// policy before the caller opens a socket.
128    pub fn enforce_url_with_dns(
129        &self,
130        target_url: &str,
131        redirect_chain_len: u8,
132    ) -> Result<ValidatedHttpEgressTarget, HttpEgressError> {
133        self.prepare()?
134            .enforce_url_with_dns(target_url, redirect_chain_len)
135    }
136
137    /// Enforce the response-byte ceiling after headers or streaming counters
138    /// expose the observed size.
139    pub fn enforce_response_bytes(&self, observed: u64) -> Result<(), HttpEgressError> {
140        self.prepare()?.enforce_response_bytes(observed)
141    }
142
143    /// Validate the contract shape before use.
144    pub fn validate(&self) -> Result<(), HttpEgressError> {
145        if self.tenant_egress_namespace.trim().is_empty() {
146            return Err(HttpEgressError::InvalidContract(
147                "tenant_egress_namespace must be non-empty".to_string(),
148            ));
149        }
150        if self.allowed_schemes.is_empty() {
151            return Err(HttpEgressError::InvalidContract(
152                "allowed_schemes must be non-empty".to_string(),
153            ));
154        }
155        if self.allowed_authority_set.is_empty() {
156            return Err(HttpEgressError::InvalidContract(
157                "allowed_authority_set must be non-empty".to_string(),
158            ));
159        }
160        if self.max_response_bytes == 0 {
161            return Err(HttpEgressError::InvalidContract(
162                "max_response_bytes must be greater than zero".to_string(),
163            ));
164        }
165        for scheme in &self.allowed_schemes {
166            validate_scheme_token(scheme)?;
167        }
168        for authority in &self.allowed_authority_set {
169            validate_authority_token(authority)?;
170        }
171        Ok(())
172    }
173
174    /// Validate this raw contract and return an immutable enforcement handle.
175    pub fn prepare(&self) -> Result<PreparedHttpEgressContract, HttpEgressError> {
176        self.validate()?;
177        Ok(PreparedHttpEgressContract {
178            contract: self.clone(),
179            tenant_egress_namespace: self.tenant_egress_namespace.trim().to_string(),
180        })
181    }
182
183    /// Validate that this contract can be used by the reqwest-backed
184    /// dispatcher whose resolver enforces address-class policy at connect
185    /// time.
186    pub fn validate_dispatchable_with_pinned_dns(&self) -> Result<(), HttpEgressError> {
187        self.validate()?;
188        for authority in &self.allowed_authority_set {
189            validate_dispatchable_authority(authority)?;
190        }
191        Ok(())
192    }
193
194    fn enforce_host_class(&self, host: &Host<&str>) -> Result<(), HttpEgressError> {
195        match host {
196            Host::Domain(domain) => {
197                let normalized = normalize_domain_name(domain);
198                self.enforce_loopback_hostname(&normalized)?;
199            }
200            Host::Ipv4(address) => self.enforce_ipv4_class(*address)?,
201            Host::Ipv6(address) => self.enforce_ipv6_class(*address)?,
202        }
203        Ok(())
204    }
205
206    fn enforce_loopback_hostname(&self, normalized: &str) -> Result<(), HttpEgressError> {
207        if self.deny_loopback && matches!(normalized, "localhost" | "localhost.localdomain") {
208            return Err(HttpEgressError::LoopbackDenied {
209                host: normalized.to_string(),
210            });
211        }
212        Ok(())
213    }
214
215    fn enforce_ipv4_class(&self, address: Ipv4Addr) -> Result<(), HttpEgressError> {
216        self.enforce_ip_address_class(address.to_string(), classify_ipv4_address(&address))
217    }
218
219    fn enforce_ipv6_class(&self, address: Ipv6Addr) -> Result<(), HttpEgressError> {
220        if let Some(mapped) = address.to_ipv4_mapped() {
221            return self.enforce_ipv4_class(mapped);
222        }
223        self.enforce_ip_address_class(address.to_string(), classify_ipv6_address(&address))
224    }
225
226    /// Enforce address-class denials after a domain has resolved to an IP.
227    pub fn enforce_resolved_ip(&self, host: &str, address: IpAddr) -> Result<(), HttpEgressError> {
228        match address {
229            IpAddr::V4(address) => {
230                self.enforce_ip_address_class(
231                    format!("{host} resolved to {address}"),
232                    classify_ipv4_address(&address),
233                )?;
234            }
235            IpAddr::V6(address) => {
236                if let Some(mapped) = address.to_ipv4_mapped() {
237                    return self.enforce_resolved_ip(host, IpAddr::V4(mapped));
238                }
239                self.enforce_ip_address_class(
240                    format!("{host} resolved to {address}"),
241                    classify_ipv6_address(&address),
242                )?;
243            }
244        }
245        Ok(())
246    }
247
248    fn enforce_ip_address_class(
249        &self,
250        host: String,
251        class: IpAddressClass,
252    ) -> Result<(), HttpEgressError> {
253        match class {
254            IpAddressClass::Global => Ok(()),
255            IpAddressClass::Loopback => {
256                if self.deny_loopback {
257                    return Err(HttpEgressError::LoopbackDenied { host });
258                }
259                Ok(())
260            }
261            IpAddressClass::LinkLocal => {
262                if self.deny_link_local {
263                    return Err(HttpEgressError::LinkLocalDenied { host });
264                }
265                Ok(())
266            }
267            IpAddressClass::Ipv6Ula => {
268                if self.deny_ipv6_ula {
269                    return Err(HttpEgressError::Ipv6UlaDenied { host });
270                }
271                Ok(())
272            }
273            IpAddressClass::PrivateOrSpecialUse => {
274                Err(HttpEgressError::PrivateNetworkDenied { host })
275            }
276        }
277    }
278
279    fn enforce_domain_resolution(&self, url: &Url) -> Result<(), HttpEgressError> {
280        let Some(Host::Domain(domain)) = url.host() else {
281            return Ok(());
282        };
283        let normalized = normalize_domain_name(domain);
284        self.enforce_loopback_hostname(&normalized)?;
285        let port = url.port_or_known_default().ok_or_else(|| {
286            HttpEgressError::InvalidUrl(format!(
287                "egress URL {url} has no explicit port or known default port"
288            ))
289        })?;
290        let addrs = (normalized.as_str(), port)
291            .to_socket_addrs()
292            .map_err(|error| HttpEgressError::DnsResolutionFailed {
293                host: normalized.clone(),
294                details: error.to_string(),
295            })?;
296        let mut saw_addr = false;
297        for addr in addrs {
298            saw_addr = true;
299            self.enforce_resolved_ip(&normalized, addr.ip())?;
300        }
301        if !saw_addr {
302            return Err(HttpEgressError::DnsResolutionFailed {
303                host: normalized,
304                details: "resolver returned no addresses".to_string(),
305            });
306        }
307        Ok(())
308    }
309
310    fn authority_is_allowed(&self, url: &Url, authority: &str) -> Result<bool, HttpEgressError> {
311        if self.allowed_authority_set.contains(authority) {
312            return Ok(true);
313        }
314        let host_authority = normalized_url_host_authority(url)?;
315        if url.port().is_some()
316            && url.port() == url.port_or_known_default()
317            && self.allowed_authority_set.contains(&host_authority)
318        {
319            return Ok(true);
320        }
321        if let Some(default_port) = url.port_or_known_default() {
322            let default_port_authority = format!("{host_authority}:{default_port}");
323            return Ok(self.allowed_authority_set.contains(&default_port_authority));
324        }
325        Ok(false)
326    }
327
328    #[cfg(feature = "reqwest-egress")]
329    fn enforce_resolver_hostname(&self, host: &str) -> Result<(), HttpEgressError> {
330        self.prepare()?.enforce_resolver_hostname(host)
331    }
332}
333
334impl PreparedHttpEgressContract {
335    /// Return the validated raw contract that backs this prepared handle.
336    #[must_use]
337    pub fn contract(&self) -> &HttpEgressContract {
338        &self.contract
339    }
340
341    /// Return the normalized tenant namespace captured during preparation.
342    #[must_use]
343    pub fn tenant_egress_namespace(&self) -> &str {
344        &self.tenant_egress_namespace
345    }
346
347    /// Return the prepared redirect-hop ceiling.
348    #[must_use]
349    pub fn max_redirect_chain(&self) -> u8 {
350        self.contract.max_redirect_chain
351    }
352
353    /// Return the prepared response-byte ceiling.
354    #[must_use]
355    pub fn max_response_bytes(&self) -> u64 {
356        self.contract.max_response_bytes
357    }
358
359    /// Enforce target, redirect, and optional response-size bounds.
360    pub fn enforce_attempt(
361        &self,
362        target_url: &str,
363        redirect_chain_len: u8,
364        observed_response_bytes: Option<u64>,
365    ) -> Result<ValidatedHttpEgressTarget, HttpEgressError> {
366        let target = self.enforce_url(target_url, redirect_chain_len)?;
367        if let Some(observed) = observed_response_bytes {
368            self.enforce_response_bytes(observed)?;
369        }
370        Ok(target)
371    }
372
373    /// Enforce target URL and redirect hop constraints.
374    pub fn enforce_url(
375        &self,
376        target_url: &str,
377        redirect_chain_len: u8,
378    ) -> Result<ValidatedHttpEgressTarget, HttpEgressError> {
379        if redirect_chain_len > self.contract.max_redirect_chain {
380            return Err(HttpEgressError::RedirectLimitExceeded {
381                observed: redirect_chain_len,
382                max: self.contract.max_redirect_chain,
383            });
384        }
385
386        let url = Url::parse(target_url)
387            .map_err(|error| HttpEgressError::InvalidUrl(error.to_string()))?;
388        if !url.username().is_empty() || url.password().is_some() {
389            return Err(HttpEgressError::UserinfoDenied);
390        }
391
392        let scheme = url.scheme().to_ascii_lowercase();
393        if !self.contract.allowed_schemes.contains(&scheme) {
394            return Err(HttpEgressError::SchemeDenied { scheme });
395        }
396
397        let host = url.host().ok_or(HttpEgressError::MissingAuthority)?;
398        self.contract.enforce_host_class(&host)?;
399
400        let authority = normalized_url_authority(&url)?;
401        if !self.contract.authority_is_allowed(&url, &authority)? {
402            return Err(HttpEgressError::AuthorityDenied { authority });
403        }
404
405        Ok(ValidatedHttpEgressTarget {
406            tenant_egress_namespace: self.tenant_egress_namespace.clone(),
407            scheme,
408            authority,
409        })
410    }
411
412    /// Enforce target URL, redirect hop constraints, and DNS safety.
413    pub fn enforce_url_with_dns(
414        &self,
415        target_url: &str,
416        redirect_chain_len: u8,
417    ) -> Result<ValidatedHttpEgressTarget, HttpEgressError> {
418        let target = self.enforce_url(target_url, redirect_chain_len)?;
419        let url = Url::parse(target_url)
420            .map_err(|error| HttpEgressError::InvalidUrl(error.to_string()))?;
421        self.contract.enforce_domain_resolution(&url)?;
422        Ok(target)
423    }
424
425    /// Enforce the response-byte ceiling.
426    pub fn enforce_response_bytes(&self, observed: u64) -> Result<(), HttpEgressError> {
427        if observed > self.contract.max_response_bytes {
428            return Err(HttpEgressError::ResponseTooLarge {
429                observed,
430                max: self.contract.max_response_bytes,
431            });
432        }
433        Ok(())
434    }
435
436    /// Validate that this prepared contract can be used by the reqwest-backed
437    /// dispatcher whose resolver enforces address-class policy at connect time.
438    pub fn validate_dispatchable_with_pinned_dns(&self) -> Result<(), HttpEgressError> {
439        for authority in &self.contract.allowed_authority_set {
440            validate_dispatchable_authority(authority)?;
441        }
442        Ok(())
443    }
444
445    #[cfg(feature = "reqwest-egress")]
446    fn enforce_resolver_hostname(&self, host: &str) -> Result<(), HttpEgressError> {
447        self.validate_dispatchable_with_pinned_dns()?;
448        let normalized = normalize_domain_name(host);
449        if normalized.is_empty() {
450            return Err(HttpEgressError::MissingAuthority);
451        }
452        self.contract.enforce_loopback_hostname(&normalized)?;
453        for authority in &self.contract.allowed_authority_set {
454            if normalized_authority_host(authority)? == normalized {
455                return Ok(());
456            }
457        }
458        Err(HttpEgressError::AuthorityDenied {
459            authority: normalized,
460        })
461    }
462}
463
464#[cfg(test)]
465#[allow(clippy::expect_used, clippy::unwrap_used)]
466mod tests;
467
468fn validate_scheme_token(scheme: &str) -> Result<(), HttpEgressError> {
469    let bytes = scheme.as_bytes();
470    if bytes.is_empty() || !bytes[0].is_ascii_lowercase() {
471        return Err(HttpEgressError::InvalidContract(format!(
472            "invalid allowed scheme {scheme:?}"
473        )));
474    }
475    if !bytes
476        .iter()
477        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.'))
478    {
479        return Err(HttpEgressError::InvalidContract(format!(
480            "invalid allowed scheme {scheme:?}"
481        )));
482    }
483    if !matches!(scheme, "http" | "https") {
484        return Err(HttpEgressError::InvalidContract(format!(
485            "invalid HTTP egress scheme {scheme:?}; expected \"http\" or \"https\""
486        )));
487    }
488    Ok(())
489}
490
491fn validate_authority_token(authority: &str) -> Result<(), HttpEgressError> {
492    if authority.trim().is_empty()
493        || authority.contains('/')
494        || authority.contains('?')
495        || authority.contains('#')
496        || authority.contains('@')
497        || authority != authority.trim()
498        || authority != authority.to_ascii_lowercase()
499        || authority.ends_with(':')
500    {
501        return Err(HttpEgressError::InvalidContract(format!(
502            "invalid allowed authority {authority:?}"
503        )));
504    }
505    validate_dispatchable_authority(authority)?;
506    let normalized = normalize_authority_token(authority)?;
507    if normalized != authority {
508        return Err(HttpEgressError::InvalidContract(format!(
509            "allowed authority {authority:?} must be normalized as {normalized:?}"
510        )));
511    }
512    Ok(())
513}
514
515fn validate_dispatchable_authority(authority: &str) -> Result<(), HttpEgressError> {
516    let _ = normalized_authority_host(authority)?;
517    Ok(())
518}
519
520fn normalize_authority_token(authority: &str) -> Result<String, HttpEgressError> {
521    let parsed = Url::parse(&format!("http://{authority}/")).map_err(|error| {
522        HttpEgressError::InvalidContract(format!(
523            "invalid allowed authority {authority:?}: {error}"
524        ))
525    })?;
526    let host = parsed.host().ok_or_else(|| {
527        HttpEgressError::InvalidContract(format!("invalid allowed authority {authority:?}"))
528    })?;
529    let normalized_host = match host {
530        Host::Domain(domain) => normalize_domain_name(domain),
531        Host::Ipv4(address) => address.to_string(),
532        Host::Ipv6(address) => format!("[{address}]"),
533    };
534    if authority_has_explicit_port(authority) {
535        let port = parsed.port_or_known_default().ok_or_else(|| {
536            HttpEgressError::InvalidContract(format!("invalid allowed authority {authority:?}"))
537        })?;
538        return Ok(format!("{normalized_host}:{port}"));
539    }
540    Ok(normalized_host)
541}
542
543fn authority_has_explicit_port(authority: &str) -> bool {
544    if authority.starts_with('[') {
545        return authority
546            .rsplit_once(']')
547            .is_some_and(|(_, suffix)| suffix.starts_with(':'));
548    }
549    authority
550        .rsplit_once(':')
551        .is_some_and(|(host, _)| !host.contains(':'))
552}
553
554fn normalized_authority_host(authority: &str) -> Result<String, HttpEgressError> {
555    let parsed = Url::parse(&format!("http://{authority}/")).map_err(|error| {
556        HttpEgressError::InvalidContract(format!(
557            "invalid allowed authority {authority:?}: {error}"
558        ))
559    })?;
560    let host = parsed.host().ok_or_else(|| {
561        HttpEgressError::InvalidContract(format!("invalid allowed authority {authority:?}"))
562    })?;
563    Ok(match host {
564        Host::Domain(domain) => normalize_domain_name(domain),
565        Host::Ipv4(address) => address.to_string(),
566        Host::Ipv6(address) => address.to_string(),
567    })
568}
569
570fn normalize_domain_name(host: &str) -> String {
571    host.trim_end_matches('.').to_ascii_lowercase()
572}
573
574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575enum IpAddressClass {
576    Global,
577    Loopback,
578    LinkLocal,
579    Ipv6Ula,
580    PrivateOrSpecialUse,
581}
582
583fn classify_ipv4_address(address: &Ipv4Addr) -> IpAddressClass {
584    if address.is_loopback() {
585        return IpAddressClass::Loopback;
586    }
587    if address.is_link_local() {
588        return IpAddressClass::LinkLocal;
589    }
590    if is_ipv4_private_or_special_use(address) {
591        return IpAddressClass::PrivateOrSpecialUse;
592    }
593    IpAddressClass::Global
594}
595
596fn classify_ipv6_address(address: &Ipv6Addr) -> IpAddressClass {
597    if let Some(mapped) = address.to_ipv4_mapped() {
598        return classify_ipv4_address(&mapped);
599    }
600    if address.is_loopback() {
601        return IpAddressClass::Loopback;
602    }
603    if is_ipv6_unicast_link_local(address) {
604        return IpAddressClass::LinkLocal;
605    }
606    if is_ipv6_unique_local(address) {
607        return IpAddressClass::Ipv6Ula;
608    }
609    if is_ipv6_private_or_special_use(address) {
610        return IpAddressClass::PrivateOrSpecialUse;
611    }
612    IpAddressClass::Global
613}
614
615fn normalized_url_authority(url: &Url) -> Result<String, HttpEgressError> {
616    let host = normalized_url_host_authority(url)?;
617    Ok(match url.port() {
618        Some(port) => format!("{host}:{port}"),
619        None => host,
620    })
621}
622
623fn normalized_url_host_authority(url: &Url) -> Result<String, HttpEgressError> {
624    let host = url.host().ok_or(HttpEgressError::MissingAuthority)?;
625    Ok(match host {
626        Host::Domain(domain) => domain.trim_end_matches('.').to_ascii_lowercase(),
627        Host::Ipv4(address) => address.to_string(),
628        Host::Ipv6(address) => format!("[{address}]"),
629    })
630}
631
632fn is_ipv6_unicast_link_local(address: &Ipv6Addr) -> bool {
633    address.segments()[0] & 0xffc0 == 0xfe80
634}
635
636fn is_ipv6_unique_local(address: &Ipv6Addr) -> bool {
637    address.segments()[0] & 0xfe00 == 0xfc00
638}
639
640fn is_ipv4_private_or_special_use(address: &Ipv4Addr) -> bool {
641    let octets = address.octets();
642    address.is_private()
643        || address.is_broadcast()
644        || address.is_multicast()
645        || octets[0] == 0
646        || (octets[0] == 100 && (64..=127).contains(&octets[1]))
647        || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
648        || (octets[0] == 192 && octets[1] == 0 && octets[2] == 2)
649        || (octets[0] == 198 && (18..=19).contains(&octets[1]))
650        || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100)
651        || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113)
652        || octets[0] >= 240
653}
654
655fn is_ipv6_private_or_special_use(address: &Ipv6Addr) -> bool {
656    if let Some(mapped) = address.to_ipv4_mapped() {
657        return is_ipv4_private_or_special_use(&mapped);
658    }
659    let segments = address.segments();
660    address.is_unspecified()
661        || address.is_multicast()
662        || (segments[0] == 0x2001 && segments[1] == 0x0db8)
663}
664
665/// Optional helper that pairs a `reqwest::Client` dispatch with
666/// `HttpEgressContract` enforcement. Behind the `reqwest-egress` feature so
667/// callers that already depend on `reqwest` get a single fail-closed entry
668/// point and substrate adapters can keep their existing client builder.
669#[cfg(feature = "reqwest-egress")]
670pub mod reqwest_helper;
671
672#[cfg(feature = "reqwest-egress")]
673#[allow(unused_imports)]
674pub use reqwest_helper::{
675    client_builder_with_contract, send_with_contract, ContractClientBuilder, ContractResponse,
676};
677
678impl HttpEgressContract {
679    /// Construct a permissive contract suitable for tests that exercise a
680    /// wiremock or other local loopback HTTP server. Production code MUST NOT
681    /// use this; production always builds a contract from tenant-scoped
682    /// substrate config.
683    ///
684    /// Loopback, link-local and IPv6 ULA denials are disabled so wiremock's
685    /// `127.0.0.1:<port>` URL is accepted. Authority allow-list is wildcard
686    /// per `allowed_authority_set` semantics: caller supplies the wiremock
687    /// authority. Schemes default to `http` and `https`.
688    pub fn permissive_for_tests(authority: &str) -> Self {
689        let mut allowed_schemes = BTreeSet::new();
690        allowed_schemes.insert("http".to_string());
691        allowed_schemes.insert("https".to_string());
692        let mut allowed_authority_set = BTreeSet::new();
693        allowed_authority_set.insert(authority.to_ascii_lowercase());
694        Self {
695            tenant_egress_namespace: "tests".to_string(),
696            allowed_schemes,
697            allowed_authority_set,
698            deny_loopback: false,
699            deny_link_local: false,
700            deny_ipv6_ula: false,
701            max_redirect_chain: 4,
702            max_response_bytes: 64 * 1024 * 1024,
703        }
704    }
705}