1use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use std::collections::BTreeSet;
26use std::path::{Component, Path, PathBuf};
27
28pub const RESOURCE_ANNOTATION: &str = "x-faucet-resource";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35#[non_exhaustive]
36pub enum ResourceKind {
37 Path,
40 Url,
42 Command,
44}
45
46impl ResourceKind {
47 pub fn as_str(self) -> &'static str {
49 match self {
50 ResourceKind::Path => "path",
51 ResourceKind::Url => "url",
52 ResourceKind::Command => "command",
53 }
54 }
55
56 pub fn parse(s: &str) -> Option<Self> {
58 match s {
59 "path" => Some(ResourceKind::Path),
60 "url" => Some(ResourceKind::Url),
61 "command" => Some(ResourceKind::Command),
62 _ => None,
63 }
64 }
65}
66
67impl std::fmt::Display for ResourceKind {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.write_str(self.as_str())
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75#[non_exhaustive]
76pub struct ResourceRef {
77 pub pointer: String,
79 pub kind: ResourceKind,
80 pub value: String,
81}
82
83impl ResourceRef {
84 pub fn new(pointer: impl Into<String>, kind: ResourceKind, value: impl Into<String>) -> Self {
85 Self {
86 pointer: pointer.into(),
87 kind,
88 value: value.into(),
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum RefusalKind {
97 Env,
99 File,
101 Secret,
103 Path,
105 Url,
107 Command,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113#[non_exhaustive]
114pub struct Refusal {
115 pub kind: RefusalKind,
116 pub subject: String,
118 pub reason: String,
119}
120
121impl Refusal {
122 pub fn new(kind: RefusalKind, subject: impl Into<String>, reason: impl Into<String>) -> Self {
123 Self {
124 kind,
125 subject: subject.into(),
126 reason: reason.into(),
127 }
128 }
129}
130
131impl std::fmt::Display for Refusal {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 write!(f, "{} ({})", self.subject, self.reason)
134 }
135}
136
137impl std::error::Error for Refusal {}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
145#[non_exhaustive]
146pub struct ReferencePolicy {
147 path_roots: Option<Vec<PathBuf>>,
148 url_schemes: Option<BTreeSet<String>>,
149 url_hosts: Option<Vec<String>>,
150 env: Option<Vec<String>>,
151 secret_schemes: Option<BTreeSet<String>>,
152 subprocess: bool,
153}
154
155impl Default for ReferencePolicy {
156 fn default() -> Self {
157 Self::deny_all()
158 }
159}
160
161pub fn is_metadata_host(host: &str) -> bool {
166 let canonical = canonical_host(host);
167 let h = canonical.trim_start_matches('[').trim_end_matches(']');
168 if let Ok(ip) = h.parse::<std::net::IpAddr>() {
169 return is_metadata_ip(ip);
170 }
171 matches!(h, "metadata.google.internal" | "metadata")
172}
173
174pub fn is_metadata_ip(ip: std::net::IpAddr) -> bool {
176 let v4_meta =
177 |v4: std::net::Ipv4Addr| v4.is_link_local() || v4.octets() == [100, 100, 100, 200];
178 match ip {
179 std::net::IpAddr::V4(v4) => v4_meta(v4),
180 std::net::IpAddr::V6(v6) => {
181 (v6.segments()[0] & 0xffc0) == 0xfe80
182 || v6 == std::net::Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254)
183 || v6.to_ipv4_mapped().is_some_and(v4_meta)
184 }
185 }
186}
187
188pub fn canonical_host(host: &str) -> String {
194 let raw = host.to_ascii_lowercase();
195 if raw.is_empty() || raw.contains(['$', '{', '}', '*', '/', '?', '#', '@']) {
196 return raw;
197 }
198 match url::Url::parse(&format!("http://{raw}/"))
199 .ok()
200 .and_then(|u| u.host().map(|h| h.to_owned()))
201 {
202 Some(url::Host::Ipv4(v4)) => v4.to_string(),
203 Some(url::Host::Ipv6(v6)) => format!("[{v6}]"),
204 Some(url::Host::Domain(d)) => d.trim_end_matches('.').to_string(),
205 None => raw.trim_end_matches('.').to_string(),
206 }
207}
208
209fn name_matches(pattern: &str, name: &str) -> bool {
211 match pattern.strip_suffix('*') {
212 Some(prefix) => name.starts_with(prefix),
213 None => pattern == name,
214 }
215}
216
217fn host_matches(pattern: &str, host: &str) -> bool {
219 let pattern = pattern.to_ascii_lowercase();
220 if pattern == "*" {
221 return true;
222 }
223 match pattern.strip_prefix("*.") {
224 Some(suffix) => host.len() > suffix.len() + 1 && host.ends_with(&format!(".{suffix}")),
225 None => canonical_host(&pattern) == host,
226 }
227}
228
229fn normalize(path: &Path) -> Option<PathBuf> {
236 let absolute = if path.is_relative() {
237 std::env::current_dir().ok()?.join(path)
238 } else {
239 path.to_path_buf()
240 };
241 let components: Vec<Component<'_>> = absolute.components().collect();
242 for split in (1..=components.len()).rev() {
243 let prefix: PathBuf = components[..split].iter().collect();
244 let Ok(mut out) = std::fs::canonicalize(&prefix) else {
245 continue;
246 };
247 for c in &components[split..] {
248 match c {
249 Component::CurDir => {}
250 Component::Normal(name) => out.push(name),
251 _ => return None,
252 }
253 }
254 return Some(out);
255 }
256 None
257}
258
259fn scheme_of(value: &str) -> Option<String> {
262 let colon = value.find(':')?;
263 let scheme = &value[..colon];
264 let valid = scheme.len() > 1
265 && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
266 && scheme
267 .chars()
268 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
269 let rest = value[colon + 1..]
270 .split(['/', '?', '#'])
271 .next()
272 .unwrap_or("");
273 let port = !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit());
274 (valid && !port).then(|| scheme.to_ascii_lowercase())
275}
276
277fn local_part(value: &str) -> Option<&str> {
280 match scheme_of(value).as_deref() {
281 Some("file") => {
282 let rest = &value[5..];
283 Some(rest.strip_prefix("//").unwrap_or(rest))
284 }
285 Some("sqlite") => {
286 let rest = &value[7..];
287 let rest = rest.strip_prefix("//").unwrap_or(rest);
288 Some(rest.split('?').next().unwrap_or(rest))
289 }
290 Some(_) => None,
291 None => Some(value),
292 }
293}
294
295fn is_relative_path(value: &str) -> bool {
299 match value.split_once('/') {
300 Some((first, _)) => {
301 !first.contains(['.', ':', '[']) && !first.eq_ignore_ascii_case("localhost")
302 }
303 None => false,
304 }
305}
306
307pub fn url_hosts(value: &str) -> Vec<String> {
311 let after = match value.find("://") {
312 Some(i) => &value[i + 3..],
313 None => match scheme_of(value) {
314 Some(s) => &value[s.len() + 1..],
315 None => value,
316 },
317 };
318 let authority = after.split(['/', '?', '#']).next().unwrap_or("");
319 let hosts = match authority.rfind('@') {
320 Some(at) => &authority[at + 1..],
321 None => authority,
322 };
323 hosts
324 .split(',')
325 .map(|h| {
326 let h = if h.starts_with('[') {
327 h.find(']').map_or(h, |end| &h[..=end])
328 } else {
329 h.rsplit_once(':')
330 .filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit()))
331 .map_or(h, |(host, _)| host)
332 };
333 canonical_host(h)
334 })
335 .filter(|h| !h.is_empty())
336 .collect()
337}
338
339impl ReferencePolicy {
340 pub fn open() -> Self {
342 Self {
343 path_roots: None,
344 url_schemes: None,
345 url_hosts: None,
346 env: None,
347 secret_schemes: None,
348 subprocess: true,
349 }
350 }
351
352 pub fn deny_all() -> Self {
354 Self {
355 path_roots: Some(Vec::new()),
356 url_schemes: Some(BTreeSet::new()),
357 url_hosts: Some(Vec::new()),
358 env: Some(Vec::new()),
359 secret_schemes: Some(BTreeSet::new()),
360 subprocess: false,
361 }
362 }
363
364 pub fn with_path_roots<I, P>(mut self, roots: I) -> Self
366 where
367 I: IntoIterator<Item = P>,
368 P: Into<PathBuf>,
369 {
370 self.path_roots = Some(roots.into_iter().map(Into::into).collect());
371 self
372 }
373
374 pub fn any_path(mut self) -> Self {
376 self.path_roots = None;
377 self
378 }
379
380 pub fn with_url_schemes<I, S>(mut self, schemes: I) -> Self
382 where
383 I: IntoIterator<Item = S>,
384 S: AsRef<str>,
385 {
386 self.url_schemes = Some(
387 schemes
388 .into_iter()
389 .map(|s| s.as_ref().to_ascii_lowercase())
390 .collect(),
391 );
392 self
393 }
394
395 pub fn any_url_scheme(mut self) -> Self {
397 self.url_schemes = None;
398 self
399 }
400
401 pub fn with_url_hosts<I, S>(mut self, hosts: I) -> Self
404 where
405 I: IntoIterator<Item = S>,
406 S: Into<String>,
407 {
408 self.url_hosts = Some(hosts.into_iter().map(Into::into).collect());
409 self
410 }
411
412 pub fn any_url_host(mut self) -> Self {
414 self.url_hosts = None;
415 self
416 }
417
418 pub fn with_env<I, S>(mut self, names: I) -> Self
420 where
421 I: IntoIterator<Item = S>,
422 S: Into<String>,
423 {
424 self.env = Some(names.into_iter().map(Into::into).collect());
425 self
426 }
427
428 pub fn any_env(mut self) -> Self {
430 self.env = None;
431 self
432 }
433
434 pub fn with_secret_schemes<I, S>(mut self, schemes: I) -> Self
436 where
437 I: IntoIterator<Item = S>,
438 S: AsRef<str>,
439 {
440 self.secret_schemes = Some(
441 schemes
442 .into_iter()
443 .map(|s| s.as_ref().to_ascii_lowercase())
444 .collect(),
445 );
446 self
447 }
448
449 pub fn any_secret_scheme(mut self) -> Self {
451 self.secret_schemes = None;
452 self
453 }
454
455 pub fn with_subprocess(mut self, allowed: bool) -> Self {
457 self.subprocess = allowed;
458 self
459 }
460
461 pub fn path_roots(&self) -> Option<&[PathBuf]> {
463 self.path_roots.as_deref()
464 }
465
466 pub fn url_schemes(&self) -> Option<&BTreeSet<String>> {
468 self.url_schemes.as_ref()
469 }
470
471 pub fn url_host_patterns(&self) -> Option<&[String]> {
473 self.url_hosts.as_deref()
474 }
475
476 pub fn env_patterns(&self) -> Option<&[String]> {
478 self.env.as_deref()
479 }
480
481 pub fn secret_schemes(&self) -> Option<&BTreeSet<String>> {
483 self.secret_schemes.as_ref()
484 }
485
486 pub fn allows_subprocess(&self) -> bool {
488 self.subprocess
489 }
490
491 pub fn check_env(&self, name: &str) -> Result<(), Refusal> {
493 match &self.env {
494 None => Ok(()),
495 Some(list) if list.iter().any(|p| name_matches(p, name)) => Ok(()),
496 Some(_) => Err(Refusal::new(
497 RefusalKind::Env,
498 name,
499 "environment variable not in the allow-list",
500 )),
501 }
502 }
503
504 pub fn check_file(&self, path: &str) -> Result<(), Refusal> {
506 self.check_local(path)
507 .map_err(|reason| Refusal::new(RefusalKind::File, path, reason))
508 }
509
510 pub fn check_secret(&self, scheme: &str, reference: &str) -> Result<(), Refusal> {
512 match &self.secret_schemes {
513 None => Ok(()),
514 Some(set) if set.contains(&scheme.to_ascii_lowercase()) => Ok(()),
515 Some(_) => Err(Refusal::new(
516 RefusalKind::Secret,
517 format!("{scheme}:{reference}"),
518 "secret-manager scheme not in the allow-list",
519 )),
520 }
521 }
522
523 pub fn check_path(&self, value: &str) -> Result<(), Refusal> {
526 match local_part(value) {
527 Some(local) => self
528 .check_local(local)
529 .map_err(|reason| Refusal::new(RefusalKind::Path, value, reason)),
530 None => self.check_url(value),
531 }
532 }
533
534 fn check_local(&self, path: &str) -> Result<(), String> {
535 let Some(roots) = &self.path_roots else {
536 return Ok(());
537 };
538 if path.is_empty() || path == ":memory:" || path == "/dev/null" {
539 return Ok(());
540 }
541 let dynamic = path.find(['*', '?', '[', '{']).unwrap_or(path.len());
542 let (base, rest) = match path[..dynamic].rfind('/') {
543 Some(slash) if dynamic < path.len() => (&path[..slash.max(1)], &path[slash + 1..]),
544 None if dynamic < path.len() => (".", path),
545 _ => (path, ""),
546 };
547 if rest.split('/').any(|seg| seg == "..") {
548 return Err("a pattern or token segment may not contain `..`".into());
549 }
550 let Some(target) = normalize(Path::new(base)) else {
551 return Err("a `..` below a directory that does not exist cannot be checked".into());
552 };
553 if roots
554 .iter()
555 .filter_map(|r| normalize(r))
556 .any(|root| target.starts_with(root))
557 {
558 return Ok(());
559 }
560 Err(if roots.is_empty() {
561 "local paths are not allowed".into()
562 } else {
563 format!(
564 "outside the allowed path roots ({})",
565 roots
566 .iter()
567 .map(|r| r.display().to_string())
568 .collect::<Vec<_>>()
569 .join(", ")
570 )
571 })
572 }
573
574 pub fn check_url(&self, value: &str) -> Result<(), Refusal> {
578 let refuse = |reason: String| Refusal::new(RefusalKind::Url, value, reason);
579 let scheme = scheme_of(value).filter(|_| !value.starts_with('/'));
580 match scheme.as_deref() {
581 Some("file") | Some("sqlite") => return self.check_path(value),
582 None if value.starts_with(['/', '.', '?', '#']) => return Ok(()),
583 None if is_relative_path(value) => return Ok(()),
584 _ => {}
585 }
586 if let (Some(scheme), Some(allowed)) = (&scheme, &self.url_schemes)
587 && !allowed.contains(scheme)
588 {
589 return Err(refuse(format!(
590 "URL scheme `{scheme}` not in the allow-list"
591 )));
592 }
593 for host in url_hosts(value) {
594 if host.contains("${") || host.contains('*') {
595 if self.url_hosts.is_some() {
596 return Err(refuse(
597 "a host taken from a token cannot be checked against the host allow-list"
598 .into(),
599 ));
600 }
601 continue;
602 }
603 match &self.url_hosts {
604 Some(list) if list.iter().any(|p| host_matches(p, &host)) => {}
605 Some(_) => return Err(refuse(format!("host `{host}` not in the allow-list"))),
606 None if is_metadata_host(&host) => {
607 return Err(refuse(format!(
608 "host `{host}` is a link-local / metadata endpoint"
609 )));
610 }
611 None => {}
612 }
613 }
614 Ok(())
615 }
616
617 pub fn check_command(&self, value: &str) -> Result<(), Refusal> {
619 if self.subprocess {
620 Ok(())
621 } else {
622 Err(Refusal::new(
623 RefusalKind::Command,
624 value,
625 "connectors that run a program are not allowed",
626 ))
627 }
628 }
629
630 pub fn check(&self, r: &ResourceRef) -> Result<(), Refusal> {
632 match r.kind {
633 ResourceKind::Path => self.check_path(&r.value),
634 ResourceKind::Url => self.check_url(&r.value),
635 ResourceKind::Command => self.check_command(&r.value),
636 }
637 }
638}
639
640const MAX_DEPTH: usize = 64;
641
642fn deref<'a>(root: &'a Value, node: &'a Value) -> &'a Value {
643 let mut cur = node;
644 for _ in 0..MAX_DEPTH {
645 let Some(r) = cur.get("$ref").and_then(Value::as_str) else {
646 return cur;
647 };
648 let Some(pointer) = r.strip_prefix('#') else {
649 return cur;
650 };
651 match root.pointer(pointer) {
652 Some(next) => cur = next,
653 None => return cur,
654 }
655 }
656 cur
657}
658
659fn annotation_of(node: &Value) -> Option<ResourceKind> {
660 node.get(RESOURCE_ANNOTATION)
661 .and_then(Value::as_str)
662 .and_then(ResourceKind::parse)
663}
664
665fn alternatives<'a>(root: &'a Value, node: &'a Value, out: &mut Vec<&'a Value>, depth: usize) {
668 if depth > MAX_DEPTH {
669 return;
670 }
671 out.push(node);
672 let node = deref(root, node);
673 if !std::ptr::eq(node, *out.last().expect("pushed")) {
674 out.push(node);
675 }
676 for key in ["oneOf", "anyOf", "allOf"] {
677 if let Some(Value::Array(branches)) = node.get(key) {
678 for b in branches {
679 alternatives(root, b, out, depth + 1);
680 }
681 }
682 }
683}
684
685fn escape_pointer(key: &str) -> String {
686 key.replace('~', "~0").replace('/', "~1")
687}
688
689fn visit(
690 root: &Value,
691 node: &Value,
692 value: &Value,
693 pointer: &str,
694 out: &mut Vec<ResourceRef>,
695 depth: usize,
696) {
697 if depth > MAX_DEPTH {
698 return;
699 }
700 let mut alts = Vec::new();
701 alternatives(root, node, &mut alts, 0);
702 let kind = alts.iter().find_map(|n| annotation_of(n));
703 match value {
704 Value::String(s) => {
705 if let Some(kind) = kind {
706 out.push(ResourceRef::new(pointer, kind, s.clone()));
707 }
708 }
709 Value::Array(items) => {
710 for (i, item) in items.iter().enumerate() {
711 let p = format!("{pointer}/{i}");
712 if let (Some(kind), Value::String(s)) = (kind, item) {
713 out.push(ResourceRef::new(p.clone(), kind, s.clone()));
714 }
715 for alt in &alts {
716 if let Some(items_schema) = alt.get("items").filter(|v| v.is_object()) {
717 visit(root, items_schema, item, &p, out, depth + 1);
718 }
719 }
720 }
721 }
722 Value::Object(map) => {
723 for (k, child) in map {
724 let p = format!("{pointer}/{}", escape_pointer(k));
725 for alt in &alts {
726 let declared = alt.get("properties").and_then(|props| props.get(k));
727 if let Some(s) = declared {
728 visit(root, s, child, &p, out, depth + 1);
729 } else if let Some(s) =
730 alt.get("additionalProperties").filter(|v| v.is_object())
731 {
732 visit(root, s, child, &p, out, depth + 1);
733 }
734 }
735 }
736 }
737 _ => {}
738 }
739}
740
741pub fn annotated_resources(schema: &Value, config: &Value) -> Vec<ResourceRef> {
746 let mut out = Vec::new();
747 visit(schema, schema, config, "", &mut out, 0);
748 let mut seen = BTreeSet::new();
749 out.retain(|r| seen.insert((r.pointer.clone(), r.kind, r.value.clone())));
750 out
751}
752
753fn collect_fields(
754 root: &Value,
755 node: &Value,
756 path: &str,
757 out: &mut BTreeSet<(String, ResourceKind)>,
758 depth: usize,
759) {
760 if depth > 12 {
761 return;
762 }
763 let mut alts = Vec::new();
764 alternatives(root, node, &mut alts, 0);
765 if let Some(kind) = alts.iter().find_map(|n| annotation_of(n))
766 && !path.is_empty()
767 {
768 out.insert((path.to_string(), kind));
769 }
770 for alt in alts {
771 if let Some(Value::Object(props)) = alt.get("properties") {
772 for (k, s) in props {
773 let p = if path.is_empty() {
774 k.clone()
775 } else {
776 format!("{path}.{k}")
777 };
778 collect_fields(root, s, &p, out, depth + 1);
779 }
780 }
781 if let Some(items) = alt.get("items").filter(|v| v.is_object()) {
782 collect_fields(root, items, &format!("{path}[]"), out, depth + 1);
783 }
784 if let Some(extra) = alt.get("additionalProperties").filter(|v| v.is_object()) {
785 collect_fields(root, extra, &format!("{path}{{}}"), out, depth + 1);
786 }
787 }
788}
789
790pub fn annotated_fields(schema: &Value) -> Vec<(String, ResourceKind)> {
793 let mut out = BTreeSet::new();
794 collect_fields(schema, schema, "", &mut out, 0);
795 out.into_iter().collect()
796}
797
798pub fn declares(schema: &Value, kind: ResourceKind) -> bool {
800 annotated_fields(schema).iter().any(|(_, k)| *k == kind)
801}
802
803pub const SANDBOX_ANNOTATION: &str = "x-faucet-sandbox";
814
815pub fn sandbox_fields(schema: &Value) -> Vec<String> {
818 let mut alts = Vec::new();
819 alternatives(schema, schema, &mut alts, 0);
820 let mut out = BTreeSet::new();
821 for alt in alts {
822 let Some(Value::Object(props)) = alt.get("properties") else {
823 continue;
824 };
825 for (name, field) in props {
826 let mut field_alts = Vec::new();
827 alternatives(schema, field, &mut field_alts, 0);
828 if field_alts
829 .iter()
830 .any(|n| n.get(SANDBOX_ANNOTATION) == Some(&Value::Bool(true)))
831 {
832 out.insert(name.clone());
833 }
834 }
835 }
836 out.into_iter().collect()
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842 use serde_json::json;
843
844 #[test]
847 fn self_referential_schemas_and_deep_configs_stop_at_the_depth_cap() {
848 let s = json!({"oneOf": [{"$ref": "#"}], "type": "object"});
849 assert!(annotated_fields(&s).is_empty());
850 let nested = json!({
851 "type": "object",
852 "properties": {"next": {"$ref": "#"}, "path": {"type": "string", "x-faucet-resource": "path"}}
853 });
854 let mut config = json!({"path": "/deepest"});
855 for _ in 0..MAX_DEPTH + 2 {
856 config = json!({"next": config});
857 }
858 let found = annotated_resources(&nested, &config);
859 assert!(found.is_empty(), "{found:?}");
860 let shallow = annotated_resources(&nested, &json!({"next": {"path": "/a"}}));
861 assert_eq!(shallow.len(), 1);
862 }
863
864 #[test]
865 fn a_scheme_without_slashes_names_its_host() {
866 assert_eq!(url_hosts("mysql:db.internal:3306/app"), vec!["db.internal"]);
867 }
868
869 #[test]
870 fn a_branch_without_properties_declares_no_sandbox_field() {
871 assert!(sandbox_fields(&json!({"oneOf": [{"type": "string"}]})).is_empty());
872 }
873
874 #[test]
875 fn sandbox_fields_are_read_from_the_root_and_its_branches() {
876 assert!(sandbox_fields(&schema()).is_empty());
877 let s = json!({
878 "type": "object",
879 "properties": {
880 "sandbox": {"type": "boolean", "x-faucet-sandbox": true},
881 "confine": {"$ref": "#/$defs/Flag"},
882 "off": {"type": "boolean", "x-faucet-sandbox": false},
883 "query": {"type": "string"}
884 },
885 "allOf": [{"properties": {"lock": {"anyOf": [{"type": "boolean", "x-faucet-sandbox": true}]}}}],
886 "$defs": {"Flag": {"type": "boolean", "x-faucet-sandbox": true}}
887 });
888 assert_eq!(sandbox_fields(&s), vec!["confine", "lock", "sandbox"]);
889 }
890
891 fn schema() -> Value {
892 json!({
893 "type": "object",
894 "properties": {
895 "path": {"type": "string", "x-faucet-resource": "path"},
896 "name": {"type": "string"},
897 "files": {"type": "array", "items": {"type": "string"}, "x-faucet-resource": "path"},
898 "tls": {"anyOf": [{"$ref": "#/$defs/Tls"}, {"type": "null"}]},
899 "auth": {"oneOf": [
900 {"type": "object", "properties": {"type": {"const": "key"}, "config": {"$ref": "#/$defs/Key"}}},
901 {"type": "object", "properties": {"type": {"const": "none"}}}
902 ]},
903 "endpoints": {"type": "object", "additionalProperties": {"type": "string", "x-faucet-resource": "url"}},
904 "cmd": {"$ref": "#/$defs/Cmd", "x-faucet-resource": "command"},
905 "hops": {"type": "array", "items": {"$ref": "#/$defs/Hop"}}
906 },
907 "$defs": {
908 "Tls": {"type": "object", "properties": {"ca_path": {"type": ["string", "null"], "x-faucet-resource": "path"}}},
909 "Key": {"type": "object", "properties": {"key_file": {"type": "string", "x-faucet-resource": "path"}}},
910 "Cmd": {"type": "string"},
911 "Hop": {"type": "object", "properties": {"url": {"type": "string", "x-faucet-resource": "url"}}}
912 }
913 })
914 }
915
916 #[test]
917 fn finds_every_annotated_value() {
918 let config = json!({
919 "path": "/data/in.csv",
920 "name": "/etc/passwd",
921 "files": ["/a", "/b"],
922 "tls": {"ca_path": "/certs/ca.pem"},
923 "auth": {"type": "key", "config": {"key_file": "/keys/k"}},
924 "endpoints": {"a/b": "https://x.example"},
925 "cmd": "/bin/tap",
926 "hops": [{"url": "http://h1"}, {"url": 5}],
927 "unknown": {"path": "/ignored"}
928 });
929 let found = annotated_resources(&schema(), &config);
930 let mut got: Vec<(&str, ResourceKind, &str)> = found
931 .iter()
932 .map(|r| (r.pointer.as_str(), r.kind, r.value.as_str()))
933 .collect();
934 got.sort();
935 assert_eq!(
936 got,
937 vec![
938 ("/auth/config/key_file", ResourceKind::Path, "/keys/k"),
939 ("/cmd", ResourceKind::Command, "/bin/tap"),
940 ("/endpoints/a~1b", ResourceKind::Url, "https://x.example"),
941 ("/files/0", ResourceKind::Path, "/a"),
942 ("/files/1", ResourceKind::Path, "/b"),
943 ("/hops/0/url", ResourceKind::Url, "http://h1"),
944 ("/path", ResourceKind::Path, "/data/in.csv"),
945 ("/tls/ca_path", ResourceKind::Path, "/certs/ca.pem"),
946 ]
947 );
948 }
949
950 #[test]
951 fn lists_annotated_fields() {
952 let fields = annotated_fields(&schema());
953 let names: Vec<String> = fields.iter().map(|(p, k)| format!("{p}:{k}")).collect();
954 assert_eq!(
955 names,
956 vec![
957 "auth.config.key_file:path",
958 "cmd:command",
959 "endpoints{}:url",
960 "files:path",
961 "hops[].url:url",
962 "path:path",
963 "tls.ca_path:path",
964 ]
965 );
966 assert!(declares(&schema(), ResourceKind::Command));
967 assert!(!declares(&json!({"type": "object"}), ResourceKind::Path));
968 }
969
970 #[test]
971 fn a_recursive_schema_terminates() {
972 let s = json!({"$ref": "#/$defs/A", "$defs": {"A": {"$ref": "#/$defs/A"}}});
973 assert!(annotated_resources(&s, &json!({"a": 1})).is_empty());
974 let s = json!({"$defs": {"N": {"type": "object", "properties": {"n": {"$ref": "#/$defs/N"}, "p": {"type": "string", "x-faucet-resource": "path"}}}}, "$ref": "#/$defs/N"});
975 let deepest = &annotated_fields(&s)[0].0;
976 assert!(
977 deepest.starts_with("n.n.") && deepest.ends_with(".p"),
978 "{deepest}"
979 );
980 let deep = json!({"n": {"n": {"p": "/x"}}});
981 assert_eq!(annotated_resources(&s, &deep)[0].pointer, "/n/n/p");
982 assert!(annotated_resources(&json!({"$ref": "http://x"}), &json!("v")).is_empty());
983 assert!(annotated_resources(&json!({"$ref": "#/missing"}), &json!("v")).is_empty());
984 }
985
986 #[test]
987 fn the_derive_attribute_produces_the_annotation() {
988 #[derive(schemars::JsonSchema)]
989 #[allow(dead_code)]
990 struct Tls {
991 #[schemars(extend("x-faucet-resource" = "path"))]
992 ca: Option<std::path::PathBuf>,
993 }
994 #[derive(schemars::JsonSchema)]
995 #[allow(dead_code)]
996 struct Cfg {
997 #[schemars(extend("x-faucet-resource" = "url"))]
998 url: String,
999 #[schemars(extend("x-faucet-resource" = "command"))]
1000 program: Vec<String>,
1001 tls: Option<Tls>,
1002 }
1003 let schema = serde_json::to_value(schemars::schema_for!(Cfg)).unwrap();
1004 let fields = annotated_fields(&schema);
1005 assert_eq!(
1006 fields,
1007 vec![
1008 ("program".to_string(), ResourceKind::Command),
1009 ("tls.ca".to_string(), ResourceKind::Path),
1010 ("url".to_string(), ResourceKind::Url),
1011 ]
1012 );
1013 let found = annotated_resources(
1014 &schema,
1015 &json!({"url": "https://h", "program": ["tap"], "tls": {"ca": "/c"}}),
1016 );
1017 assert_eq!(found.len(), 3);
1018 }
1019
1020 #[test]
1021 fn kinds_round_trip() {
1022 for k in [ResourceKind::Path, ResourceKind::Url, ResourceKind::Command] {
1023 assert_eq!(ResourceKind::parse(k.as_str()), Some(k));
1024 assert_eq!(serde_json::to_value(k).unwrap(), json!(k.as_str()));
1025 }
1026 assert_eq!(ResourceKind::parse("other"), None);
1027 }
1028
1029 #[test]
1030 fn the_default_denies_everything() {
1031 let p = ReferencePolicy::default();
1032 assert_eq!(p, ReferencePolicy::deny_all());
1033 assert!(p.check_env("HOME").is_err());
1034 assert!(p.check_file("/tmp/x").is_err());
1035 assert!(p.check_secret("vault", "a/b").is_err());
1036 assert!(p.check_path("/tmp/x").is_err());
1037 assert!(p.check_url("https://example.com").is_err());
1038 assert!(p.check_command("/bin/sh").is_err());
1039 assert!(!p.allows_subprocess());
1040 let e = p.check_path("/tmp/x").unwrap_err();
1041 assert_eq!(e.kind, RefusalKind::Path);
1042 assert!(e.to_string().contains("not allowed"), "{e}");
1043 }
1044
1045 #[test]
1046 fn the_open_policy_allows_everything_but_metadata_hosts() {
1047 let p = ReferencePolicy::open();
1048 assert!(p.check_env("HOME").is_ok());
1049 assert!(p.check_file("/etc/passwd").is_ok());
1050 assert!(p.check_secret("vault", "a").is_ok());
1051 assert!(p.check_path("/etc/passwd").is_ok());
1052 assert!(p.check_url("https://example.com").is_ok());
1053 assert!(p.check_command("/bin/sh").is_ok());
1054 for bad in [
1055 "http://169.254.169.254/latest",
1056 "http://[fe80::1]/",
1057 "http://[fd00:ec2::254]/",
1058 "http://[::ffff:169.254.169.254]/",
1059 "http://metadata.google.internal/x",
1060 "http://100.100.100.200/",
1061 ] {
1062 assert!(p.check_url(bad).is_err(), "{bad}");
1063 }
1064 assert!(p.check_url("http://[::1]:80/").is_ok());
1065 let listed = ReferencePolicy::open().with_url_hosts(["169.254.169.254"]);
1066 assert!(listed.check_url("http://169.254.169.254/").is_ok());
1067 }
1068
1069 #[test]
1070 fn env_and_secret_allow_lists() {
1071 let p = ReferencePolicy::deny_all()
1072 .with_env(["APP_*", "TOKEN"])
1073 .with_secret_schemes(["Vault"]);
1074 assert!(p.check_env("APP_KEY").is_ok());
1075 assert!(p.check_env("TOKEN").is_ok());
1076 assert!(p.check_env("TOKEN2").is_err());
1077 assert!(p.check_secret("vault", "x").is_ok());
1078 assert_eq!(
1079 p.check_secret("aws-sm", "x").unwrap_err().subject,
1080 "aws-sm:x"
1081 );
1082 assert_eq!(p.env_patterns().unwrap().len(), 2);
1083 assert!(p.secret_schemes().unwrap().contains("vault"));
1084 assert!(ReferencePolicy::deny_all().any_env().check_env("X").is_ok());
1085 assert!(
1086 ReferencePolicy::deny_all()
1087 .any_secret_scheme()
1088 .check_secret("x", "y")
1089 .is_ok()
1090 );
1091 }
1092
1093 #[test]
1094 fn paths_must_sit_under_a_root() {
1095 let dir = std::env::temp_dir().join(format!("faucet-resolve-{}", std::process::id()));
1096 std::fs::create_dir_all(dir.join("sub")).unwrap();
1097 let p = ReferencePolicy::deny_all().with_path_roots([&dir]);
1098 assert_eq!(p.path_roots().unwrap().len(), 1);
1099 let d = dir.display();
1100 for ok in [
1101 format!("{d}/in.csv"),
1102 format!("{d}/sub/new/out.jsonl"),
1103 format!("{d}/*.csv"),
1104 format!("{d}/sub/part-{{part}}.jsonl"),
1105 format!("{d}/x/${{now.date}}.csv"),
1106 format!("file://{d}/a"),
1107 format!("sqlite:{d}/db.sqlite?mode=rwc"),
1108 format!("sqlite://{d}/db.sqlite"),
1109 ":memory:".to_string(),
1110 "/dev/null".to_string(),
1111 String::new(),
1112 ] {
1113 assert!(p.check_path(&ok).is_ok(), "{ok}");
1114 }
1115 for bad in [
1116 "/etc/passwd".to_string(),
1117 format!("{d}/../escape"),
1118 format!("{d}/*/../../etc/*"),
1119 format!("{d}x/sibling"),
1120 "relative/elsewhere".to_string(),
1121 ] {
1122 assert!(p.check_path(&bad).is_err(), "{bad}");
1123 }
1124 assert!(p.check_file(&format!("{d}/a")).is_ok());
1125 assert_eq!(
1126 p.check_file("/etc/hosts").unwrap_err().kind,
1127 RefusalKind::File
1128 );
1129 #[cfg(unix)]
1130 {
1131 let link = dir.join("link");
1132 let _ = std::fs::remove_file(&link);
1133 std::os::unix::fs::symlink("/etc", &link).unwrap();
1134 assert!(p.check_path(&format!("{d}/link/passwd")).is_err());
1135 }
1136 assert!(p.check_path(&format!("{d}/sub/../in.csv")).is_ok());
1137 assert!(p.check_path(&format!("{d}/missing/../in.csv")).is_err());
1138 let cwd = ReferencePolicy::deny_all().with_path_roots(["."]);
1139 assert!(cwd.check_path("data/in.csv").is_ok());
1140 assert!(cwd.check_path("*.csv").is_ok());
1141 assert!(
1142 ReferencePolicy::deny_all()
1143 .any_path()
1144 .check_path("/x")
1145 .is_ok()
1146 );
1147 let _ = std::fs::remove_dir_all(&dir);
1148 }
1149
1150 #[test]
1151 fn a_remote_location_in_a_path_field_is_a_url() {
1152 let p = ReferencePolicy::open().with_url_hosts(["bucket"]);
1153 assert!(p.check_path("s3://bucket/key").is_ok());
1154 assert_eq!(
1155 p.check_path("s3://other/key").unwrap_err().kind,
1156 RefusalKind::Url
1157 );
1158 assert!(p.check_path("C:/data/x").is_ok());
1159 }
1160
1161 #[test]
1162 fn urls_are_checked_by_scheme_and_host() {
1163 let p = ReferencePolicy::open()
1164 .with_url_schemes(["https", "postgres"])
1165 .with_url_hosts(["api.example.com", "*.internal", "db"]);
1166 assert!(p.url_schemes().unwrap().contains("https"));
1167 assert_eq!(p.url_host_patterns().unwrap().len(), 3);
1168 for ok in [
1169 "https://api.example.com/v1",
1170 "https://user:p@ss@api.example.com:8443/x?q#f",
1171 "https://a.b.internal/",
1172 "postgres://u:p@db:5432/x",
1173 "postgres://db,db/x",
1174 "db:5432",
1175 "/tmp/local",
1176 ] {
1177 assert!(p.check_url(ok).is_ok(), "{ok}");
1178 }
1179 for bad in [
1180 "http://api.example.com/",
1181 "https://evil.example.com/",
1182 "https://internal/",
1183 "postgres://db,evil/x",
1184 "https://${parent.host}/x",
1185 "evil:5432",
1186 ] {
1187 assert!(p.check_url(bad).is_err(), "{bad}");
1188 }
1189 let any_host = ReferencePolicy::open();
1190 assert!(any_host.check_url("https://${parent.host}/x").is_ok());
1191 assert!(any_host.check_url("unix:///var/run/x.sock").is_ok());
1192 let star = ReferencePolicy::open().with_url_hosts(["*"]);
1193 assert!(star.check_url("https://anything/").is_ok());
1194 let paths = ReferencePolicy::open()
1195 .with_path_roots(Vec::<PathBuf>::new())
1196 .with_url_hosts(Vec::<String>::new());
1197 assert!(paths.check_url("file:///etc/passwd").is_err());
1198 assert!(paths.check_url("sqlite:/etc/x.db").is_err());
1199 assert!(paths.check_url("file:./rel").is_err());
1200 assert!(paths.check_url("/jobs/1").is_ok());
1201 assert!(paths.check_url("jobs/${job_id}").is_ok());
1202 assert!(paths.check_url("localhost/x").is_err());
1203 assert!(paths.check_url("api.example.com/x").is_err());
1204 assert!(
1205 ReferencePolicy::deny_all()
1206 .any_url_scheme()
1207 .any_url_host()
1208 .check_url("ftp://x/")
1209 .is_ok()
1210 );
1211 }
1212
1213 #[test]
1214 fn commands_follow_the_subprocess_switch() {
1215 let r = ResourceRef::new("/executable", ResourceKind::Command, "/bin/tap");
1216 assert!(ReferencePolicy::deny_all().check(&r).is_err());
1217 assert!(
1218 ReferencePolicy::deny_all()
1219 .with_subprocess(true)
1220 .check(&r)
1221 .is_ok()
1222 );
1223 let url = ResourceRef::new("/u", ResourceKind::Url, "https://x");
1224 assert!(ReferencePolicy::open().check(&url).is_ok());
1225 let path = ResourceRef::new("/p", ResourceKind::Path, "/x");
1226 assert!(ReferencePolicy::open().check(&path).is_ok());
1227 }
1228
1229 #[cfg(unix)]
1230 #[test]
1231 fn a_parent_step_after_a_symlink_follows_the_link_target() {
1232 let base = std::env::temp_dir().join(format!("faucet-resolve-ln-{}", std::process::id()));
1233 let root = base.join("root");
1234 let outside = base.join("outside");
1235 std::fs::create_dir_all(&root).unwrap();
1236 std::fs::create_dir_all(outside.join("inner")).unwrap();
1237 std::fs::write(outside.join("secret"), "x").unwrap();
1238 std::os::unix::fs::symlink(outside.join("inner"), root.join("l")).unwrap();
1239 let p = ReferencePolicy::deny_all().with_path_roots([&root]);
1240 let r = root.display();
1241 assert!(p.check_path(&format!("{r}/l/../secret")).is_err());
1242 assert!(p.check_path(&format!("{r}/l/../new.csv")).is_err());
1243 assert!(p.check_path(&format!("{r}/l/../*.csv")).is_err());
1244 assert!(p.check_path(&format!("{r}/ok.csv")).is_ok());
1245 let _ = std::fs::remove_dir_all(&base);
1246 }
1247
1248 #[test]
1249 fn numeric_and_dotted_forms_of_a_metadata_host_are_refused() {
1250 let p = ReferencePolicy::open();
1251 for bad in [
1252 "http://2852039166/latest",
1253 "http://0xa9fea9fe/latest",
1254 "http://0xA9.0xFE.0xA9.0xFE/",
1255 "http://0251.0376.0251.0376/",
1256 "http://169.254.43518/",
1257 "http://169.254.169.254./latest",
1258 "http://metadata.google.internal./x",
1259 "http://METADATA.google.internal/x",
1260 "http://[0:0:0:0:0:ffff:a9fe:a9fe]/",
1261 "postgres://u@db,2852039166:5432/x",
1262 ] {
1263 assert!(p.check_url(bad).is_err(), "{bad}");
1264 }
1265 assert!(is_metadata_host("2852039166"));
1266 assert!(!is_metadata_host("example.com."));
1267 assert_eq!(canonical_host("Example.COM."), "example.com");
1268 assert_eq!(canonical_host("${host}"), "${host}");
1269 assert_eq!(canonical_host("0x7f000001"), "127.0.0.1");
1270 let listed = ReferencePolicy::open().with_url_hosts(["api.example.com"]);
1271 assert!(listed.check_url("https://API.example.com./v1").is_ok());
1272 assert!(listed.check_url("https://0x7f000001/").is_err());
1273 }
1274
1275 #[test]
1276 fn helpers() {
1277 assert_eq!(scheme_of("C:/x"), None);
1278 assert_eq!(scheme_of("HTTPS://x").as_deref(), Some("https"));
1279 assert_eq!(scheme_of("no-colon"), None);
1280 assert_eq!(scheme_of("bad scheme:x"), None);
1281 assert_eq!(url_hosts("https://[::1]:8080/x"), vec!["[::1]".to_string()]);
1282 assert_eq!(url_hosts("h:notaport"), vec!["h:notaport".to_string()]);
1283 assert!(!host_matches("*.example.com", "example.com"));
1284 assert_eq!(
1285 normalize(Path::new("/./faucet-no-such-dir/./x")),
1286 Some(PathBuf::from("/faucet-no-such-dir/x"))
1287 );
1288 assert_eq!(normalize(Path::new("/faucet-no-such-dir/../etc")), None);
1289 let e = Refusal::new(RefusalKind::Command, "x", "why");
1290 assert_eq!(e.to_string(), "x (why)");
1291 assert_eq!(ResourceKind::Url.to_string(), "url");
1292 }
1293}