|
1 | 1 | use std::ops::Range; |
2 | 2 |
|
3 | 3 | use anyhow::{bail, ensure, Context}; |
4 | | -use spin_factors::AppComponent; |
| 4 | +use spin_factors::{App, AppComponent}; |
5 | 5 | use spin_locked_app::MetadataKey; |
6 | 6 |
|
7 | 7 | const ALLOWED_HOSTS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("allowed_outbound_hosts"); |
@@ -34,6 +34,46 @@ pub fn allowed_outbound_hosts(component: &AppComponent) -> anyhow::Result<Vec<St |
34 | 34 | Ok(allowed_hosts) |
35 | 35 | } |
36 | 36 |
|
| 37 | +/// Validates that all service chaining of an app will be satisfied by the |
| 38 | +/// supplied subset of components. |
| 39 | +/// |
| 40 | +/// This does a best effort look up of components that are |
| 41 | +/// allowed to be accessed through service chaining and will error early if a |
| 42 | +/// component is configured to to chain to another component that is not |
| 43 | +/// retained. All wildcard service chaining is disallowed and all templated URLs |
| 44 | +/// are ignored. |
| 45 | +pub fn validate_service_chaining_for_components( |
| 46 | + app: &App, |
| 47 | + retained_components: &[&str], |
| 48 | +) -> anyhow::Result<()> { |
| 49 | + app |
| 50 | + .triggers().try_for_each(|t| { |
| 51 | + let Ok(component) = t.component() else { return Ok(()) }; |
| 52 | + if retained_components.contains(&component.id()) { |
| 53 | + let allowed_hosts = allowed_outbound_hosts(&component).context("failed to get allowed hosts")?; |
| 54 | + for host in allowed_hosts { |
| 55 | + // Templated URLs are not yet resolved at this point, so ignore unresolvable URIs |
| 56 | + if let Ok(uri) = host.parse::<http::Uri>() { |
| 57 | + if let Some(chaining_target) = parse_service_chaining_target(&uri) { |
| 58 | + if !retained_components.contains(&chaining_target.as_ref()) { |
| 59 | + if chaining_target == "*" { |
| 60 | + return Err(anyhow::anyhow!("Selected component '{}' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]", component.id())); |
| 61 | + } |
| 62 | + return Err(anyhow::anyhow!( |
| 63 | + "Selected component '{}' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://{}.spin.internal\"]", |
| 64 | + component.id(), chaining_target |
| 65 | + )); |
| 66 | + } |
| 67 | + } |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + anyhow::Ok(()) |
| 72 | + })?; |
| 73 | + |
| 74 | + Ok(()) |
| 75 | +} |
| 76 | + |
37 | 77 | /// An address is a url-like string that contains a host, a port, and an optional scheme |
38 | 78 | #[derive(Eq, Debug, Clone)] |
39 | 79 | pub struct AllowedHostConfig { |
@@ -818,4 +858,90 @@ mod test { |
818 | 858 | AllowedHostsConfig::parse(&["*://127.0.0.1/24:63551"], &dummy_resolver()).unwrap(); |
819 | 859 | assert!(allowed.allows(&OutboundUrl::parse("tcp://127.0.0.1:63551", "tcp").unwrap())); |
820 | 860 | } |
| 861 | + |
| 862 | + #[tokio::test] |
| 863 | + async fn validate_service_chaining_for_components_fails() { |
| 864 | + let manifest = toml::toml! { |
| 865 | + spin_manifest_version = 2 |
| 866 | + |
| 867 | + [application] |
| 868 | + name = "test-app" |
| 869 | + |
| 870 | + [[trigger.test-trigger]] |
| 871 | + component = "empty" |
| 872 | + |
| 873 | + [component.empty] |
| 874 | + source = "does-not-exist.wasm" |
| 875 | + allowed_outbound_hosts = ["http://another.spin.internal"] |
| 876 | + |
| 877 | + [[trigger.another-trigger]] |
| 878 | + component = "another" |
| 879 | + |
| 880 | + [component.another] |
| 881 | + source = "does-not-exist.wasm" |
| 882 | + |
| 883 | + [[trigger.third-trigger]] |
| 884 | + component = "third" |
| 885 | + |
| 886 | + [component.third] |
| 887 | + source = "does-not-exist.wasm" |
| 888 | + allowed_outbound_hosts = ["http://*.spin.internal"] |
| 889 | + }; |
| 890 | + let locked_app = spin_factors_test::build_locked_app(&manifest) |
| 891 | + .await |
| 892 | + .expect("could not build locked app"); |
| 893 | + let app = App::new("unused", locked_app); |
| 894 | + let Err(e) = validate_service_chaining_for_components(&app, &["empty"]) else { |
| 895 | + panic!("Expected service chaining to non-retained component error"); |
| 896 | + }; |
| 897 | + assert_eq!( |
| 898 | + e.to_string(), |
| 899 | + "Selected component 'empty' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://another.spin.internal\"]" |
| 900 | + ); |
| 901 | + let Err(e) = validate_service_chaining_for_components(&app, &["third", "another"]) else { |
| 902 | + panic!("Expected wildcard service chaining error"); |
| 903 | + }; |
| 904 | + assert_eq!( |
| 905 | + e.to_string(), |
| 906 | + "Selected component 'third' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]" |
| 907 | + ); |
| 908 | + assert!(validate_service_chaining_for_components(&app, &["another"]).is_ok()); |
| 909 | + } |
| 910 | + |
| 911 | + #[tokio::test] |
| 912 | + async fn validate_service_chaining_for_components_with_templated_host_passes() { |
| 913 | + let manifest = toml::toml! { |
| 914 | + spin_manifest_version = 2 |
| 915 | + |
| 916 | + [application] |
| 917 | + name = "test-app" |
| 918 | + |
| 919 | + [variables] |
| 920 | + host = { default = "test" } |
| 921 | + |
| 922 | + [[trigger.test-trigger]] |
| 923 | + component = "empty" |
| 924 | + |
| 925 | + [component.empty] |
| 926 | + source = "does-not-exist.wasm" |
| 927 | + |
| 928 | + [[trigger.another-trigger]] |
| 929 | + component = "another" |
| 930 | + |
| 931 | + [component.another] |
| 932 | + source = "does-not-exist.wasm" |
| 933 | + |
| 934 | + [[trigger.third-trigger]] |
| 935 | + component = "third" |
| 936 | + |
| 937 | + [component.third] |
| 938 | + source = "does-not-exist.wasm" |
| 939 | + allowed_outbound_hosts = ["http://{{ host }}.spin.internal"] |
| 940 | + }; |
| 941 | + let locked_app = spin_factors_test::build_locked_app(&manifest) |
| 942 | + .await |
| 943 | + .expect("could not build locked app"); |
| 944 | + let app = App::new("unused", locked_app); |
| 945 | + assert!(validate_service_chaining_for_components(&app, &["empty", "third"]).is_ok()); |
| 946 | + } |
821 | 947 | } |
0 commit comments