1use std::collections::BTreeSet;
10use std::fmt;
11use std::str::FromStr;
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16use crate::error::{Error, Result};
17
18pub const DEFAULT_PORT: u16 = 443;
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub enum HostPattern {
25 Exact(String),
26 Suffix(String),
28}
29
30impl HostPattern {
31 pub fn matches(&self, host: &str) -> bool {
33 match self {
34 HostPattern::Exact(h) => h == host,
35 HostPattern::Suffix(d) => host
36 .strip_suffix(d.as_str())
37 .is_some_and(|rest| rest.len() > 1 && rest.ends_with('.')),
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub struct Entry {
45 pub host: HostPattern,
46 pub port: u16,
47}
48
49pub fn normalize_host(raw: &str) -> Result<String> {
51 let h = raw.trim().trim_end_matches('.').to_ascii_lowercase();
52 if h.is_empty() || h.len() > 253 {
53 return Err(Error::invalid(format!(
54 "egress host {raw:?}: not a hostname"
55 )));
56 }
57 if h.parse::<std::net::IpAddr>().is_ok() || h.starts_with('[') {
58 return Err(Error::invalid(format!(
59 "egress host {raw:?}: egress is by hostname; an IP address cannot be allowed"
60 )));
61 }
62 for label in h.split('.') {
63 let ok = !label.is_empty()
64 && label.len() <= 63
65 && !label.starts_with('-')
66 && !label.ends_with('-')
67 && label
68 .bytes()
69 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'));
70 if !ok {
71 return Err(Error::invalid(format!(
72 "egress host {raw:?}: {label:?} is not a valid DNS label"
73 )));
74 }
75 }
76 Ok(h)
77}
78
79impl FromStr for Entry {
80 type Err = Error;
81
82 fn from_str(s: &str) -> Result<Entry> {
84 let s = s.trim();
85 let (host, port) = match s.rsplit_once(':') {
86 Some((h, p)) => {
87 let port = p
88 .parse::<u16>()
89 .ok()
90 .filter(|p| *p != 0)
91 .ok_or_else(|| Error::invalid(format!("egress {s:?}: bad port {p:?}")))?;
92 (h, port)
93 }
94 None => (s, DEFAULT_PORT),
95 };
96 let host = match host.strip_prefix("*.") {
97 Some(domain) => {
98 let d = normalize_host(domain)?;
99 if !d.contains('.') {
100 return Err(Error::invalid(format!(
101 "egress {s:?}: a wildcard needs a domain of at least two labels"
102 )));
103 }
104 HostPattern::Suffix(d)
105 }
106 None if host.contains('*') => {
107 return Err(Error::invalid(format!(
108 "egress {s:?}: `*` is only allowed as a leading `*.`"
109 )));
110 }
111 None => HostPattern::Exact(normalize_host(host)?),
112 };
113 Ok(Entry { host, port })
114 }
115}
116
117impl fmt::Display for Entry {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match &self.host {
120 HostPattern::Exact(h) => write!(f, "{h}:{}", self.port),
121 HostPattern::Suffix(d) => write!(f, "*.{d}:{}", self.port),
122 }
123 }
124}
125
126impl Entry {
127 pub fn allows(&self, host: &str, port: u16) -> bool {
129 self.port == port && self.host.matches(host)
130 }
131}
132
133impl Serialize for Entry {
134 fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
135 s.collect_str(self)
136 }
137}
138
139impl<'de> Deserialize<'de> for Entry {
140 fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Entry, D::Error> {
141 let s = String::deserialize(d)?;
142 s.parse().map_err(serde::de::Error::custom)
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct SecretBinding {
150 pub env: String,
152 pub secret: String,
154 pub hosts: Vec<Entry>,
156 pub placeholder: String,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct EgressSecretSpec {
164 pub env: String,
166 pub secret: Option<String>,
168 pub hosts: Vec<String>,
170}
171
172#[derive(Serialize, Deserialize, JsonSchema)]
173#[serde(untagged)]
174enum SecretRepr {
175 Short(String),
177 Full {
179 env: String,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 secret: Option<String>,
184 hosts: Vec<String>,
186 },
187}
188
189impl JsonSchema for EgressSecretSpec {
190 fn schema_name() -> std::borrow::Cow<'static, str> {
191 "EgressSecretSpec".into()
192 }
193
194 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
195 SecretRepr::json_schema(g)
196 }
197}
198
199impl EgressSecretSpec {
200 pub fn parse(s: &str) -> Result<EgressSecretSpec> {
202 let (head, hosts) = s.split_once('@').ok_or_else(|| {
203 Error::invalid(format!(
204 "egress secret {s:?}: expected NAME@host1,host2 (the hosts it may be sent to)"
205 ))
206 })?;
207 let (env, secret) = match head.split_once('=') {
208 Some((e, s)) => (e, Some(s.to_string())),
209 None => (head, None),
210 };
211 Ok(EgressSecretSpec {
212 env: env.to_string(),
213 secret,
214 hosts: hosts.split(',').map(|h| h.trim().to_string()).collect(),
215 })
216 }
217}
218
219impl Serialize for EgressSecretSpec {
220 fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
221 SecretRepr::Full {
222 env: self.env.clone(),
223 secret: self.secret.clone(),
224 hosts: self.hosts.clone(),
225 }
226 .serialize(s)
227 }
228}
229
230impl<'de> Deserialize<'de> for EgressSecretSpec {
231 fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
232 match SecretRepr::deserialize(d)? {
233 SecretRepr::Short(s) => EgressSecretSpec::parse(&s).map_err(serde::de::Error::custom),
234 SecretRepr::Full { env, secret, hosts } => Ok(EgressSecretSpec { env, secret, hosts }),
235 }
236 }
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct EgressSpec {
242 pub allow: Vec<String>,
244 pub none: bool,
246 pub secrets: Vec<EgressSecretSpec>,
247}
248
249#[derive(Serialize, Deserialize, JsonSchema)]
250#[serde(untagged)]
251enum EgressRepr {
252 Keyword(String),
254 List(Vec<String>),
257 Full {
259 #[serde(default)]
261 allow: Vec<String>,
262 #[serde(default)]
264 secrets: Vec<EgressSecretSpec>,
265 },
266}
267
268impl JsonSchema for EgressSpec {
269 fn schema_name() -> std::borrow::Cow<'static, str> {
270 "EgressSpec".into()
271 }
272
273 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
274 EgressRepr::json_schema(g)
275 }
276}
277
278impl Serialize for EgressSpec {
279 fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
280 let repr = if self.none {
281 EgressRepr::Keyword("none".into())
282 } else if self.secrets.is_empty() {
283 EgressRepr::List(self.allow.clone())
284 } else {
285 EgressRepr::Full {
286 allow: self.allow.clone(),
287 secrets: self.secrets.clone(),
288 }
289 };
290 repr.serialize(s)
291 }
292}
293
294impl<'de> Deserialize<'de> for EgressSpec {
295 fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
296 Ok(match EgressRepr::deserialize(d)? {
297 EgressRepr::Keyword(k) if k.eq_ignore_ascii_case("none") => EgressSpec {
298 none: true,
299 ..Default::default()
300 },
301 EgressRepr::Keyword(k) => {
302 return Err(serde::de::Error::custom(format!(
303 "egress: {k:?} is not `none`, a list of host[:port] or an object with allow/secrets"
304 )));
305 }
306 EgressRepr::List(allow) => EgressSpec {
307 allow,
308 ..Default::default()
309 },
310 EgressRepr::Full { allow, secrets } => EgressSpec {
311 allow,
312 none: false,
313 secrets,
314 },
315 })
316 }
317}
318
319impl EgressSpec {
320 pub fn none() -> EgressSpec {
322 EgressSpec {
323 none: true,
324 ..Default::default()
325 }
326 }
327
328 pub fn allow<I, S>(hosts: I) -> EgressSpec
330 where
331 I: IntoIterator<Item = S>,
332 S: Into<String>,
333 {
334 EgressSpec {
335 allow: hosts.into_iter().map(Into::into).collect(),
336 ..Default::default()
337 }
338 }
339}
340
341#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
343pub struct Policy {
344 pub entries: Vec<Entry>,
346 pub secrets: Vec<SecretBinding>,
347}
348
349pub const MAX_ENTRIES: usize = 256;
352pub const MAX_SECRETS: usize = 16;
354
355fn valid_env_name(n: &str) -> bool {
356 let mut b = n.bytes();
357 b.next()
358 .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_')
359 && b.all(|c| c.is_ascii_alphanumeric() || c == b'_')
360 && n.len() <= 128
361}
362
363impl Policy {
364 pub fn from_spec(spec: &EgressSpec, network: &str) -> Result<Policy> {
367 if spec.none && (!spec.allow.is_empty() || !spec.secrets.is_empty()) {
368 return Err(Error::invalid(
369 "egress: none cannot be combined with hosts or secrets",
370 ));
371 }
372 let mut entries = BTreeSet::new();
373 for a in &spec.allow {
374 entries.insert(a.parse::<Entry>()?);
375 }
376 let mut secrets: Vec<SecretBinding> = Vec::new();
377 for s in &spec.secrets {
378 if !valid_env_name(&s.env) {
379 return Err(Error::invalid(format!(
380 "egress secret: {:?} is not an environment variable name",
381 s.env
382 )));
383 }
384 if secrets.iter().any(|b| b.env == s.env) {
385 return Err(Error::invalid(format!(
386 "egress secret {}: listed twice",
387 s.env
388 )));
389 }
390 let store = s.secret.clone().unwrap_or_else(|| s.env.clone());
391 crate::secrets::validate_name(&store)?;
392 if s.hosts.is_empty() {
393 return Err(Error::invalid(format!(
394 "egress secret {}: name the hosts it may be sent to (NAME@host1,host2)",
395 s.env
396 )));
397 }
398 let hosts = s
399 .hosts
400 .iter()
401 .map(|h| h.parse::<Entry>())
402 .collect::<Result<Vec<_>>>()?;
403 entries.extend(hosts.iter().cloned());
404 secrets.push(SecretBinding {
405 placeholder: placeholder(network, &s.env),
406 env: s.env.clone(),
407 secret: store,
408 hosts,
409 });
410 }
411 if secrets.len() > MAX_SECRETS || entries.len() > MAX_ENTRIES {
412 return Err(Error::invalid(format!(
413 "egress: at most {MAX_ENTRIES} hosts and {MAX_SECRETS} secrets per sandbox"
414 )));
415 }
416 Ok(Policy {
417 entries: entries.into_iter().collect(),
418 secrets,
419 })
420 }
421
422 pub fn is_none(&self) -> bool {
424 self.entries.is_empty()
425 }
426
427 pub fn allows(&self, host: &str, port: u16) -> bool {
429 self.entries.iter().any(|e| e.allows(host, port))
430 }
431
432 pub fn ports(&self) -> BTreeSet<u16> {
434 self.entries.iter().map(|e| e.port).collect()
435 }
436
437 pub fn dns_names(&self) -> BTreeSet<String> {
440 self.entries
441 .iter()
442 .map(|e| match &e.host {
443 HostPattern::Exact(h) => h.clone(),
444 HostPattern::Suffix(d) => d.clone(),
445 })
446 .collect()
447 }
448
449 pub fn secrets_for<'a>(
451 &'a self,
452 host: &'a str,
453 port: u16,
454 ) -> impl Iterator<Item = &'a SecretBinding> {
455 self.secrets
456 .iter()
457 .filter(move |s| s.hosts.iter().any(|e| e.allows(host, port)))
458 }
459
460 pub fn intercepts(&self, host: &str, port: u16) -> bool {
463 self.secrets_for(host, port).next().is_some()
464 }
465
466 pub fn pinned_host(&self, port: u16) -> Option<&str> {
469 let mut hosts = self.entries.iter().filter(|e| e.port == port);
470 let first = hosts.next()?;
471 let HostPattern::Exact(h) = &first.host else {
472 return None;
473 };
474 hosts.next().is_none().then_some(h.as_str())
475 }
476
477 pub fn list(&self) -> Vec<String> {
479 self.entries.iter().map(Entry::to_string).collect()
480 }
481}
482
483pub fn placeholder(network: &str, env: &str) -> String {
486 let d = ring::digest::digest(
487 &ring::digest::SHA256,
488 format!("isb-egress-placeholder\0{network}\0{env}").as_bytes(),
489 );
490 let hex: String = d.as_ref()[..16]
491 .iter()
492 .map(|b| format!("{b:02x}"))
493 .collect();
494 format!("isb_placeholder_{hex}")
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 fn e(s: &str) -> Entry {
502 s.parse().unwrap()
503 }
504
505 #[test]
506 fn entries_parse_with_default_port() {
507 assert_eq!(
508 e("API.Example.com"),
509 Entry {
510 host: HostPattern::Exact("api.example.com".into()),
511 port: 443
512 }
513 );
514 assert_eq!(e("db.example.com:5432").port, 5432);
515 assert_eq!(
516 e("*.example.com:8443"),
517 Entry {
518 host: HostPattern::Suffix("example.com".into()),
519 port: 8443
520 }
521 );
522 assert_eq!(e("example.com.").to_string(), "example.com:443");
523 }
524
525 #[test]
526 fn bad_entries_are_refused() {
527 for bad in [
528 "",
529 "1.2.3.4",
530 "1.2.3.4:443",
531 "[::1]:443",
532 "example.com:0",
533 "example.com:70000",
534 "example.com:x",
535 "*.com",
536 "*",
537 "a*.example.com",
538 "ex ample.com",
539 "-bad.example.com",
540 "a..b",
541 "https://example.com",
542 ] {
543 assert!(bad.parse::<Entry>().is_err(), "{bad:?} should be refused");
544 }
545 }
546
547 #[test]
548 fn exact_and_suffix_matching() {
549 let exact = e("api.example.com");
550 assert!(exact.allows("api.example.com", 443));
551 assert!(!exact.allows("api.example.com", 80));
552 assert!(!exact.allows("x.api.example.com", 443));
553 assert!(!exact.allows("example.com", 443));
554 let wild = e("*.example.com");
555 assert!(wild.allows("a.example.com", 443));
556 assert!(wild.allows("a.b.example.com", 443));
557 assert!(!wild.allows("example.com", 443), "the apex is not covered");
558 assert!(
559 !wild.allows("badexample.com", 443),
560 "a suffix is a label boundary"
561 );
562 assert!(!wild.allows("a.example.com.evil.com", 443));
563 assert!(!wild.allows("a.example.com", 8443));
564 }
565
566 #[test]
567 fn entries_roundtrip_as_strings() {
568 let v = serde_json::to_string(&e("*.example.com:8443")).unwrap();
569 assert_eq!(v, "\"*.example.com:8443\"");
570 assert_eq!(
571 serde_json::from_str::<Entry>(&v).unwrap(),
572 e("*.example.com:8443")
573 );
574 }
575
576 #[test]
577 fn spec_forms_deserialize() {
578 let none: EgressSpec = serde_yaml_ng::from_str("none").unwrap();
579 assert!(none.none);
580 let list: EgressSpec =
581 serde_yaml_ng::from_str("[api.example.com, '*.cdn.net:80']").unwrap();
582 assert_eq!(list.allow.len(), 2);
583 let full: EgressSpec = serde_yaml_ng::from_str(
584 "allow: [api.example.com]\nsecrets:\n - GH@api.github.com\n - {env: K, secret: store-k, hosts: [x.example.com:8443]}\n",
585 )
586 .unwrap();
587 assert_eq!(full.secrets.len(), 2);
588 assert_eq!(full.secrets[0].env, "GH");
589 assert_eq!(full.secrets[1].secret.as_deref(), Some("store-k"));
590 assert!(serde_yaml_ng::from_str::<EgressSpec>("open").is_err());
591 let empty: EgressSpec = serde_yaml_ng::from_str("[]").unwrap();
592 assert!(Policy::from_spec(&empty, "n").unwrap().is_none());
593 }
594
595 #[test]
596 fn spec_roundtrips() {
597 for text in ["none", "[a.example.com]"] {
598 let s: EgressSpec = serde_yaml_ng::from_str(text).unwrap();
599 let back: EgressSpec =
600 serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&s).unwrap()).unwrap();
601 assert_eq!(s, back);
602 }
603 }
604
605 #[test]
606 fn policy_collects_secret_hosts_into_the_allow_list() {
607 let spec = EgressSpec {
608 allow: vec!["registry.npmjs.org".into()],
609 none: false,
610 secrets: vec![EgressSecretSpec::parse("GH_TOKEN=gh-prod@api.github.com").unwrap()],
611 };
612 let p = Policy::from_spec(&spec, "isbbrxabc").unwrap();
613 assert!(p.allows("registry.npmjs.org", 443));
614 assert!(p.allows("api.github.com", 443));
615 assert!(p.intercepts("api.github.com", 443));
616 assert!(!p.intercepts("registry.npmjs.org", 443));
617 assert_eq!(p.secrets[0].secret, "gh-prod");
618 assert_eq!(p.secrets[0].env, "GH_TOKEN");
619 assert!(p.secrets[0].placeholder.starts_with("isb_placeholder_"));
620 assert_eq!(p.ports().into_iter().collect::<Vec<_>>(), vec![443]);
621 }
622
623 #[test]
624 fn secrets_are_validated() {
625 let bad = |s: &str| {
626 Policy::from_spec(
627 &EgressSpec {
628 secrets: vec![EgressSecretSpec::parse(s).unwrap()],
629 ..Default::default()
630 },
631 "n",
632 )
633 };
634 assert!(bad("1BAD@a.example.com").is_err());
635 assert!(bad("OK@1.2.3.4").is_err());
636 assert!(bad("OK=../x@a.example.com").is_err());
637 assert!(EgressSecretSpec::parse("NOHOSTS").is_err());
638 let both = EgressSpec {
639 none: true,
640 allow: vec!["a.example.com".into()],
641 ..Default::default()
642 };
643 assert!(Policy::from_spec(&both, "n").is_err());
644 }
645
646 #[test]
647 fn placeholders_are_stable_and_per_sandbox() {
648 assert_eq!(placeholder("n1", "K"), placeholder("n1", "K"));
649 assert_ne!(placeholder("n1", "K"), placeholder("n2", "K"));
650 assert_ne!(placeholder("n1", "K"), placeholder("n1", "J"));
651 }
652
653 #[test]
654 fn dns_names_cover_entries() {
655 let p = Policy::from_spec(
656 &EgressSpec::allow(["api.example.com", "*.cdn.net:80", "api.example.com:8443"]),
657 "n",
658 )
659 .unwrap();
660 assert_eq!(
661 p.dns_names().into_iter().collect::<Vec<_>>(),
662 vec!["api.example.com".to_string(), "cdn.net".to_string()]
663 );
664 assert_eq!(
665 p.ports().into_iter().collect::<Vec<_>>(),
666 vec![80, 443, 8443]
667 );
668 }
669
670 #[test]
671 fn pinned_host_needs_exactly_one_exact_entry_on_the_port() {
672 let p = Policy::from_spec(
673 &EgressSpec::allow([
674 "db.example.com:5432",
675 "a.example.com",
676 "b.example.com",
677 "*.w.net:9000",
678 ]),
679 "n",
680 )
681 .unwrap();
682 assert_eq!(p.pinned_host(5432), Some("db.example.com"));
683 assert_eq!(p.pinned_host(443), None, "two hosts share 443");
684 assert_eq!(p.pinned_host(9000), None, "a suffix names no host");
685 assert_eq!(p.pinned_host(1), None);
686 }
687}