Skip to main content

redevplugin_target_classifier/
lib.rs

1use 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}