1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
11
12use openmoat_core::{AtomicAction, CompiledPolicy, Decision, Defaults, Kind, Policy, Verdict};
13
14pub const RULE_ADDRESS: &str = "proxy-address";
16pub const RULE_SNI: &str = "proxy-sni";
18pub const RULE_REQUEST: &str = "proxy-request";
20pub const RULE_UPSTREAM: &str = "proxy-upstream";
22pub const RULE_AUDIT: &str = "proxy-audit";
24
25const METADATA_NAMES: &[&str] = &["metadata.google.internal", "metadata.goog"];
27const ALIBABA_METADATA: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 200);
28const AWS_METADATA_V6: Ipv6Addr = Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254);
29const NAT64: [u16; 6] = [0x64, 0xff9b, 0, 0, 0, 0];
31const PRIVATE_V4: [(Ipv4Addr, u8, &str); 5] = [
33 (
34 Ipv4Addr::new(10, 0, 0, 0),
35 8,
36 "a private address (10.0.0.0/8)",
37 ),
38 (
39 Ipv4Addr::new(172, 16, 0, 0),
40 12,
41 "a private address (172.16.0.0/12)",
42 ),
43 (
44 Ipv4Addr::new(192, 168, 0, 0),
45 16,
46 "a private address (192.168.0.0/16)",
47 ),
48 (
49 Ipv4Addr::new(100, 64, 0, 0),
50 10,
51 "a shared CGNAT address (100.64.0.0/10)",
52 ),
53 (
54 Ipv4Addr::new(198, 18, 0, 0),
55 15,
56 "a benchmarking address (198.18.0.0/15)",
57 ),
58];
59
60#[must_use]
63pub fn host(policy: &CompiledPolicy<'_>, host: &str) -> Decision {
64 if METADATA_NAMES
65 .iter()
66 .any(|m| host == *m || host.strip_suffix(m).is_some_and(|p| p.ends_with('.')))
67 {
68 return Decision::single(
69 Verdict::Deny,
70 RULE_ADDRESS,
71 format!("{host} is a cloud metadata service"),
72 );
73 }
74 let atom = AtomicAction::Fetch {
75 host: host.to_owned(),
76 };
77 let mut decision = policy.evaluate_atomic(&atom).unwrap_or_else(|| {
78 Decision::single(
79 Verdict::Deny,
80 RULE_REQUEST,
81 format!("no rule decided {host}"),
82 )
83 });
84 if matches!(decision.verdict, Verdict::Ask | Verdict::Defer) {
87 decision.verdict = Verdict::Deny;
88 decision
89 .reasons
90 .push("the proxy cannot ask, so a host the hook would ask for or leave to the agent is refused".to_owned());
91 }
92 decision
93}
94
95#[must_use]
99pub fn forbidden(ip: IpAddr) -> Option<Forbidden> {
100 match ip.to_canonical() {
101 IpAddr::V4(v4) => forbidden_v4(v4),
102 IpAddr::V6(v6) => forbidden_v6(v6),
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum Forbidden {
110 Loopback,
112 LinkLocal,
114 Metadata,
116 NotUnicast,
118 Private(&'static str),
122}
123
124impl Forbidden {
125 #[must_use]
127 pub fn describe(self) -> &'static str {
128 match self {
129 Self::Loopback => "a loopback address",
130 Self::LinkLocal => "a link-local address",
131 Self::Metadata => "a cloud metadata address",
132 Self::NotUnicast => "not a unicast address",
133 Self::Private(range) => range,
134 }
135 }
136}
137
138fn forbidden_v4(ip: Ipv4Addr) -> Option<Forbidden> {
139 if ip.is_loopback() {
140 Some(Forbidden::Loopback)
141 } else if ip.is_link_local() {
142 Some(Forbidden::LinkLocal)
143 } else if ip == ALIBABA_METADATA {
144 Some(Forbidden::Metadata)
145 } else if ip.is_unspecified() || ip.is_broadcast() || ip.is_multicast() || ip.octets()[0] == 0 {
146 Some(Forbidden::NotUnicast)
147 } else {
148 PRIVATE_V4
149 .iter()
150 .find(|(net, len, _)| u32::from(ip) >> (32 - len) == u32::from(*net) >> (32 - len))
151 .map(|(_, _, range)| Forbidden::Private(range))
152 }
153}
154
155fn forbidden_v6(ip: Ipv6Addr) -> Option<Forbidden> {
156 let seg = ip.segments();
157 if ip.is_loopback() {
158 Some(Forbidden::Loopback)
159 } else if ip.is_unspecified() || ip.is_multicast() {
160 Some(Forbidden::NotUnicast)
161 } else if seg[0] & 0xffc0 == 0xfe80 {
162 Some(Forbidden::LinkLocal)
163 } else if ip == AWS_METADATA_V6 {
164 Some(Forbidden::Metadata)
165 } else if seg[..6] == NAT64 || seg[..6] == [0; 6] {
166 let [a, b] = seg[6].to_be_bytes();
168 let [c, d] = seg[7].to_be_bytes();
169 forbidden_v4(Ipv4Addr::new(a, b, c, d))
170 } else if seg[0] & 0xfe00 == 0xfc00 {
171 Some(Forbidden::Private("a unique local address (fc00::/7)"))
172 } else {
173 None
174 }
175}
176
177#[must_use]
185pub fn address_policy(policy: &Policy) -> Policy {
186 let allow = policy
187 .allow
188 .iter()
189 .map(|g| {
190 let keep = |list: &[String]| {
191 list.iter()
192 .filter(|p| names_address(p))
193 .cloned()
194 .collect::<Vec<_>>()
195 };
196 openmoat_core::RuleGroup {
197 id: g.id.clone(),
198 reason: g.reason.clone(),
199 net: keep(g.patterns(Kind::Net)),
200 fetch: keep(g.patterns(Kind::Fetch)),
201 ..Default::default()
202 }
203 })
204 .collect();
205 Policy {
206 defaults: Defaults::All(Verdict::Deny),
207 deny: policy.deny.clone(),
208 allow,
209 ask: Vec::new(),
210 ..policy.clone()
211 }
212}
213
214fn names_address(pattern: &str) -> bool {
216 !pattern.starts_with(['*', '?', '[', '{'])
217 && (pattern.starts_with(|c: char| c.is_ascii_digit()) || pattern.contains(':'))
218 && pattern
219 .chars()
220 .all(|c| c.is_ascii_hexdigit() || ".:*?[]{},!-".contains(c))
221}
222
223#[must_use]
226pub fn named(addresses: &CompiledPolicy<'_>, ip: IpAddr) -> bool {
227 let atom = AtomicAction::Fetch {
228 host: ip.to_canonical().to_string(),
229 };
230 addresses
231 .evaluate_atomic(&atom)
232 .is_some_and(|d| d.verdict == Verdict::Allow)
233}
234
235#[cfg(test)]
236mod tests;