//! 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")); } }