1#![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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct HttpEgressContract {
18 pub tenant_egress_namespace: String,
21 #[serde(default)]
23 pub allowed_schemes: BTreeSet<String>,
24 #[serde(default)]
29 pub allowed_authority_set: BTreeSet<String>,
30 pub deny_loopback: bool,
33 pub deny_link_local: bool,
35 pub deny_ipv6_ula: bool,
37 pub max_redirect_chain: u8,
39 pub max_response_bytes: u64,
41}
42
43#[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#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct PreparedHttpEgressContract {
54 contract: HttpEgressContract,
55 tenant_egress_namespace: String,
56}
57
58#[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 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 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 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 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 pub fn enforce_response_bytes(&self, observed: u64) -> Result<(), HttpEgressError> {
140 self.prepare()?.enforce_response_bytes(observed)
141 }
142
143 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 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 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 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 #[must_use]
337 pub fn contract(&self) -> &HttpEgressContract {
338 &self.contract
339 }
340
341 #[must_use]
343 pub fn tenant_egress_namespace(&self) -> &str {
344 &self.tenant_egress_namespace
345 }
346
347 #[must_use]
349 pub fn max_redirect_chain(&self) -> u8 {
350 self.contract.max_redirect_chain
351 }
352
353 #[must_use]
355 pub fn max_response_bytes(&self) -> u64 {
356 self.contract.max_response_bytes
357 }
358
359 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 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 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 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 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#[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 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}