diff --git a/.changesets/config_allowlist_insecure_artifacts.md b/.changesets/config_allowlist_insecure_artifacts.md new file mode 100644 index 0000000000..ecb9430227 --- /dev/null +++ b/.changesets/config_allowlist_insecure_artifacts.md @@ -0,0 +1,5 @@ +### Allow Router to pull graph artifacts from unsecure (non-SSL) registries. + +Allow users to configure a list of safe registry hostnames, so Router can pull graph artifacts over HTTP instead of HTTPS. Unsecure registries are commonly run within a private network such as a Kubernetes cluster or as a pull-through cache, where users want to avoid the overhead of setting up and distributing SSL certificates. + +By [@sirddoger](https://github.com/sirdodger) in https://github.com/apollographql/router/pull/8919 diff --git a/apollo-router/src/executable.rs b/apollo-router/src/executable.rs index 8249e95922..02d0f6c0f8 100644 --- a/apollo-router/src/executable.rs +++ b/apollo-router/src/executable.rs @@ -33,6 +33,7 @@ use crate::metrics::meter_provider_internal; use crate::plugin::plugins; use crate::plugins::telemetry::reload::otel::init_telemetry; use crate::registry::OciConfig; +use crate::registry::should_use_ssl; use crate::registry::validate_oci_reference; use crate::router::ConfigurationSource; use crate::router::RouterHttpServer; @@ -258,6 +259,8 @@ impl Opt { }) .unwrap_or(INITIAL_OCI_POLL_INTERVAL); + let use_ssl = should_use_ssl(&validated_reference); + Ok(OciConfig { apollo_key: self .apollo_key @@ -266,6 +269,7 @@ impl Opt { reference: validated_reference, hot_reload: self.hot_reload, poll_interval, + use_ssl, }) } diff --git a/apollo-router/src/registry/mod.rs b/apollo-router/src/registry/mod.rs index e33771c738..7ff97e8267 100644 --- a/apollo-router/src/registry/mod.rs +++ b/apollo-router/src/registry/mod.rs @@ -19,6 +19,7 @@ use thiserror::Error; use tokio::sync::mpsc::channel; use tokio_stream::wrappers::ReceiverStream; use tracing::instrument::WithSubscriber; +use url::Url; use crate::uplink::schema::SchemaState; @@ -100,6 +101,11 @@ pub struct OciConfig { /// The duration between polling pub poll_interval: Duration, + + /// Whether to use SSL (HTTPS) when connecting to the OCI registry. + /// Determined once at config creation from the registry hostname and + /// the `APOLLO_GRAPH_ARTIFACT_UNSECURE_HOSTS` environment variable. + pub use_ssl: bool, } #[derive(Debug, Clone)] @@ -296,17 +302,71 @@ async fn fetch_oci_blob( Ok(blob_data) } -/// The oci reference may not contain the protocol, only hostname[:port]. As a result, -/// in order to test locally without SSL, either (1) protocol needs to be exposed as an -/// env var or (2) protocol needs to be inferred from hostname. Rather than introduce a -/// largely unused configuration option, this function checks the hostname for local -/// development/testing and disables SSL accordingly. -async fn infer_oci_protocol(registry: &str) -> ClientProtocol { - let host = registry.split(":").next().expect("host must be provided"); - if host == "localhost" || host == "127.0.0.1" || host == "dockerhost" { - ClientProtocol::Http +const UNSECURE_HOSTS_ENV_VAR: &str = "APOLLO_GRAPH_ARTIFACT_UNSECURE_HOSTS"; +const DEFAULT_UNSECURE_HOSTS: &[&str] = &["localhost", "127.0.0.1", "dockerhost"]; + +/// Parse a comma-separated string of unsecure hosts. Empty entries are ignored. +fn parse_unsecure_hosts(value: &str) -> Vec { + value + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +fn unsecure_hosts() -> Vec { + match std::env::var(UNSECURE_HOSTS_ENV_VAR) { + Ok(val) => parse_unsecure_hosts(&val), + Err(_) => DEFAULT_UNSECURE_HOSTS + .iter() + .map(|s| s.to_string()) + .collect(), + } +} + +/// Extract the hostname from a registry string like "host", "host:port", +/// "http://host:port", or an IPv6 address like "[::1]:port". +/// If a scheme is already present, it is parsed directly; otherwise a dummy +/// scheme is prepended so `url::Url` can parse it. +/// IPv6 addresses are returned without brackets (e.g. "::1" not "[::1]"). +fn extract_host(registry: &str) -> Option { + let url = if registry.contains("://") { + Url::parse(registry).ok()? } else { - ClientProtocol::Https + Url::parse(&format!("dummy://{registry}")).ok()? + }; + url.host().map(|h| match h { + url::Host::Ipv6(addr) => addr.to_string(), + other => other.to_string(), + }) +} + +/// Check whether `registry` matches any entry in `hosts`, comparing only the +/// hostname portion (stripping any port). +fn is_unsecure_host(registry: &str, hosts: &[String]) -> bool { + extract_host(registry).is_some_and(|host| hosts.iter().any(|h| h == &host)) +} + +/// Determine whether SSL should be used for the given OCI reference. +/// +/// Consults the `APOLLO_GRAPH_ARTIFACT_UNSECURE_HOSTS` environment variable, +/// which contains a comma-separated list of hostnames that should use HTTP +/// instead of HTTPS. When the variable is unset, the defaults are "localhost", +/// "127.0.0.1", and "dockerhost". Setting it to an empty string disables all +/// HTTP overrides. +pub(crate) fn should_use_ssl(reference: &str) -> bool { + reference + .parse::() + .map_or(true, |r| !is_unsecure_host(r.registry(), &unsecure_hosts())) +} + +impl OciConfig { + fn client_protocol(&self) -> ClientProtocol { + if self.use_ssl { + ClientProtocol::Https + } else { + ClientProtocol::Http + } } } @@ -314,7 +374,7 @@ async fn infer_oci_protocol(registry: &str) -> ClientProtocol { pub(crate) async fn fetch_oci_manifest_digest(oci_config: &OciConfig) -> Result { let reference: Reference = oci_config.reference.as_str().parse()?; let auth = build_auth(&reference, &oci_config.apollo_key); - let protocol = infer_oci_protocol(reference.resolve_registry()).await; + let protocol = oci_config.client_protocol(); let client = Client::new(ClientConfig { protocol, @@ -359,7 +419,7 @@ pub(crate) async fn fetch_oci_manifest_digest(oci_config: &OciConfig) -> Result< pub(crate) async fn fetch_oci(oci_config: &OciConfig) -> Result { let reference: Reference = oci_config.reference.as_str().parse()?; let auth = build_auth(&reference, &oci_config.apollo_key); - let protocol = infer_oci_protocol(reference.registry()).await; + let protocol = oci_config.client_protocol(); tracing::debug!( "prepared to fetch schema from oci over {:?}, auth anonymous? {:?}", @@ -563,6 +623,7 @@ mod tests { reference: reference.clone(), hot_reload: false, poll_interval: Duration::from_millis(10), + use_ssl: false, } } @@ -855,74 +916,80 @@ mod tests { } } - #[tokio::test] - async fn test_infer_oci_protocol_localhost() { - let result = infer_oci_protocol("localhost").await; - assert_eq!(result, ClientProtocol::Http); - } - - #[tokio::test] - async fn test_infer_oci_protocol_localhost_with_port() { - let result = infer_oci_protocol("localhost:5000").await; - assert_eq!(result, ClientProtocol::Http); - } - - #[tokio::test] - async fn test_infer_oci_protocol_127_0_0_1() { - let result = infer_oci_protocol("127.0.0.1").await; - assert_eq!(result, ClientProtocol::Http); - } - - #[tokio::test] - async fn test_infer_oci_protocol_127_0_0_1_with_port() { - let result = infer_oci_protocol("127.0.0.1:5000").await; - assert_eq!(result, ClientProtocol::Http); - } - - #[tokio::test] - async fn test_infer_oci_protocol_docker_io() { - let result = infer_oci_protocol("docker.io").await; - assert_eq!(result, ClientProtocol::Https); - } - - #[tokio::test] - async fn test_infer_oci_protocol_docker_io_with_port() { - let result = infer_oci_protocol("docker.io:443").await; - assert_eq!(result, ClientProtocol::Https); - } - - #[tokio::test] - async fn test_infer_oci_protocol_apollo_registry() { - let result = infer_oci_protocol("registry.apollographql.com").await; - assert_eq!(result, ClientProtocol::Https); + #[rstest::rstest] + #[case::external_registry("registry.apollographql.com/my-graph:latest")] + #[case::docker_io("docker.io/library/alpine:latest")] + #[case::invalid_reference_defaults_true("")] + #[case::no_substring_match("localhost.example.com/my-graph:latest")] + fn should_use_ssl_true(#[case] reference: &str) { + assert!(should_use_ssl(reference)); } - #[tokio::test] - async fn test_infer_oci_protocol_apollo_registry_with_port() { - let result = infer_oci_protocol("registry.apollographql.com:443").await; - assert_eq!(result, ClientProtocol::Https); + #[rstest::rstest] + #[case::localhost("localhost:5000/test-graph:latest")] + #[case::loopback("127.0.0.1:5000/test-graph:latest")] + #[case::dockerhost("dockerhost:5000/test-graph:latest")] + fn should_use_ssl_false(#[case] reference: &str) { + assert!(!should_use_ssl(reference)); } - #[tokio::test] - async fn test_infer_oci_protocol_custom_registry() { - let result = infer_oci_protocol("localhost.example.com").await; - assert_eq!(result, ClientProtocol::Https); + #[rstest::rstest] + #[case::comma_separated("host1,host2,host3", vec!["host1", "host2", "host3"])] + #[case::with_whitespace(" host1 , host2 , host3 ", vec!["host1", "host2", "host3"])] + #[case::empty_string("", vec![])] + #[case::trailing_commas("host1,,host2,", vec!["host1", "host2"])] + #[case::single_host("myregistry.local", vec!["myregistry.local"])] + fn parse_unsecure_hosts_cases(#[case] input: &str, #[case] expected: Vec<&str>) { + assert_eq!(parse_unsecure_hosts(input), expected); } - #[tokio::test] - async fn test_infer_oci_protocol_port_only() { - // This case will never pass the initial reference validation, but is - // included here as a second layer of security. - let result = infer_oci_protocol(":8080").await; - assert_eq!(result, ClientProtocol::Https); + #[rstest::rstest] + #[case::exact("myregistry.local", &["myregistry.local"], true)] + #[case::with_port("myregistry.local:5000", &["myregistry.local"], true)] + #[case::no_match("other.registry.com", &["myregistry.local"], false)] + #[case::empty_list("localhost", &[], false)] + #[case::default_localhost("localhost", DEFAULT_UNSECURE_HOSTS, true)] + #[case::default_localhost_port("localhost:5000", DEFAULT_UNSECURE_HOSTS, true)] + #[case::default_loopback("127.0.0.1", DEFAULT_UNSECURE_HOSTS, true)] + #[case::default_loopback_port("127.0.0.1:5000", DEFAULT_UNSECURE_HOSTS, true)] + #[case::default_dockerhost("dockerhost", DEFAULT_UNSECURE_HOSTS, true)] + #[case::default_dockerhost_port("dockerhost:5000", DEFAULT_UNSECURE_HOSTS, true)] + #[case::default_docker_io("docker.io", DEFAULT_UNSECURE_HOSTS, false)] + #[case::default_apollo("registry.apollographql.com", DEFAULT_UNSECURE_HOSTS, false)] + #[case::no_substring("localhost.example.com", &["localhost"], false)] + #[case::no_prefix_match("notlocalhost", &["localhost"], false)] + #[case::custom_replaces_defaults("internal.registry.corp", &["internal.registry.corp"], true)] + #[case::custom_port("internal.registry.corp:8080", &["internal.registry.corp"], true)] + #[case::custom_missing_localhost("localhost", &["internal.registry.corp"], false)] + #[case::ipv6_match("[::1]", &["::1"], true)] + #[case::ipv6_match_port("[::1]:5000", &["::1"], true)] + #[case::ipv6_no_match("localhost", &["::1"], false)] + fn is_unsecure_host_cases( + #[case] registry: &str, + #[case] hosts: &[&str], + #[case] expected: bool, + ) { + let hosts: Vec = hosts.iter().map(|s| s.to_string()).collect(); + assert_eq!(is_unsecure_host(registry, &hosts), expected); } - #[tokio::test] - async fn test_infer_oci_protocol_empty_string() { - // This case will never pass the initial reference validation, but is - // included here as a second layer of security. - let result = infer_oci_protocol("").await; - assert_eq!(result, ClientProtocol::Https); + #[rstest::rstest] + #[case::simple("localhost", "localhost")] + #[case::simple_port("localhost:5000", "localhost")] + #[case::ipv4("127.0.0.1", "127.0.0.1")] + #[case::ipv4_port("127.0.0.1:5000", "127.0.0.1")] + #[case::ipv6("[::1]", "::1")] + #[case::ipv6_port("[::1]:5000", "::1")] + #[case::domain_port("registry.example.com:443", "registry.example.com")] + #[case::http_scheme("http://localhost:5000", "localhost")] + #[case::http_ipv4("http://127.0.0.1:5000", "127.0.0.1")] + #[case::https_scheme("https://registry.example.com", "registry.example.com")] + #[case::https_port("https://registry.example.com:443", "registry.example.com")] + #[case::https_path("https://registry.example.com/v2/repo", "registry.example.com")] + #[case::http_path("http://localhost:5000/v2/my-graph/manifests/latest", "localhost")] + #[case::http_ipv6("http://[::1]:5000", "::1")] + fn extract_host_cases(#[case] input: &str, #[case] expected: &str) { + assert_eq!(extract_host(input), Some(expected.to_string())); } #[test] @@ -1236,6 +1303,7 @@ mod tests { reference: image_reference.to_string(), hot_reload: true, poll_interval: Duration::from_millis(10), + use_ssl: false, }; let result = create_oci_schema_stream(oci_config); @@ -1271,6 +1339,7 @@ mod tests { reference: image_reference.to_string(), hot_reload: false, poll_interval: Duration::from_millis(10), + use_ssl: false, }; let result = create_oci_schema_stream(oci_config); @@ -1296,6 +1365,7 @@ mod tests { reference: digest_reference.to_string(), hot_reload: true, poll_interval: Duration::from_millis(10), + use_ssl: true, }; let result = create_oci_schema_stream(oci_config); @@ -1383,6 +1453,7 @@ mod tests { reference: digest_ref, hot_reload: false, poll_interval: Duration::from_millis(10), + use_ssl: false, }; let result = create_oci_schema_stream(oci_config_digest); @@ -1613,6 +1684,7 @@ mod tests { reference: image_reference.to_string(), hot_reload: true, poll_interval: Duration::from_millis(10), + use_ssl: false, }; let start_time = tokio::time::Instant::now(); diff --git a/docs/source/routing/configuration/envvars.mdx b/docs/source/routing/configuration/envvars.mdx index 808a7a6469..c30d73ae02 100644 --- a/docs/source/routing/configuration/envvars.mdx +++ b/docs/source/routing/configuration/envvars.mdx @@ -92,6 +92,32 @@ For information on finding graph artifact references, see [Graph Artifacts](/gra + + +##### `APOLLO_GRAPH_ARTIFACT_UNSECURE_HOSTS` + + + + +A comma-separated list of registry hostnames to use HTTP instead of HTTPS when fetching graph artifacts. + +

+ +When this variable is not set, Router defaults to allowing HTTP for `localhost`, `127.0.0.1`, and `dockerhost`. + +

+ +When this variable is set to a non-empty value, only the specified hosts use HTTP and the defaults are replaced. For example, to allow HTTP for a custom internal registry and `localhost`: + +

+ +```bash +APOLLO_GRAPH_ARTIFACT_UNSECURE_HOSTS="internal.registry.corp,localhost" +``` + +When this variable is set to an empty string (`""`), all HTTP overrides are disabled, requiring HTTPS for every registry, including localhost. + +