Skip to main content

alien_core/resources/
sandbox_policy.rs

1//! Runtime sandbox destination policies and public-address validation.
2
3use 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/// Per-sandbox outbound policy enforced by the gateway.
15///
16/// Mail ports 25, 465 and 587 require an explicit port in an allowlist target,
17/// such as `smtp.example.com:587`; `internet` and portless targets exclude them.
18/// This protects the deployment's shared outbound IP from mail abuse and blocklisting,
19/// which could disrupt every tenant's access through that IP.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub enum EgressPolicy {
23    /// Public internet access except mail ports 25, 465 and 587.
24    /// Private and link-local destinations remain forbidden.
25    Internet,
26    /// Explicit destinations; unspecified ports exclude 25, 465 and 587.
27    /// Wildcards include the apex. An explicit port can allow a mail destination.
28    #[serde(untagged)]
29    Allow {
30        /// Hostnames, wildcard hostnames, IPv4 literals or IPv4 CIDRs, optionally with a port.
31        allow: Vec<String>,
32    },
33}
34
35// Utoipa does not support serde's per-variant `untagged`, so describe the wire union explicitly.
36#[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    /// Validates every target before a deployment or runtime request can take effect.
94    pub fn validate(&self) -> Result<()> {
95        self.targets().map(|_| ())
96    }
97
98    /// Returns a validated policy with ASCII, lowercase names and canonical IP networks.
99    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    /// Canonical JSON used to authenticate gateway policy writes: normalized, sorted, no whitespace.
109    /// Duplicate entries remain present; sorting makes input order irrelevant to the digest.
110    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    /// SHA-256 digest authenticating the policy body in an admin JWS.
124    pub fn policy_digest(&self) -> Result<String> {
125        Ok(format!(
126            "sha256:{:x}",
127            Sha256::digest(self.canonical_json()?)
128        ))
129    }
130
131    /// Whether a concrete public destination and port match this policy.
132    pub fn allows(&self, host: &str, port: u16) -> Result<bool> {
133        let target = Target::parse(&format!("{host}:{port}"))?;
134        // Sandboxes share one outbound IP: unsolicited mail can get that IP blocklisted
135        // for every tenant. Explicit destination:port grants retain legitimate mail use.
136        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        // Valid targets are ASCII without JSON escape characters. Include object/array syntax,
158        // two quotes per target and commas to measure the exact canonical JSON byte length.
159        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            // Test intersection, not just the first address: a broad CIDR can include private IPs.
232            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
332/// Whether an IPv4 address is public unicast, excluding metadata and special-use ranges.
333pub 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
339// Includes shared address space, metadata endpoints and non-unicast/reserved ranges.
340const 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;