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