rskit_httpclient/
destination.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct DestinationPolicy {
31 pub allowed_schemes: Vec<String>,
33 pub allowed_hosts: Vec<String>,
35 pub block_link_local: bool,
37 pub block_metadata: bool,
39}
40
41impl DestinationPolicy {
42 #[must_use]
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 #[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 #[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 #[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 #[must_use]
79 pub fn with_block_metadata(mut self, block: bool) -> Self {
80 self.block_metadata = block;
81 self
82 }
83
84 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}