redevplugin_target_classifier/
lib.rs1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
2
3pub const TARGET_CLASSIFIER_VERSION: &str = "target-classifier-v2";
4pub const BLOCKED_IP_RANGES: &[&str] = &[
5 "0.0.0.0/8",
6 "10.0.0.0/8",
7 "100.64.0.0/10",
8 "127.0.0.0/8",
9 "169.254.0.0/16",
10 "172.16.0.0/12",
11 "192.0.0.0/24",
12 "192.0.2.0/24",
13 "192.31.196.0/24",
14 "192.52.193.0/24",
15 "192.88.99.0/24",
16 "192.168.0.0/16",
17 "192.175.48.0/24",
18 "198.18.0.0/15",
19 "198.51.100.0/24",
20 "203.0.113.0/24",
21 "224.0.0.0/4",
22 "240.0.0.0/4",
23 "::/96",
24 "::1/128",
25 "64:ff9b::/96",
26 "64:ff9b:1::/48",
27 "100::/64",
28 "2001::/23",
29 "2001:db8::/32",
30 "2002::/16",
31 "3fff::/20",
32 "5f00::/16",
33 "2620:4f:8000::/48",
34 "fc00::/7",
35 "fe80::/10",
36 "fec0::/10",
37 "ff00::/8",
38];
39pub const SPECIAL_HOSTS: &[&str] = &[
40 "localhost",
41 "metadata.google.internal",
42 "metadata.goog",
43 "instance-data",
44 "instance-data.ec2.internal",
45 "metadata.azure.internal",
46 "169.254.169.254",
47];
48
49pub fn is_special_host(host: &str) -> bool {
50 let normalized = normalize_host(host);
51 SPECIAL_HOSTS.contains(&normalized.as_str())
52}
53
54pub fn is_blocked_host_literal(host: &str) -> bool {
55 let normalized = normalize_host(host);
56 normalized
57 .trim_start_matches('[')
58 .trim_end_matches(']')
59 .parse::<IpAddr>()
60 .is_ok_and(is_blocked_address)
61}
62
63pub fn is_blocked_address(addr: IpAddr) -> bool {
64 match unmap_ipv4_mapped(addr) {
65 IpAddr::V4(addr) => is_blocked_ipv4(addr),
66 IpAddr::V6(addr) => is_blocked_ipv6(addr),
67 }
68}
69
70fn normalize_host(host: &str) -> String {
71 host.trim().trim_end_matches('.').to_ascii_lowercase()
72}
73
74fn unmap_ipv4_mapped(addr: IpAddr) -> IpAddr {
75 match addr {
76 IpAddr::V6(addr) => addr
77 .to_ipv4_mapped()
78 .map(IpAddr::V4)
79 .unwrap_or(IpAddr::V6(addr)),
80 IpAddr::V4(addr) => IpAddr::V4(addr),
81 }
82}
83
84fn is_blocked_ipv4(addr: Ipv4Addr) -> bool {
85 let octets = addr.octets();
86 match octets {
87 [0, _, _, _] => true,
88 [10, _, _, _] => true,
89 [100, second, _, _] if (64..=127).contains(&second) => true,
90 [127, _, _, _] => true,
91 [169, 254, _, _] => true,
92 [172, second, _, _] if (16..=31).contains(&second) => true,
93 [192, 0, 0, _] => true,
94 [192, 0, 2, _] => true,
95 [192, 31, 196, _] => true,
96 [192, 52, 193, _] => true,
97 [192, 88, 99, _] => true,
98 [192, 168, _, _] => true,
99 [192, 175, 48, _] => true,
100 [198, second, _, _] if (18..=19).contains(&second) => true,
101 [198, 51, 100, _] => true,
102 [203, 0, 113, _] => true,
103 [first, _, _, _] if first >= 224 => true,
104 _ => false,
105 }
106}
107
108fn is_blocked_ipv6(addr: Ipv6Addr) -> bool {
109 let segments = addr.segments();
110 let ipv4_compatible = segments[..6].iter().all(|segment| *segment == 0);
111 let nat64_well_known = segments[0] == 0x0064
112 && segments[1] == 0xff9b
113 && segments[2..6].iter().all(|segment| *segment == 0);
114 let nat64_local = segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1;
115 let discard_only = segments[0] == 0x0100 && segments[1..4].iter().all(|segment| *segment == 0);
116 ipv4_compatible
117 || addr.is_loopback()
118 || nat64_well_known
119 || nat64_local
120 || discard_only
121 || (segments[0] == 0x2001 && segments[1] <= 0x01ff)
122 || (segments[0] == 0x2001 && segments[1] == 0x0db8)
123 || segments[0] == 0x2002
124 || (segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
125 || segments[0] == 0x5f00
126 || (segments[0] == 0x2620 && segments[1] == 0x004f && segments[2] == 0x8000)
127 || (segments[0] & 0xfe00) == 0xfc00
128 || (segments[0] & 0xffc0) == 0xfe80
129 || (segments[0] & 0xffc0) == 0xfec0
130 || addr.is_multicast()
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use serde::Deserialize;
137
138 fn contract() -> &'static str {
139 std::str::from_utf8(
140 redevplugin_contracts::get(
141 redevplugin_contracts::ContractId::TARGET_CLASSIFIER_FIXTURE,
142 )
143 .bytes(),
144 )
145 .expect("target classifier contract is valid UTF-8")
146 }
147
148 #[derive(Deserialize)]
149 struct TargetClassifierContract {
150 version: String,
151 blocked_ip_ranges: Vec<String>,
152 special_hosts: Vec<String>,
153 fixtures: Vec<TargetClassifierFixture>,
154 }
155
156 #[derive(Deserialize)]
157 struct TargetClassifierFixture {
158 name: String,
159 destination: String,
160 resolved_address: Option<String>,
161 decision: String,
162 }
163
164 #[test]
165 fn constants_match_target_classifier_contract() {
166 let contract = read_contract();
167 assert_eq!(contract.version, TARGET_CLASSIFIER_VERSION);
168 let ranges = contract
169 .blocked_ip_ranges
170 .iter()
171 .map(String::as_str)
172 .collect::<Vec<_>>();
173 let hosts = contract
174 .special_hosts
175 .iter()
176 .map(String::as_str)
177 .collect::<Vec<_>>();
178 assert_eq!(ranges, BLOCKED_IP_RANGES);
179 assert_eq!(hosts, SPECIAL_HOSTS);
180 }
181
182 #[test]
183 fn classifier_matches_target_classifier_fixtures() {
184 let contract = read_contract();
185 assert!(!contract.fixtures.is_empty());
186 for fixture in contract.fixtures {
187 let host = host_from_destination(&fixture.destination);
188 let mut denied = is_special_host(host) || is_blocked_host_literal(host);
189 if let Some(resolved_address) = fixture.resolved_address.as_deref() {
190 let addr = resolved_address.parse::<IpAddr>().unwrap_or_else(|err| {
191 panic!("{} resolved address parse error: {err}", fixture.name)
192 });
193 denied = denied || is_blocked_address(addr);
194 }
195 match fixture.decision.as_str() {
196 "allow" => assert!(!denied, "{} should be allowed", fixture.name),
197 "deny" => assert!(denied, "{} should be denied", fixture.name),
198 other => panic!("{} has unsupported decision {other}", fixture.name),
199 }
200 }
201 }
202
203 #[test]
204 fn classifier_matches_3fff_documentation_prefix_boundaries() {
205 for denied in ["3fff::", "3fff:0fff:ffff::"] {
206 assert!(
207 is_blocked_address(denied.parse().expect("blocked IPv6 address must parse")),
208 "{denied} must be blocked by 3fff::/20"
209 );
210 }
211 for allowed in ["3ffe:ffff::", "3fff:1000::", "3ff0::"] {
212 assert!(
213 !is_blocked_address(allowed.parse().expect("allowed IPv6 address must parse")),
214 "{allowed} must remain outside 3fff::/20"
215 );
216 }
217 }
218
219 fn read_contract() -> TargetClassifierContract {
220 serde_json::from_str(contract()).expect("target classifier contract must decode")
221 }
222
223 fn host_from_destination(destination: &str) -> &str {
224 let authority = destination
225 .split_once("://")
226 .map(|(_, rest)| rest)
227 .unwrap_or(destination);
228 if let Some(without_bracket) = authority.strip_prefix('[') {
229 return without_bracket
230 .split_once(']')
231 .map(|(host, _)| host)
232 .unwrap_or(without_bracket);
233 }
234 authority
235 .split_once(':')
236 .map(|(host, _)| host)
237 .unwrap_or(authority)
238 }
239}