From 4b4a1f88fb2dc0cb1ed82eb57a61378a17e87e81 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 4 Jul 2026 09:52:31 -0400 Subject: [PATCH] feat(security): enforce trusted-registry image policy at the orchestrator pull sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog- and manifest-supplied image refs reached pull_image without ever passing the RPC boundary's validator — a malicious catalog entry or manifest could pull from an arbitrary registry. The allowlist now lives in container::image_policy (the RPC check delegates to it) and both orchestrator pull sites (install_fresh and ensure_resolved_source_available) refuse refs that fail it. The shared policy accepts trusted-registry refs and registry-less Docker Hub shorthand (grafana/grafana etc., used by 8 shipped manifests — a registry-less ref cannot name an attacker host), and rejects explicit non-allowlisted hosts, shell metacharacters, and malformed refs. §A of the 1.8.0 hardening plan. Co-Authored-By: Claude Fable 5 --- .../archipelago/src/api/rpc/package/config.rs | 32 +------ .../archipelago/src/container/image_policy.rs | 95 +++++++++++++++++++ core/archipelago/src/container/mod.rs | 1 + .../src/container/prod_orchestrator.rs | 18 ++++ docs/1.8.0-RELEASE-HARDENING-PLAN.md | 13 ++- 5 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 core/archipelago/src/container/image_policy.rs diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 214e3ec6..c41f63dd 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -94,35 +94,11 @@ async fn dynamic_app_config( )) } -/// Trusted Docker registries. Only images from these sources are allowed. -#[allow(dead_code)] -pub(super) const TRUSTED_REGISTRIES: &[&str] = &[ - "docker.io/", - "ghcr.io/", - "localhost/", - "git.tx1138.com/", - "146.59.87.168:3000/", -]; - -/// Validate Docker image against trusted registry allowlist. +/// Validate a Docker image reference. Delegates to the shared policy in +/// `container::image_policy` — the same rules the orchestrator enforces at +/// its pull sites, so the two layers can't drift apart. pub(super) fn is_valid_docker_image(image: &str) -> bool { - if image.is_empty() || image.len() > 256 { - return false; - } - // Reject shell metacharacters - let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r']; - if image.chars().any(|c| dangerous_chars.contains(&c)) { - return false; - } - // Must come from a trusted registry — match the exact domain, not just prefix - let registry = match image.split('/').next() { - Some(r) => r, - None => return false, - }; - matches!( - registry, - "docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "146.59.87.168:3000" - ) + crate::container::image_policy::is_valid_docker_image(image) } /// Per-app Linux capabilities needed beyond the default cap-drop=ALL. diff --git a/core/archipelago/src/container/image_policy.rs b/core/archipelago/src/container/image_policy.rs new file mode 100644 index 00000000..e8baf589 --- /dev/null +++ b/core/archipelago/src/container/image_policy.rs @@ -0,0 +1,95 @@ +//! Trusted-registry policy for container image references — the single +//! source of truth. The RPC boundary (`api::rpc::package::config`) and the +//! orchestrator's pull sites both validate against this, so a catalog- or +//! manifest-supplied ref can't reach `pull_image` unchecked (§A of the +//! 1.8.0 hardening plan). + +/// Registries images may be pulled from with an explicit host part. +pub const TRUSTED_REGISTRIES: &[&str] = &[ + "docker.io", + "ghcr.io", + "localhost", + "git.tx1138.com", + "146.59.87.168:3000", +]; + +/// Validate a container image reference. +/// +/// Accepts: +/// * refs whose explicit registry host is on [`TRUSTED_REGISTRIES`] +/// (`docker.io/grafana/grafana`, `146.59.87.168:3000/archy/x:1`), and +/// * registry-less Docker Hub shorthand (`nginx`, `grafana/grafana`) — +/// the first segment has no `.`/`:` so it cannot name an attacker host; +/// resolution follows the host's registries.conf search order. +/// +/// Rejects empty/oversized refs, shell metacharacters, and any ref whose +/// explicit registry host is not on the allowlist. +pub fn is_valid_docker_image(image: &str) -> bool { + if image.is_empty() || image.len() > 256 { + return false; + } + // Reject shell metacharacters + let dangerous_chars = [ + '&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r', ' ', '\t', + ]; + if image.chars().any(|c| dangerous_chars.contains(&c)) { + return false; + } + let first_segment = match image.split('/').next() { + Some(r) if !r.is_empty() => r, + _ => return false, + }; + if TRUSTED_REGISTRIES.contains(&first_segment) { + return true; + } + // No dot/colon in the first segment ⇒ it's a Docker Hub namespace or a + // bare repo name, not a registry host — allowed. Anything that *looks* + // like a host (has a dot or port) but isn't allowlisted is rejected. + !first_segment.contains('.') && !first_segment.contains(':') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_trusted_registries() { + for img in [ + "docker.io/library/nginx:1.25", + "ghcr.io/owner/app:latest", + "localhost/archy-dev:1", + "git.tx1138.com/lfg2025/x:2", + "146.59.87.168:3000/archy/bitcoin-knots:28.1", + ] { + assert!(is_valid_docker_image(img), "{img} should be accepted"); + } + } + + #[test] + fn accepts_docker_hub_shorthand() { + for img in ["nginx", "grafana/grafana:11.2.0", "lightninglabs/lnd:v0.18"] { + assert!(is_valid_docker_image(img), "{img} should be accepted"); + } + } + + #[test] + fn rejects_untrusted_registry_hosts() { + for img in [ + "evil.com/backdoor:latest", + "203.0.113.7:5000/x", + "registry.gitlab.com/x/y", + "quay.io/x/y", + ] { + assert!(!is_valid_docker_image(img), "{img} should be rejected"); + } + } + + #[test] + fn rejects_malformed_refs() { + assert!(!is_valid_docker_image("")); + assert!(!is_valid_docker_image(&"a".repeat(257))); + assert!(!is_valid_docker_image("docker.io/x; rm -rf /")); + assert!(!is_valid_docker_image("docker.io/$(curl evil)")); + assert!(!is_valid_docker_image("/leading-slash")); + } +} diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index 1ee128e7..f1281f9f 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -7,6 +7,7 @@ pub mod dev_orchestrator; pub mod docker_packages; pub mod filebrowser; pub mod hooks; +pub mod image_policy; pub mod image_versions; pub mod lnd; pub mod prod_orchestrator; diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 8fdbe869..07c703f8 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -1986,6 +1986,16 @@ impl ProdContainerOrchestrator { image_signature, .. } => { + // §A: validate at the pull site, not just the RPC boundary — + // catalog/manifest-supplied refs reach here without ever + // passing package::config's check. + if !crate::container::image_policy::is_valid_docker_image(&image) { + anyhow::bail!( + "refusing to pull image {:?} for {}: not from a trusted registry", + image, + lm.manifest.app.id + ); + } self.runtime .pull_image(&image, image_signature.as_deref()) .await @@ -2431,6 +2441,14 @@ impl ProdContainerOrchestrator { image_signature, .. } => { + // Same trusted-registry gate as install_fresh (§A). + if !crate::container::image_policy::is_valid_docker_image(&image) { + anyhow::bail!( + "refusing to pull image {:?} for {}: not from a trusted registry", + image, + lm.manifest.app.id + ); + } let exists = match self.runtime.image_exists(&image).await { Ok(exists) => exists, Err(err) => { diff --git a/docs/1.8.0-RELEASE-HARDENING-PLAN.md b/docs/1.8.0-RELEASE-HARDENING-PLAN.md index e3715ad1..ce9f253a 100644 --- a/docs/1.8.0-RELEASE-HARDENING-PLAN.md +++ b/docs/1.8.0-RELEASE-HARDENING-PLAN.md @@ -68,11 +68,14 @@ arbitrary app catalog to the entire fleet — fully unattended under `config.rs:104,124` allowlist pull images over unauthenticated HTTP. Remove the raw-IP entries; give the mirror a valid/pinned cert. (Same host also baked insecurely into the ISO — see §F.) -- [ ] 🟠 **Validate every image string at the pull site, not just the RPC boundary.** - `is_valid_docker_image` runs in `install.rs:224`/`runtime.rs:549` but - `prod_orchestrator::install_fresh` (1978) and `resolve_catalog_image` (944-971) pass - catalog/manifest images straight to `pull_image`. Call the validator right before - every pull. +- [x] 🟠 **Validate every image string at the pull site, not just the RPC boundary.** + DONE 2026-07-03: policy extracted to `container::image_policy` (single source of truth; + RPC-boundary check delegates to it) and BOTH orchestrator pull sites (`install_fresh` + + `ensure_resolved_source_available`) hard-bail on refs that fail it. Policy accepts + trusted-registry refs + registry-less Docker Hub shorthand (`grafana/grafana` — used by + 8 manifests, can't name an attacker host); rejects any explicit non-allowlisted + registry host, shell metachars, malformed refs. 4 new unit tests; container 159 / + package 46 green. ---