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)]
28#[non_exhaustive]
29pub struct DestinationPolicy {
30 pub allowed_schemes: Vec<String>,
32 pub allowed_hosts: Vec<String>,
34 pub block_link_local: bool,
36 pub block_metadata: bool,
38}
39
40impl DestinationPolicy {
41 #[must_use]
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 #[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 #[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 #[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 #[must_use]
78 pub fn with_block_metadata(mut self, block: bool) -> Self {
79 self.block_metadata = block;
80 self
81 }
82
83 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}