1use sha2::{Digest, Sha256};
4use std::net::Ipv4Addr;
5
6use alien_error::{AlienError, Context, IntoAlienError};
7use ipnet::Ipv4Net;
8use serde::{Deserialize, Deserializer, Serialize};
9#[cfg(feature = "openapi")]
10use utoipa::openapi::schema::{ArrayBuilder, ObjectBuilder, OneOfBuilder, Type};
11
12use crate::error::{ErrorData, Result};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub enum EgressPolicy {
23 Internet,
26 #[serde(untagged)]
29 Allow {
30 allow: Vec<String>,
32 },
33}
34
35#[cfg(feature = "openapi")]
37impl utoipa::PartialSchema for EgressPolicy {
38 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
39 OneOfBuilder::new()
40 .description(Some("Per-sandbox outbound policy. Mail ports 25, 465 and 587 require an explicit allowlist port, such as smtp.example.com:587; internet and portless targets exclude them. This protects the deployment's shared outbound IP from mail abuse and blocklisting that could disrupt every tenant."))
41 .item(
42 ObjectBuilder::new()
43 .schema_type(Type::String)
44 .enum_values(Some(["internet"])),
45 )
46 .item(
47 ObjectBuilder::new()
48 .property(
49 "allow",
50 ArrayBuilder::new()
51 .items(ObjectBuilder::new().schema_type(Type::String))
52 .min_items(Some(0))
53 .max_items(Some(4096)),
54 )
55 .required("allow")
56 .additional_properties(Some(
57 utoipa::openapi::schema::AdditionalProperties::FreeForm(false),
58 )),
59 )
60 .into()
61 }
62}
63
64#[cfg(feature = "openapi")]
65impl utoipa::ToSchema for EgressPolicy {}
66
67impl<'de> Deserialize<'de> for EgressPolicy {
68 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
69 #[derive(Deserialize)]
70 #[serde(rename_all = "camelCase")]
71 enum Mode {
72 Internet,
73 }
74 #[derive(Deserialize)]
75 #[serde(deny_unknown_fields)]
76 struct Allow {
77 allow: Vec<String>,
78 }
79 #[derive(Deserialize)]
80 #[serde(untagged)]
81 enum Input {
82 Mode(Mode),
83 Allow(Allow),
84 }
85 Ok(match Input::deserialize(deserializer)? {
86 Input::Mode(Mode::Internet) => Self::Internet,
87 Input::Allow(Allow { allow }) => Self::Allow { allow },
88 })
89 }
90}
91
92impl EgressPolicy {
93 pub fn validate(&self) -> Result<()> {
95 self.targets().map(|_| ())
96 }
97
98 pub fn normalized(&self) -> Result<Self> {
100 match self {
101 Self::Allow { .. } => Ok(Self::Allow {
102 allow: self.targets()?.iter().map(Target::canonical).collect(),
103 }),
104 _ => Ok(self.clone()),
105 }
106 }
107
108 pub fn canonical_json(&self) -> Result<Vec<u8>> {
111 let mut policy = self.normalized()?;
112 if let Self::Allow { allow } = &mut policy {
113 allow.sort_unstable();
114 }
115 serde_json::to_vec(&policy)
116 .into_alien_error()
117 .context(ErrorData::SandboxEgressInvalid {
118 target: "policy".to_string(),
119 reason: "could not encode canonical policy".to_string(),
120 })
121 }
122
123 pub fn policy_digest(&self) -> Result<String> {
125 Ok(format!(
126 "sha256:{:x}",
127 Sha256::digest(self.canonical_json()?)
128 ))
129 }
130
131 pub fn allows(&self, host: &str, port: u16) -> Result<bool> {
133 let target = Target::parse(&format!("{host}:{port}"))?;
134 let mail = matches!(port, 25 | 465 | 587);
137 Ok((!mail && matches!(self, Self::Internet))
138 || self
139 .targets()?
140 .iter()
141 .any(|entry| (!mail || entry.port == Some(port)) && entry.contains(&target)))
142 }
143
144 fn targets(&self) -> Result<Vec<Target>> {
145 let Self::Allow { allow } = self else {
146 return Ok(Vec::new());
147 };
148 if allow.len() > 4096 {
149 return Err(AlienError::new(ErrorData::SandboxEgressPolicyTooLarge {
150 limit: "4096 targets".to_string(),
151 }));
152 }
153 let targets = allow
154 .iter()
155 .map(|target| Target::parse(target))
156 .collect::<Result<Vec<_>>>()?;
157 let canonical_bytes = 12 - usize::from(!targets.is_empty())
160 + targets
161 .iter()
162 .map(|target| target.canonical().len() + 3)
163 .sum::<usize>();
164 if canonical_bytes > 256 * 1024 {
165 return Err(AlienError::new(ErrorData::SandboxEgressPolicyTooLarge {
166 limit: "256 KiB of canonical JSON".to_string(),
167 }));
168 }
169 Ok(targets)
170 }
171}
172
173#[derive(Debug)]
174struct Target {
175 host: Host,
176 port: Option<u16>,
177}
178
179#[derive(Debug)]
180enum Host {
181 Name { name: String, wildcard: bool },
182 Network(Ipv4Net),
183}
184
185fn invalid(target: &str, reason: &str) -> AlienError<ErrorData> {
186 AlienError::new(ErrorData::SandboxEgressInvalid {
187 target: target.to_string(),
188 reason: reason.to_string(),
189 })
190}
191
192impl Target {
193 fn parse(target: &str) -> Result<Self> {
194 let (host, port) = match target.rsplit_once(':') {
195 Some((host, port)) => {
196 if port.is_empty() || !port.bytes().all(|c| c.is_ascii_digit()) {
197 return Err(invalid(
198 target,
199 "port must be an integer between 1 and 65535",
200 ));
201 }
202 let port: u16 =
203 port.parse()
204 .into_alien_error()
205 .context(ErrorData::SandboxEgressInvalid {
206 target: target.to_string(),
207 reason: "port must be between 1 and 65535".to_string(),
208 })?;
209 if port == 0 {
210 return Err(invalid(target, "port must be between 1 and 65535"));
211 }
212 (host, Some(port))
213 }
214 None => (target, None),
215 };
216 let host = host.strip_suffix('.').unwrap_or(host);
217 let network = if host.contains('/') {
218 Some(
219 host.parse::<Ipv4Net>()
220 .into_alien_error()
221 .context(ErrorData::SandboxEgressInvalid {
222 target: target.to_string(),
223 reason: "expected an IPv4 CIDR".to_string(),
224 })?
225 .trunc(),
226 )
227 } else {
228 host.parse::<Ipv4Addr>().ok().map(Ipv4Net::from)
229 };
230 if let Some(network) = network {
231 if DENIED_RANGES.iter().any(|&(address, prefix)| {
233 let denied = Ipv4Net::new_assert(Ipv4Addr::from(address), prefix);
234 denied.contains(&network) || network.contains(&denied)
235 }) {
236 return Err(invalid(
237 target,
238 "private, link-local and special-use IPv4 ranges are forbidden",
239 ));
240 }
241 return Ok(Self {
242 host: Host::Network(network),
243 port,
244 });
245 }
246 let (name, wildcard) = match host.strip_prefix("*.") {
247 Some(name) => (name, true),
248 None => (host, false),
249 };
250 let name = idna::domain_to_ascii_strict(name)
251 .into_alien_error()
252 .context(ErrorData::SandboxEgressInvalid {
253 target: target.to_string(),
254 reason: "expected a DNS hostname".to_string(),
255 })?
256 .to_ascii_lowercase();
257 if name.len() > 253
258 || !name.contains('.')
259 || name.split('.').any(|label| {
260 label.is_empty()
261 || label.len() > 63
262 || label.starts_with('-')
263 || label.ends_with('-')
264 || !label
265 .bytes()
266 .all(|c| c.is_ascii_alphanumeric() || c == b'-')
267 })
268 || name
269 .rsplit('.')
270 .next()
271 .is_some_and(|label| label.bytes().all(|c| c.is_ascii_digit()))
272 {
273 return Err(invalid(
274 target,
275 "expected a hostname, *.hostname, IPv4 literal or IPv4 CIDR",
276 ));
277 }
278 if psl::domain_str(&name).is_none() {
279 return Err(invalid(
280 target,
281 "public suffixes cannot be allowed as destinations",
282 ));
283 }
284 Ok(Self {
285 host: Host::Name { name, wildcard },
286 port,
287 })
288 }
289
290 fn canonical(&self) -> String {
291 let host = match &self.host {
292 Host::Name { name, wildcard } => format!("{}{name}", if *wildcard { "*." } else { "" }),
293 Host::Network(network) if network.prefix_len() == 32 => network.addr().to_string(),
294 Host::Network(network) => network.to_string(),
295 };
296 match self.port {
297 Some(port) => format!("{host}:{port}"),
298 None => host,
299 }
300 }
301
302 fn contains(&self, target: &Self) -> bool {
303 if self.port.is_some() && self.port != target.port {
304 return false;
305 }
306 match (&self.host, &target.host) {
307 (Host::Network(parent), Host::Network(child)) => parent.contains(child),
308 (
309 Host::Name {
310 name: parent,
311 wildcard: parent_wildcard,
312 },
313 Host::Name {
314 name: child,
315 wildcard: child_wildcard,
316 },
317 ) => {
318 if *parent_wildcard {
319 child == parent
320 || child
321 .strip_suffix(parent)
322 .is_some_and(|prefix| prefix.ends_with('.') && prefix.len() > 1)
323 } else {
324 !child_wildcard && child == parent
325 }
326 }
327 _ => false,
328 }
329 }
330}
331
332pub fn is_public_ipv4(ip: Ipv4Addr) -> bool {
334 !DENIED_RANGES.iter().any(|&(address, prefix)| {
335 Ipv4Net::new_assert(Ipv4Addr::from(address), prefix).contains(&ip)
336 })
337}
338
339const DENIED_RANGES: &[(u32, u8)] = &[
341 (0x00000000, 8),
342 (0x0a000000, 8),
343 (0x64400000, 10),
344 (0x7f000000, 8),
345 (0xa9fe0000, 16),
346 (0xac100000, 12),
347 (0xc0a80000, 16),
348 (0xc0000000, 24),
349 (0xc0000200, 24),
350 (0xc6120000, 15),
351 (0xc6336400, 24),
352 (0xcb007100, 24),
353 (0xe0000000, 3),
354 (0xa83f8110, 32),
355];
356
357#[cfg(test)]
358mod tests;