Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changesets/config_allowlist_insecure_artifacts.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions apollo-router/src/executable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -266,6 +269,7 @@ impl Opt {
reference: validated_reference,
hot_reload: self.hot_reload,
poll_interval,
use_ssl,
})
}

Expand Down
216 changes: 144 additions & 72 deletions apollo-router/src/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -296,25 +302,79 @@ 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<String> {
value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}

fn unsecure_hosts() -> Vec<String> {
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<String> {
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::<Reference>()
.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
}
}
}

/// Fetch the manifest digest (without fetching the full manifest) to detect changes
pub(crate) async fn fetch_oci_manifest_digest(oci_config: &OciConfig) -> Result<String, OciError> {
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,
Expand Down Expand Up @@ -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<OciContent, OciError> {
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? {:?}",
Expand Down Expand Up @@ -563,6 +623,7 @@ mod tests {
reference: reference.clone(),
hot_reload: false,
poll_interval: Duration::from_millis(10),
use_ssl: false,
}
}

Expand Down Expand Up @@ -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<String> = 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]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
26 changes: 26 additions & 0 deletions docs/source/routing/configuration/envvars.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,32 @@
</td>
</tr>
<tr>
<td style="min-width: 150px;">

##### `APOLLO_GRAPH_ARTIFACT_UNSECURE_HOSTS`

Check notice on line 97 in docs/source/routing/configuration/envvars.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/routing/configuration/envvars.mdx#L97

**Structural Elements**: Headings should use sentence case. While this is a constant/proper noun, ensure it follows the reference document type naming convention (Nouns). ```suggestion ##### `APOLLO_GRAPH_ARTIFACT_UNSECURE_HOSTS` ```

</td>
<td>

A comma-separated list of registry hostnames to use HTTP instead of HTTPS when fetching graph artifacts.

Check notice on line 102 in docs/source/routing/configuration/envvars.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/routing/configuration/envvars.mdx#L102

**Word and Symbol Usage**: Use "instead of" instead of "rather than". ```suggestion A comma-separated list of registry hostnames to use HTTP instead of HTTPS when fetching graph artifacts. ```

<br/><br/>

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

Check warning on line 106 in docs/source/routing/configuration/envvars.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/routing/configuration/envvars.mdx#L106

**Framing**: Frame content relative to the reader using 'you'. **Products and Features**: Remove 'the' before 'Router' as it is a standalone product name. **Text Formatting**: Use code font for user input values like 127.0.0.1 and dockerhost. **Verb Tense and Voice**: Use active voice to clarify the relationship between the user and the configuration. **Word and Symbol Usage**: Use contractions like "isn't" and use "enabling" instead of "allowing". ```suggestion When you don't set this variable, the Router defaults to allowing HTTP for `localhost`, `127.0.0.1`, and `dockerhost`. ```

<br/><br/>

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`:

Check notice on line 110 in docs/source/routing/configuration/envvars.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/routing/configuration/envvars.mdx#L110

**Framing**: Frame content relative to the reader using 'you' and 'your'. **Verb Tense and Voice**: Use active voice for more direct instructions. **Word and Symbol Usage**: Use "enable" instead of "allow". ```suggestion When you set this variable to a non-empty value, only the specified hosts use HTTP and the defaults are replaced. For example, to allow HTTP for your custom internal registry and `localhost`: ```

<br/><br/>

```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.

Check warning on line 118 in docs/source/routing/configuration/envvars.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/routing/configuration/envvars.mdx#L118

**Framing**: Frame content relative to the reader using 'you'. **Text Formatting**: Use code font for the symbol localhost. **Verb Tense and Voice**: Use active voice to describe the user action. **Voice**: Use an authoritative and encouraging tone to prescribe the recommended security path. **Word and Symbol Usage**: Use codefont for localhost. ```suggestion When you set this variable to an empty string (`""`), all HTTP overrides are disabled, requiring HTTPS for every registry, including localhost. ```

</td>
</tr>
</tbody>
</table>
Expand Down