1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
13
14use openmoat_core::{AtomicAction, CompiledPolicy, Decision, Defaults, Kind, Policy, Verdict};
15
16pub const RULE_ADDRESS: &str = "proxy-address";
18pub const RULE_SNI: &str = "proxy-sni";
20pub const RULE_REQUEST: &str = "proxy-request";
22pub const RULE_UPSTREAM: &str = "proxy-upstream";
24pub const RULE_AUDIT: &str = "proxy-audit";
26
27const METADATA_NAMES: &[&str] = &["metadata.google.internal", "metadata.goog"];
29const ALIBABA_METADATA: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 200);
30const AZURE_WIRESERVER: Ipv4Addr = Ipv4Addr::new(168, 63, 129, 16);
32const AWS_METADATA_V6: Ipv6Addr = Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254);
33const NAT64: [u16; 6] = [0x64, 0xff9b, 0, 0, 0, 0];
35const NAT64_LOCAL: [u16; 3] = [0x64, 0xff9b, 1];
38const PRIVATE_V4: [(Ipv4Addr, u8, &str); 6] = [
40 (
41 Ipv4Addr::new(10, 0, 0, 0),
42 8,
43 "a private address (10.0.0.0/8)",
44 ),
45 (
46 Ipv4Addr::new(172, 16, 0, 0),
47 12,
48 "a private address (172.16.0.0/12)",
49 ),
50 (
51 Ipv4Addr::new(192, 168, 0, 0),
52 16,
53 "a private address (192.168.0.0/16)",
54 ),
55 (
56 Ipv4Addr::new(100, 64, 0, 0),
57 10,
58 "a shared CGNAT address (100.64.0.0/10)",
59 ),
60 (
61 Ipv4Addr::new(198, 18, 0, 0),
62 15,
63 "a benchmarking address (198.18.0.0/15)",
64 ),
65 (
66 Ipv4Addr::new(192, 0, 0, 0),
67 24,
68 "an IETF protocol address (192.0.0.0/24)",
69 ),
70];
71
72#[must_use]
75pub fn host(policy: &CompiledPolicy<'_>, host: &str) -> Decision {
76 if METADATA_NAMES
77 .iter()
78 .any(|m| host == *m || host.strip_suffix(m).is_some_and(|p| p.ends_with('.')))
79 {
80 return Decision::single(
81 Verdict::Deny,
82 RULE_ADDRESS,
83 format!("{host} is a cloud metadata service"),
84 );
85 }
86 let atom = AtomicAction::Fetch {
87 host: host.to_owned(),
88 };
89 let mut decision = policy.evaluate_atomic(&atom).unwrap_or_else(|| {
90 Decision::single(
91 Verdict::Deny,
92 RULE_REQUEST,
93 format!("no rule decided {host}"),
94 )
95 });
96 if matches!(decision.verdict, Verdict::Ask | Verdict::Defer) {
99 decision.verdict = Verdict::Deny;
100 decision
101 .reasons
102 .push("the proxy cannot ask, so a host the hook would ask for or leave to the agent is refused".to_owned());
103 }
104 decision
105}
106
107#[must_use]
111pub fn forbidden(ip: IpAddr) -> Option<Forbidden> {
112 match ip.to_canonical() {
113 IpAddr::V4(v4) => forbidden_v4(v4),
114 IpAddr::V6(v6) => forbidden_v6(v6),
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120#[non_exhaustive]
121pub enum Forbidden {
122 Loopback,
124 LinkLocal,
126 Metadata,
128 NotUnicast,
130 Private(&'static str),
134}
135
136impl Forbidden {
137 #[must_use]
139 pub fn describe(self) -> &'static str {
140 match self {
141 Self::Loopback => "a loopback address",
142 Self::LinkLocal => "a link-local address",
143 Self::Metadata => "a cloud metadata address",
144 Self::NotUnicast => "not a unicast address",
145 Self::Private(range) => range,
146 }
147 }
148}
149
150fn forbidden_v4(ip: Ipv4Addr) -> Option<Forbidden> {
151 if ip.is_loopback() {
152 Some(Forbidden::Loopback)
153 } else if ip.is_link_local() {
154 Some(Forbidden::LinkLocal)
155 } else if ip == ALIBABA_METADATA || ip == AZURE_WIRESERVER {
156 Some(Forbidden::Metadata)
157 } else if ip.is_unspecified() || ip.is_broadcast() || ip.is_multicast() || ip.octets()[0] == 0 {
158 Some(Forbidden::NotUnicast)
159 } else {
160 PRIVATE_V4
161 .iter()
162 .find(|(net, len, _)| u32::from(ip) >> (32 - len) == u32::from(*net) >> (32 - len))
163 .map(|(_, _, range)| Forbidden::Private(range))
164 }
165}
166
167fn forbidden_v6(ip: Ipv6Addr) -> Option<Forbidden> {
168 let seg = ip.segments();
169 if ip.is_loopback() {
170 Some(Forbidden::Loopback)
171 } else if ip.is_unspecified() || ip.is_multicast() {
172 Some(Forbidden::NotUnicast)
173 } else if seg[0] & 0xffc0 == 0xfe80 {
174 Some(Forbidden::LinkLocal)
175 } else if ip == AWS_METADATA_V6 {
176 Some(Forbidden::Metadata)
177 } else if let Some(v4) = carried_v4(ip) {
178 forbidden_v4(v4)
179 } else if seg[..3] == NAT64_LOCAL {
180 let [a, b] = seg[6].to_be_bytes();
183 let [c, d] = seg[7].to_be_bytes();
184 forbidden_v4(Ipv4Addr::new(a, b, c, d)).or(Some(Forbidden::Private(
185 "a local-use NAT64 address (64:ff9b:1::/48)",
186 )))
187 } else if seg[0] & 0xfe00 == 0xfc00 {
188 Some(Forbidden::Private("a unique local address (fc00::/7)"))
189 } else {
190 None
191 }
192}
193
194fn carried_v4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
197 let seg = ip.segments();
198 (seg[..6] == NAT64 || seg[..6] == [0; 6]).then(|| {
199 let [a, b] = seg[6].to_be_bytes();
200 let [c, d] = seg[7].to_be_bytes();
201 Ipv4Addr::new(a, b, c, d)
202 })
203}
204
205#[must_use]
214pub fn address_policy(policy: &Policy) -> Policy {
215 let allow = policy
216 .allow
217 .iter()
218 .map(|g| {
219 let keep = |list: &[String]| {
220 list.iter()
221 .filter(|p| names_address(p))
222 .cloned()
223 .collect::<Vec<_>>()
224 };
225 openmoat_core::RuleGroup {
226 id: g.id.clone(),
227 reason: g.reason.clone(),
228 net: keep(g.patterns(Kind::Net)),
229 fetch: keep(g.patterns(Kind::Fetch)),
230 ..Default::default()
231 }
232 })
233 .collect();
234 Policy {
236 defaults: Defaults::All(Verdict::Ask),
237 deny: policy.deny.clone(),
238 allow,
239 ask: Vec::new(),
240 ..policy.clone()
241 }
242}
243
244fn names_address(pattern: &str) -> bool {
246 !pattern.starts_with(['*', '?', '[', '{'])
247 && (pattern.starts_with(|c: char| c.is_ascii_digit()) || pattern.contains(':'))
248 && pattern
249 .chars()
250 .all(|c| c.is_ascii_hexdigit() || ".:*?[]{},!-".contains(c))
251}
252
253#[must_use]
256pub fn named(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> bool {
257 let atom = AtomicAction::Fetch {
258 host: ip.to_canonical().to_string(),
259 };
260 addresses
261 .evaluate_atomic(&atom)
262 .is_some_and(|d| d.verdict == Verdict::Allow)
263}
264
265#[must_use]
271pub fn denied(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> Option<Decision> {
272 let ip = ip.to_canonical();
273 let carried = match ip {
274 IpAddr::V6(v6) => carried_v4(v6).map(IpAddr::V4),
275 IpAddr::V4(_) => None,
276 };
277 [Some(ip), carried].into_iter().flatten().find_map(|ip| {
278 let atom = AtomicAction::Fetch {
279 host: ip.to_string(),
280 };
281 addresses
282 .evaluate_atomic(&atom)
283 .filter(|d| d.verdict == Verdict::Deny)
284 })
285}
286
287#[cfg(test)]
288mod tests;