Skip to main content

rskit_httpclient/
destination.rs

1//! Destination validation policy for outbound HTTP requests.
2
3use std::net::{Ipv4Addr, Ipv6Addr};
4
5use reqwest::Url;
6use rskit_errors::{AppError, AppResult};
7
8const DEFAULT_SCHEMES: [&str; 2] = ["http", "https"];
9const METADATA_HOSTS: [&str; 4] = [
10    "169.254.169.254",
11    "metadata.google.internal",
12    "metadata",
13    "100.100.100.200",
14];
15
16/// Destination policy applied to initial request URLs and redirect targets.
17///
18/// By default, HTTP and HTTPS URLs are allowed while link-local and common
19/// cloud metadata destinations are blocked. `allowed_hosts` is an allow-list:
20/// when it is empty, any non-blocked host is allowed; when it is non-empty, the
21/// URL host must match one of the configured entries. Host entries are exact
22/// matches unless they start with `*.`, in which case only dot-boundary
23/// subdomains match (for example, `*.example.com` matches `api.example.com` but
24/// not `badexample.com` or the apex `example.com`).
25///
26/// Hostname validation happens before DNS resolution. Use `allowed_hosts` for
27/// high-trust clients that must not follow attacker-controlled DNS names.
28#[derive(Debug, Clone, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct DestinationPolicy {
31    /// Allowed URL schemes. Empty means all schemes are rejected.
32    pub allowed_schemes: Vec<String>,
33    /// Optional host allow-list. Empty means any non-blocked host is allowed.
34    pub allowed_hosts: Vec<String>,
35    /// Block IPv4/IPv6 link-local address literals.
36    pub block_link_local: bool,
37    /// Block common cloud metadata hosts and address literals.
38    pub block_metadata: bool,
39}
40
41impl DestinationPolicy {
42    /// Create the default destination policy.
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Replace allowed schemes.
49    #[must_use]
50    pub fn with_allowed_schemes<I, S>(mut self, schemes: I) -> Self
51    where
52        I: IntoIterator<Item = S>,
53        S: Into<String>,
54    {
55        self.allowed_schemes = schemes.into_iter().map(Into::into).collect();
56        self
57    }
58
59    /// Replace the host allow-list.
60    #[must_use]
61    pub fn with_allowed_hosts<I, S>(mut self, hosts: I) -> Self
62    where
63        I: IntoIterator<Item = S>,
64        S: Into<String>,
65    {
66        self.allowed_hosts = hosts.into_iter().map(Into::into).collect();
67        self
68    }
69
70    /// Set whether link-local address literals are blocked.
71    #[must_use]
72    pub fn with_block_link_local(mut self, block: bool) -> Self {
73        self.block_link_local = block;
74        self
75    }
76
77    /// Set whether common cloud metadata hosts are blocked.
78    #[must_use]
79    pub fn with_block_metadata(mut self, block: bool) -> Self {
80        self.block_metadata = block;
81        self
82    }
83
84    /// Validate a request or redirect URL against this policy.
85    ///
86    /// # Errors
87    /// Returns an error when the URL scheme, host, or address literal violates
88    /// the configured policy.
89    pub fn validate(&self, url: &Url) -> AppResult<()> {
90        self.validate_scheme(url.scheme())?;
91        let host = url
92            .host_str()
93            .ok_or_else(|| AppError::invalid_input("url", "URL must include a host"))?;
94        self.validate_host(host)
95    }
96
97    fn validate_scheme(&self, scheme: &str) -> AppResult<()> {
98        if self
99            .allowed_schemes
100            .iter()
101            .any(|allowed| allowed.eq_ignore_ascii_case(scheme))
102        {
103            Ok(())
104        } else {
105            Err(AppError::invalid_input(
106                "url.scheme",
107                format!("URL scheme '{scheme}' is not allowed"),
108            ))
109        }
110    }
111
112    fn validate_host(&self, host: &str) -> AppResult<()> {
113        let normalized = normalize_host(host);
114        if self.block_metadata && is_metadata_host(&normalized) {
115            return Err(AppError::invalid_input(
116                "url.host",
117                "metadata service destinations are blocked",
118            ));
119        }
120        if self.block_link_local && is_link_local_literal(&normalized) {
121            return Err(AppError::invalid_input(
122                "url.host",
123                "link-local destinations are blocked",
124            ));
125        }
126        if !self.allowed_hosts.is_empty()
127            && !self
128                .allowed_hosts
129                .iter()
130                .any(|allowed| host_matches(&normalized, &normalize_host(allowed)))
131        {
132            return Err(AppError::invalid_input(
133                "url.host",
134                format!("URL host '{host}' is not allowed"),
135            ));
136        }
137        Ok(())
138    }
139}
140
141impl Default for DestinationPolicy {
142    fn default() -> Self {
143        Self {
144            allowed_schemes: DEFAULT_SCHEMES.into_iter().map(str::to_string).collect(),
145            allowed_hosts: Vec::new(),
146            block_link_local: true,
147            block_metadata: true,
148        }
149    }
150}
151
152fn normalize_host(host: &str) -> String {
153    host.trim()
154        .trim_matches(['[', ']'])
155        .trim_end_matches('.')
156        .to_ascii_lowercase()
157}
158
159fn host_matches(host: &str, allowed: &str) -> bool {
160    if let Some(suffix) = allowed.strip_prefix("*.") {
161        host.len() > suffix.len()
162            && host.ends_with(suffix)
163            && host.as_bytes()[host.len() - suffix.len() - 1] == b'.'
164    } else {
165        host == allowed
166    }
167}
168
169fn is_metadata_host(host: &str) -> bool {
170    METADATA_HOSTS
171        .into_iter()
172        .any(|metadata| host.eq_ignore_ascii_case(metadata))
173        || host.parse::<Ipv4Addr>().is_ok_and(is_metadata_ipv4)
174        || host.parse::<Ipv6Addr>().is_ok_and(|ip| {
175            ip == Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254)
176                || ipv6_embedded_ipv4(ip).is_some_and(is_metadata_ipv4)
177        })
178}
179
180fn is_link_local_literal(host: &str) -> bool {
181    host.parse::<Ipv4Addr>().is_ok_and(|ip| ip.is_link_local())
182        || host.parse::<Ipv6Addr>().is_ok_and(|ip| {
183            ip.is_unicast_link_local()
184                || ipv6_embedded_ipv4(ip).is_some_and(|ipv4| ipv4.is_link_local())
185        })
186}
187
188fn is_metadata_ipv4(ip: Ipv4Addr) -> bool {
189    METADATA_HOSTS
190        .into_iter()
191        .filter_map(|metadata| metadata.parse::<Ipv4Addr>().ok())
192        .any(|metadata| ip == metadata)
193}
194
195fn ipv6_embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
196    let octets = ip.octets();
197    let is_mapped =
198        octets[..10].iter().all(|octet| *octet == 0) && octets[10] == 0xff && octets[11] == 0xff;
199    let is_compatible = octets[..12].iter().all(|octet| *octet == 0);
200
201    if is_mapped || is_compatible {
202        Some(Ipv4Addr::new(
203            octets[12], octets[13], octets[14], octets[15],
204        ))
205    } else {
206        None
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn default_policy_blocks_metadata_ip_literal() {
216        let url = Url::parse("http://169.254.169.254/latest/meta-data").unwrap();
217
218        assert!(DestinationPolicy::new().validate(&url).is_err());
219    }
220
221    #[test]
222    fn allowed_schemes_are_case_insensitive_and_empty_rejects_all() {
223        let allowed = DestinationPolicy::new().with_allowed_schemes(["HTTPS"]);
224        let rejected = DestinationPolicy::new().with_allowed_schemes(Vec::<String>::new());
225
226        assert!(
227            allowed
228                .validate(&Url::parse("https://api.example.com/").unwrap())
229                .is_ok()
230        );
231        let error = rejected
232            .validate(&Url::parse("https://api.example.com/").unwrap())
233            .expect_err("empty scheme allow-list should reject");
234        assert!(
235            error
236                .message()
237                .contains("URL scheme 'https' is not allowed")
238        );
239    }
240
241    #[test]
242    fn metadata_and_link_local_blocks_can_be_disabled() {
243        let policy = DestinationPolicy::new()
244            .with_block_metadata(false)
245            .with_block_link_local(false);
246
247        assert!(
248            policy
249                .validate(&Url::parse("http://169.254.169.254/latest").unwrap())
250                .is_ok()
251        );
252        assert!(
253            policy
254                .validate(&Url::parse("http://[fe80::1]/").unwrap())
255                .is_ok()
256        );
257    }
258
259    #[test]
260    fn default_policy_blocks_ipv4_mapped_metadata_literal() {
261        let url = Url::parse("http://[::ffff:169.254.169.254]/latest/meta-data").unwrap();
262
263        assert!(DestinationPolicy::new().validate(&url).is_err());
264    }
265
266    #[test]
267    fn default_policy_blocks_ipv4_compatible_metadata_literal() {
268        let url = Url::parse("http://[::169.254.169.254]/latest/meta-data").unwrap();
269
270        assert!(DestinationPolicy::new().validate(&url).is_err());
271    }
272
273    #[test]
274    fn default_policy_blocks_link_local_literal() {
275        let url = Url::parse("http://[fe80::1]/").unwrap();
276
277        assert!(DestinationPolicy::new().validate(&url).is_err());
278    }
279
280    #[test]
281    fn default_policy_blocks_ipv4_mapped_link_local_literal() {
282        let url = Url::parse("http://[::ffff:169.254.1.2]/").unwrap();
283
284        assert!(DestinationPolicy::new().validate(&url).is_err());
285    }
286
287    #[test]
288    fn default_policy_blocks_ipv4_compatible_link_local_literal() {
289        let url = Url::parse("http://[::169.254.1.2]/").unwrap();
290
291        assert!(DestinationPolicy::new().validate(&url).is_err());
292    }
293
294    #[test]
295    fn allow_list_rejects_non_matching_host() {
296        let policy = DestinationPolicy::new().with_allowed_hosts(["api.example.com"]);
297        let url = Url::parse("https://other.example.com/").unwrap();
298
299        assert!(policy.validate(&url).is_err());
300    }
301
302    #[test]
303    fn allow_list_trims_configured_hosts() {
304        let policy = DestinationPolicy::new().with_allowed_hosts([" api.example.com. "]);
305        let url = Url::parse("https://api.example.com/").unwrap();
306
307        assert!(policy.validate(&url).is_ok());
308    }
309
310    #[test]
311    fn wildcard_allow_list_uses_dot_boundary() {
312        let policy = DestinationPolicy::new().with_allowed_hosts(["*.example.com"]);
313
314        assert!(
315            policy
316                .validate(&Url::parse("https://api.example.com/").unwrap())
317                .is_ok()
318        );
319        assert!(
320            policy
321                .validate(&Url::parse("https://badexample.com/").unwrap())
322                .is_err()
323        );
324        assert!(
325            policy
326                .validate(&Url::parse("https://example.com/").unwrap())
327                .is_err()
328        );
329    }
330}