From 7d8847bdb016ec3d2c535bd2a0e2b84893291c2a Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Wed, 25 Feb 2026 09:44:44 -0800 Subject: [PATCH 1/8] add env var allowlist to support non-SSL graph artifacts repos --- apollo-router/src/registry/mod.rs | 179 +++++++++++++++++- docs/source/routing/configuration/envvars.mdx | 18 ++ 2 files changed, 190 insertions(+), 7 deletions(-) diff --git a/apollo-router/src/registry/mod.rs b/apollo-router/src/registry/mod.rs index e33771c738..8153c2246d 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; @@ -296,14 +297,57 @@ 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. +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", or +/// an IPv6 address like "[::1]:port", using `url::Url` for robust parsing. +/// IPv6 addresses are returned without brackets (e.g. "::1" not "[::1]"). +fn extract_host(registry: &str) -> Option { + Url::parse(&format!("dummy://{registry}")).ok().and_then(|url| { + 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) + .map(|host| hosts.iter().any(|h| h == &host)) + .unwrap_or(false) +} + +/// Determine whether to use HTTP or HTTPS for the OCI registry. +/// +/// Uses 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. 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" { + let hosts = unsecure_hosts(); + if is_unsecure_host(registry, &hosts) { ClientProtocol::Http } else { ClientProtocol::Https @@ -925,6 +969,127 @@ mod tests { assert_eq!(result, ClientProtocol::Https); } + #[test] + fn test_parse_unsecure_hosts_comma_separated() { + let hosts = parse_unsecure_hosts("host1,host2,host3"); + assert_eq!(hosts, vec!["host1", "host2", "host3"]); + } + + #[test] + fn test_parse_unsecure_hosts_with_whitespace() { + let hosts = parse_unsecure_hosts(" host1 , host2 , host3 "); + assert_eq!(hosts, vec!["host1", "host2", "host3"]); + } + + #[test] + fn test_parse_unsecure_hosts_empty_string() { + let hosts = parse_unsecure_hosts(""); + assert!(hosts.is_empty()); + } + + #[test] + fn test_parse_unsecure_hosts_trailing_commas() { + let hosts = parse_unsecure_hosts("host1,,host2,"); + assert_eq!(hosts, vec!["host1", "host2"]); + } + + #[test] + fn test_parse_unsecure_hosts_single_host() { + let hosts = parse_unsecure_hosts("myregistry.local"); + assert_eq!(hosts, vec!["myregistry.local"]); + } + + #[test] + fn test_is_unsecure_host_exact_match() { + let hosts = vec!["myregistry.local".to_string()]; + assert!(is_unsecure_host("myregistry.local", &hosts)); + assert!(is_unsecure_host("myregistry.local:5000", &hosts)); + } + + #[test] + fn test_is_unsecure_host_no_match() { + let hosts = vec!["myregistry.local".to_string()]; + assert!(!is_unsecure_host("other.registry.com", &hosts)); + assert!(!is_unsecure_host("docker.io", &hosts)); + } + + #[test] + fn test_is_unsecure_host_empty_list() { + let hosts: Vec = vec![]; + assert!(!is_unsecure_host("localhost", &hosts)); + assert!(!is_unsecure_host("127.0.0.1", &hosts)); + } + + #[test] + fn test_is_unsecure_host_defaults() { + let hosts: Vec = DEFAULT_UNSECURE_HOSTS + .iter() + .map(|s| s.to_string()) + .collect(); + assert!(is_unsecure_host("localhost", &hosts)); + assert!(is_unsecure_host("localhost:5000", &hosts)); + assert!(is_unsecure_host("127.0.0.1", &hosts)); + assert!(is_unsecure_host("127.0.0.1:5000", &hosts)); + assert!(is_unsecure_host("dockerhost", &hosts)); + assert!(is_unsecure_host("dockerhost:5000", &hosts)); + assert!(!is_unsecure_host("docker.io", &hosts)); + assert!(!is_unsecure_host("registry.apollographql.com", &hosts)); + } + + #[test] + fn test_is_unsecure_host_custom_list_replaces_defaults() { + let hosts = parse_unsecure_hosts("internal.registry.corp"); + assert!(is_unsecure_host("internal.registry.corp", &hosts)); + assert!(is_unsecure_host("internal.registry.corp:8080", &hosts)); + assert!(!is_unsecure_host("localhost", &hosts)); + assert!(!is_unsecure_host("127.0.0.1", &hosts)); + assert!(!is_unsecure_host("dockerhost", &hosts)); + } + + #[test] + fn test_is_unsecure_host_no_substring_match() { + let hosts = vec!["localhost".to_string()]; + assert!(!is_unsecure_host("localhost.example.com", &hosts)); + assert!(!is_unsecure_host("notlocalhost", &hosts)); + } + + #[test] + fn test_extract_host_simple() { + assert_eq!(extract_host("localhost"), Some("localhost".to_string())); + assert_eq!(extract_host("localhost:5000"), Some("localhost".to_string())); + } + + #[test] + fn test_extract_host_ipv4() { + assert_eq!(extract_host("127.0.0.1"), Some("127.0.0.1".to_string())); + assert_eq!( + extract_host("127.0.0.1:5000"), + Some("127.0.0.1".to_string()) + ); + } + + #[test] + fn test_extract_host_ipv6() { + assert_eq!(extract_host("[::1]"), Some("::1".to_string())); + assert_eq!(extract_host("[::1]:5000"), Some("::1".to_string())); + } + + #[test] + fn test_extract_host_domain_with_port() { + assert_eq!( + extract_host("registry.example.com:443"), + Some("registry.example.com".to_string()) + ); + } + + #[test] + fn test_is_unsecure_host_ipv6() { + let hosts = vec!["::1".to_string()]; + assert!(is_unsecure_host("[::1]", &hosts)); + assert!(is_unsecure_host("[::1]:5000", &hosts)); + assert!(!is_unsecure_host("localhost", &hosts)); + } + #[test] fn test_validate_oci_reference_valid_cases() { // Test valid digest references with different algorithms diff --git a/docs/source/routing/configuration/envvars.mdx b/docs/source/routing/configuration/envvars.mdx index 808a7a6469..6c597a4038 100644 --- a/docs/source/routing/configuration/envvars.mdx +++ b/docs/source/routing/configuration/envvars.mdx @@ -92,6 +92,24 @@ For information on finding graph artifact references, see [Graph Artifacts](/gra + + +##### `APOLLO_GRAPH_ARTIFACT_UNSECURE_HOSTS` + + + + +A comma-separated list of registry hostnames that should use HTTP instead of HTTPS when fetching graph artifacts. + +When this variable is **not set**, the router defaults to allowing HTTP for `localhost`, `127.0.0.1`, and `dockerhost`. When set to a non-empty value, only the specified hosts use HTTP—the defaults are replaced entirely. Setting it to an empty string (`""`) disables all HTTP overrides, requiring HTTPS for every registry including localhost. + +For example, to allow HTTP for a custom internal registry: + +``` +APOLLO_GRAPH_ARTIFACT_UNSECURE_HOSTS="internal.registry.corp,localhost" +``` + + From e5c7635215190f334f8dda17dbafb0886641c9c1 Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Wed, 25 Feb 2026 09:57:08 -0800 Subject: [PATCH 2/8] lint --- apollo-router/src/registry/mod.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apollo-router/src/registry/mod.rs b/apollo-router/src/registry/mod.rs index 8153c2246d..130d8ff61d 100644 --- a/apollo-router/src/registry/mod.rs +++ b/apollo-router/src/registry/mod.rs @@ -323,12 +323,14 @@ fn unsecure_hosts() -> Vec { /// an IPv6 address like "[::1]:port", using `url::Url` for robust parsing. /// IPv6 addresses are returned without brackets (e.g. "::1" not "[::1]"). fn extract_host(registry: &str) -> Option { - Url::parse(&format!("dummy://{registry}")).ok().and_then(|url| { - url.host().map(|h| match h { - url::Host::Ipv6(addr) => addr.to_string(), - other => other.to_string(), + Url::parse(&format!("dummy://{registry}")) + .ok() + .and_then(|url| { + 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 @@ -1056,7 +1058,10 @@ mod tests { #[test] fn test_extract_host_simple() { assert_eq!(extract_host("localhost"), Some("localhost".to_string())); - assert_eq!(extract_host("localhost:5000"), Some("localhost".to_string())); + assert_eq!( + extract_host("localhost:5000"), + Some("localhost".to_string()) + ); } #[test] From 7d59ccf7b9358d2a485b5818119e24f841c6a756 Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Wed, 25 Feb 2026 10:08:03 -0800 Subject: [PATCH 3/8] docs tweak --- docs/source/routing/configuration/envvars.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/routing/configuration/envvars.mdx b/docs/source/routing/configuration/envvars.mdx index 6c597a4038..ff39ec3dde 100644 --- a/docs/source/routing/configuration/envvars.mdx +++ b/docs/source/routing/configuration/envvars.mdx @@ -99,9 +99,9 @@ For information on finding graph artifact references, see [Graph Artifacts](/gra -A comma-separated list of registry hostnames that should use HTTP instead of HTTPS when fetching graph artifacts. +A comma-separated list of registry hostnames to use HTTP instead of HTTPS when fetching graph artifacts. -When this variable is **not set**, the router defaults to allowing HTTP for `localhost`, `127.0.0.1`, and `dockerhost`. When set to a non-empty value, only the specified hosts use HTTP—the defaults are replaced entirely. Setting it to an empty string (`""`) disables all HTTP overrides, requiring HTTPS for every registry including localhost. +When this variable is **not set**, Router defaults to allowing HTTP for `localhost`, `127.0.0.1`, and `dockerhost`. When set to a non-empty value, only the specified hosts use HTTP—the defaults are replaced entirely. Setting it to an empty string (`""`) disables all HTTP overrides, requiring HTTPS for every registry including localhost. For example, to allow HTTP for a custom internal registry: From fa63915005a5217653446b8b6fc8246625a310d3 Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Wed, 25 Feb 2026 10:19:40 -0800 Subject: [PATCH 4/8] add changeset --- .changesets/config_allowlist_insecure_artifacts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changesets/config_allowlist_insecure_artifacts.md 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 From 993a691164abe7bd1263558fb67acb54a3a27aa8 Mon Sep 17 00:00:00 2001 From: Christopher Lee Date: Wed, 25 Feb 2026 14:31:42 -0800 Subject: [PATCH 5/8] Code review tweak for simplicity Co-authored-by: Caroline Rodewig <16093297+carodewig@users.noreply.github.com> --- apollo-router/src/registry/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apollo-router/src/registry/mod.rs b/apollo-router/src/registry/mod.rs index 130d8ff61d..9d80d13d55 100644 --- a/apollo-router/src/registry/mod.rs +++ b/apollo-router/src/registry/mod.rs @@ -337,8 +337,7 @@ fn extract_host(registry: &str) -> Option { /// hostname portion (stripping any port). fn is_unsecure_host(registry: &str, hosts: &[String]) -> bool { extract_host(registry) - .map(|host| hosts.iter().any(|h| h == &host)) - .unwrap_or(false) + .map_or(false, |host| hosts.iter().any(|h| h == &host)) } /// Determine whether to use HTTP or HTTPS for the OCI registry. From 4ae8073ada5305b897c582590634a638f112f5b5 Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Wed, 25 Feb 2026 15:00:45 -0800 Subject: [PATCH 6/8] infer oci protocol on startup instead of each network call --- apollo-router/src/executable.rs | 4 + apollo-router/src/registry/mod.rs | 189 +++++++++++++++++------------- 2 files changed, 112 insertions(+), 81 deletions(-) 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 9d80d13d55..457739a96a 100644 --- a/apollo-router/src/registry/mod.rs +++ b/apollo-router/src/registry/mod.rs @@ -101,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)] @@ -319,39 +324,49 @@ fn unsecure_hosts() -> Vec { } } -/// Extract the hostname from a registry string like "host", "host:port", or -/// an IPv6 address like "[::1]:port", using `url::Url` for robust parsing. +/// 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 { - Url::parse(&format!("dummy://{registry}")) - .ok() - .and_then(|url| { - url.host().map(|h| match h { - url::Host::Ipv6(addr) => addr.to_string(), - other => other.to_string(), - }) - }) + let url = if registry.contains("://") { + Url::parse(registry).ok()? + } else { + 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) - .map_or(false, |host| hosts.iter().any(|h| h == &host)) + extract_host(registry).is_some_and(|host| hosts.iter().any(|h| h == &host)) } -/// Determine whether to use HTTP or HTTPS for the OCI registry. +/// Determine whether SSL should be used for the given OCI reference. /// -/// Uses 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. -async fn infer_oci_protocol(registry: &str) -> ClientProtocol { - let hosts = unsecure_hosts(); - if is_unsecure_host(registry, &hosts) { - ClientProtocol::Http - } else { - ClientProtocol::Https +/// 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 + } } } @@ -359,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, @@ -404,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? {:?}", @@ -608,6 +623,7 @@ mod tests { reference: reference.clone(), hot_reload: false, poll_interval: Duration::from_millis(10), + use_ssl: false, } } @@ -900,74 +916,39 @@ 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); + #[test] + fn test_should_use_ssl_localhost() { + assert!(!should_use_ssl("localhost:5000/test-graph:latest")); } - #[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); + #[test] + fn test_should_use_ssl_127_0_0_1() { + assert!(!should_use_ssl("127.0.0.1:5000/test-graph:latest")); } - #[tokio::test] - async fn test_infer_oci_protocol_apollo_registry() { - let result = infer_oci_protocol("registry.apollographql.com").await; - assert_eq!(result, ClientProtocol::Https); + #[test] + fn test_should_use_ssl_dockerhost() { + assert!(!should_use_ssl("dockerhost:5000/test-graph:latest")); } - #[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); + #[test] + fn test_should_use_ssl_external_registry() { + assert!(should_use_ssl("registry.apollographql.com/my-graph:latest")); } - #[tokio::test] - async fn test_infer_oci_protocol_custom_registry() { - let result = infer_oci_protocol("localhost.example.com").await; - assert_eq!(result, ClientProtocol::Https); + #[test] + fn test_should_use_ssl_docker_io() { + assert!(should_use_ssl("docker.io/library/alpine:latest")); } - #[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); + #[test] + fn test_should_use_ssl_invalid_reference_defaults_true() { + assert!(should_use_ssl("")); } - #[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); + #[test] + fn test_should_use_ssl_no_substring_match() { + assert!(should_use_ssl("localhost.example.com/my-graph:latest")); } #[test] @@ -1086,6 +1067,47 @@ mod tests { ); } + #[test] + fn test_extract_host_with_http_scheme() { + assert_eq!( + extract_host("http://localhost:5000"), + Some("localhost".to_string()) + ); + assert_eq!( + extract_host("http://127.0.0.1:5000"), + Some("127.0.0.1".to_string()) + ); + } + + #[test] + fn test_extract_host_with_https_scheme() { + assert_eq!( + extract_host("https://registry.example.com"), + Some("registry.example.com".to_string()) + ); + assert_eq!( + extract_host("https://registry.example.com:443"), + Some("registry.example.com".to_string()) + ); + } + + #[test] + fn test_extract_host_with_scheme_and_path() { + assert_eq!( + extract_host("https://registry.example.com/v2/repo"), + Some("registry.example.com".to_string()) + ); + assert_eq!( + extract_host("http://localhost:5000/v2/my-graph/manifests/latest"), + Some("localhost".to_string()) + ); + } + + #[test] + fn test_extract_host_with_scheme_ipv6() { + assert_eq!(extract_host("http://[::1]:5000"), Some("::1".to_string())); + } + #[test] fn test_is_unsecure_host_ipv6() { let hosts = vec!["::1".to_string()]; @@ -1405,6 +1427,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); @@ -1440,6 +1463,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); @@ -1465,6 +1489,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); @@ -1552,6 +1577,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); @@ -1782,6 +1808,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(); From 3d8941e36b73abb6d6faed8de0f55215ca1d4c79 Mon Sep 17 00:00:00 2001 From: Michelle Mabuyo Date: Thu, 26 Feb 2026 11:12:06 -0500 Subject: [PATCH 7/8] edit wording for clarity, add line breaks for easier reading --- docs/source/routing/configuration/envvars.mdx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/source/routing/configuration/envvars.mdx b/docs/source/routing/configuration/envvars.mdx index ff39ec3dde..c30d73ae02 100644 --- a/docs/source/routing/configuration/envvars.mdx +++ b/docs/source/routing/configuration/envvars.mdx @@ -101,14 +101,22 @@ For information on finding graph artifact references, see [Graph Artifacts](/gra 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 set to a non-empty value, only the specified hosts use HTTP—the defaults are replaced entirely. Setting it to an empty string (`""`) disables all HTTP overrides, requiring HTTPS for every registry including localhost. +

