feat(security): enforce trusted-registry image policy at the orchestrator pull sites
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 <noreply@anthropic.com>
This commit is contained in:
parent
2f20ba8148
commit
4b4a1f88fb
@ -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.
|
||||
|
||||
95
core/archipelago/src/container/image_policy.rs
Normal file
95
core/archipelago/src/container/image_policy.rs
Normal file
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user