-For example, to allow HTTP for a custom internal registry: +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. + From a9d23f3fc3f39ce7fad7af91ad08fbca76fed3ef Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Thu, 26 Feb 2026 08:23:49 -0800 Subject: [PATCH 8/8] use rstest to parameterize tests for clarity --- apollo-router/src/registry/mod.rs | 272 ++++++++---------------------- 1 file changed, 74 insertions(+), 198 deletions(-) diff --git a/apollo-router/src/registry/mod.rs b/apollo-router/src/registry/mod.rs index 457739a96a..7ff97e8267 100644 --- a/apollo-router/src/registry/mod.rs +++ b/apollo-router/src/registry/mod.rs @@ -916,204 +916,80 @@ mod tests { } } - #[test] - fn test_should_use_ssl_localhost() { - assert!(!should_use_ssl("localhost:5000/test-graph:latest")); - } - - #[test] - fn test_should_use_ssl_127_0_0_1() { - assert!(!should_use_ssl("127.0.0.1:5000/test-graph:latest")); - } - - #[test] - fn test_should_use_ssl_dockerhost() { - assert!(!should_use_ssl("dockerhost:5000/test-graph:latest")); - } - - #[test] - fn test_should_use_ssl_external_registry() { - assert!(should_use_ssl("registry.apollographql.com/my-graph:latest")); - } - - #[test] - fn test_should_use_ssl_docker_io() { - assert!(should_use_ssl("docker.io/library/alpine:latest")); - } - - #[test] - fn test_should_use_ssl_invalid_reference_defaults_true() { - assert!(should_use_ssl("")); - } - - #[test] - fn test_should_use_ssl_no_substring_match() { - assert!(should_use_ssl("localhost.example.com/my-graph:latest")); - } - - #[test] - fn test_parse_unsecure_hosts_comma_separated() { - let hosts = parse_unsecure_hosts("host1,host2,host3"); - assert_eq!(hosts, vec!["host1", "host2", "host3"]); - } - - #[test] - fn test_parse_unsecure_hosts_with_whitespace() { - let hosts = parse_unsecure_hosts(" host1 , host2 , host3 "); - assert_eq!(hosts, vec!["host1", "host2", "host3"]); - } - - #[test] - fn test_parse_unsecure_hosts_empty_string() { - let hosts = parse_unsecure_hosts(""); - assert!(hosts.is_empty()); - } - - #[test] - fn test_parse_unsecure_hosts_trailing_commas() { - let hosts = parse_unsecure_hosts("host1,,host2,"); - assert_eq!(hosts, vec!["host1", "host2"]); - } - - #[test] - fn test_parse_unsecure_hosts_single_host() { - let hosts = parse_unsecure_hosts("myregistry.local"); - assert_eq!(hosts, vec!["myregistry.local"]); - } - - #[test] - fn test_is_unsecure_host_exact_match() { - let hosts = vec!["myregistry.local".to_string()]; - assert!(is_unsecure_host("myregistry.local", &hosts)); - assert!(is_unsecure_host("myregistry.local:5000", &hosts)); - } - - #[test] - fn test_is_unsecure_host_no_match() { - let hosts = vec!["myregistry.local".to_string()]; - assert!(!is_unsecure_host("other.registry.com", &hosts)); - assert!(!is_unsecure_host("docker.io", &hosts)); - } - - #[test] - fn test_is_unsecure_host_empty_list() { - let hosts: Vec = vec![]; - assert!(!is_unsecure_host("localhost", &hosts)); - assert!(!is_unsecure_host("127.0.0.1", &hosts)); - } - - #[test] - fn test_is_unsecure_host_defaults() { - let hosts: Vec = DEFAULT_UNSECURE_HOSTS - .iter() - .map(|s| s.to_string()) - .collect(); - assert!(is_unsecure_host("localhost", &hosts)); - assert!(is_unsecure_host("localhost:5000", &hosts)); - assert!(is_unsecure_host("127.0.0.1", &hosts)); - assert!(is_unsecure_host("127.0.0.1:5000", &hosts)); - assert!(is_unsecure_host("dockerhost", &hosts)); - assert!(is_unsecure_host("dockerhost:5000", &hosts)); - assert!(!is_unsecure_host("docker.io", &hosts)); - assert!(!is_unsecure_host("registry.apollographql.com", &hosts)); - } - - #[test] - fn test_is_unsecure_host_custom_list_replaces_defaults() { - let hosts = parse_unsecure_hosts("internal.registry.corp"); - assert!(is_unsecure_host("internal.registry.corp", &hosts)); - assert!(is_unsecure_host("internal.registry.corp:8080", &hosts)); - assert!(!is_unsecure_host("localhost", &hosts)); - assert!(!is_unsecure_host("127.0.0.1", &hosts)); - assert!(!is_unsecure_host("dockerhost", &hosts)); - } - - #[test] - fn test_is_unsecure_host_no_substring_match() { - let hosts = vec!["localhost".to_string()]; - assert!(!is_unsecure_host("localhost.example.com", &hosts)); - assert!(!is_unsecure_host("notlocalhost", &hosts)); - } - - #[test] - fn test_extract_host_simple() { - assert_eq!(extract_host("localhost"), Some("localhost".to_string())); - assert_eq!( - extract_host("localhost:5000"), - Some("localhost".to_string()) - ); - } - - #[test] - fn test_extract_host_ipv4() { - assert_eq!(extract_host("127.0.0.1"), Some("127.0.0.1".to_string())); - assert_eq!( - extract_host("127.0.0.1:5000"), - Some("127.0.0.1".to_string()) - ); - } - - #[test] - fn test_extract_host_ipv6() { - assert_eq!(extract_host("[::1]"), Some("::1".to_string())); - assert_eq!(extract_host("[::1]:5000"), Some("::1".to_string())); - } - - #[test] - fn test_extract_host_domain_with_port() { - assert_eq!( - extract_host("registry.example.com:443"), - Some("registry.example.com".to_string()) - ); - } - - #[test] - fn test_extract_host_with_http_scheme() { - assert_eq!( - extract_host("http://localhost:5000"), - Some("localhost".to_string()) - ); - assert_eq!( - extract_host("http://127.0.0.1:5000"), - Some("127.0.0.1".to_string()) - ); - } - - #[test] - fn test_extract_host_with_https_scheme() { - assert_eq!( - extract_host("https://registry.example.com"), - Some("registry.example.com".to_string()) - ); - assert_eq!( - extract_host("https://registry.example.com:443"), - Some("registry.example.com".to_string()) - ); - } - - #[test] - fn test_extract_host_with_scheme_and_path() { - assert_eq!( - extract_host("https://registry.example.com/v2/repo"), - Some("registry.example.com".to_string()) - ); - assert_eq!( - extract_host("http://localhost:5000/v2/my-graph/manifests/latest"), - Some("localhost".to_string()) - ); - } - - #[test] - fn test_extract_host_with_scheme_ipv6() { - assert_eq!(extract_host("http://[::1]:5000"), Some("::1".to_string())); - } - - #[test] - fn test_is_unsecure_host_ipv6() { - let hosts = vec!["::1".to_string()]; - assert!(is_unsecure_host("[::1]", &hosts)); - assert!(is_unsecure_host("[::1]:5000", &hosts)); - assert!(!is_unsecure_host("localhost", &hosts)); + #[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)); + } + + #[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)); + } + + #[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); + } + + #[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); + } + + #[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]