From 2bf8181110fc3f5f55dac8a4698d24d4eed4374b Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 1 May 2026 08:59:11 -0400 Subject: [PATCH] refactor(security): tighten capability + TLS-bypass surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small, focused tightenings: - core/container/src/podman_client.rs: drop the legacy Hetzner 23.182.128.160:3000 mirror from image_uses_insecure_registry(). It was decommissioned in v1.7.x and is stripped from active registry config at load time; leaving it in the bypass list let a stale config still skip TLS. Replace the inline match with a named INSECURE_REGISTRY_HOSTS slice so future entries are one line. Test now also pins the spoofing-immune semantics ("evil.example/146.59.87.168:3000/x" must NOT match). - core/archipelago/src/api/rpc/package/config.rs: split bitcoin from lnd in get_app_capabilities(). bitcoind never opens raw sockets — drop CAP_NET_RAW from bitcoin/bitcoin-core/bitcoin-knots. lnd/fedimint/fedimint-gateway keep it because they enumerate network interfaces during cert generation. - core/archipelago/src/bootstrap.rs: tighten_secrets_dir() enforces 0700 on /var/lib/archipelago/secrets and 0600 on every file inside on each startup. The dir-mode is the load-bearing isolation boundary against rootless container escapes (their UID maps to >=100000, can't traverse uid=1000/0700). The per-file sweep is defense-in-depth against any installer that wrote 0644. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../archipelago/src/api/rpc/package/config.rs | 37 ++++++++++------ core/archipelago/src/bootstrap.rs | 44 +++++++++++++++++++ core/container/src/podman_client.rs | 24 +++++++--- 3 files changed, 87 insertions(+), 18 deletions(-) diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index a3917acc..940dcbc0 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -70,19 +70,30 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec { "--cap-add=SETGID".to_string(), "--cap-add=NET_BIND_SERVICE".to_string(), ], - // Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding - // LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration) - "bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" | "fedimint-gateway" => { - vec![ - "--cap-add=CHOWN".to_string(), - "--cap-add=FOWNER".to_string(), - "--cap-add=SETUID".to_string(), - "--cap-add=SETGID".to_string(), - "--cap-add=DAC_OVERRIDE".to_string(), - "--cap-add=NET_BIND_SERVICE".to_string(), - "--cap-add=NET_RAW".to_string(), - ] - } + // Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the + // RPC port. NO NET_RAW — bitcoind never opens raw sockets and + // dropping it removes a class of intra-pod spoofing capability. + "bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![ + "--cap-add=CHOWN".to_string(), + "--cap-add=FOWNER".to_string(), + "--cap-add=SETUID".to_string(), + "--cap-add=SETGID".to_string(), + "--cap-add=DAC_OVERRIDE".to_string(), + "--cap-add=NET_BIND_SERVICE".to_string(), + ], + // LND additionally needs NET_RAW for TLS certificate generation + // (netlink interface enumeration during `lnd --tlscertpath` first run). + // Fedimint inherits the same set because the gateway also enumerates + // network interfaces on startup. + "lnd" | "fedimint" | "fedimint-gateway" => vec![ + "--cap-add=CHOWN".to_string(), + "--cap-add=FOWNER".to_string(), + "--cap-add=SETUID".to_string(), + "--cap-add=SETGID".to_string(), + "--cap-add=DAC_OVERRIDE".to_string(), + "--cap-add=NET_BIND_SERVICE".to_string(), + "--cap-add=NET_RAW".to_string(), + ], // Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally) "vaultwarden" => vec![ "--cap-add=CHOWN".to_string(), diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index bbd88380..281abc60 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -66,6 +66,50 @@ pub async fn ensure_doctor_installed() { Ok(false) => debug!("Bitcoin RPC bind settings already usable"), Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e), } + match tighten_secrets_dir().await { + Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"), + Ok(_) => debug!("Secrets directory already at expected mode"), + Err(e) => warn!("Secrets dir tightening failed (non-fatal): {:#}", e), + } +} + +/// Make sure /var/lib/archipelago/secrets/ stays 0700 owned by archipelago, +/// and every file inside is 0600. The parent dir mode is the load-bearing +/// boundary against host-side reads from other UIDs (rootless container +/// escapes get mapped to UID >= 100000 and can't traverse a 0700/uid=1000 +/// directory). The per-file 0600 sweep is defense-in-depth in case some +/// installer wrote a 0644 file. +async fn tighten_secrets_dir() -> Result { + let dir = Path::new("/var/lib/archipelago/secrets"); + if !dir.exists() { + return Ok(0); + } + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .await + .with_context(|| format!("chmod 0700 {}", dir.display()))?; + + let mut entries = fs::read_dir(dir) + .await + .with_context(|| format!("read_dir {}", dir.display()))?; + let mut tightened = 0u32; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let meta = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + if !meta.is_file() { + continue; + } + if meta.permissions().mode() & 0o777 != 0o600 { + fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .await + .with_context(|| format!("chmod 0600 {}", path.display()))?; + tightened += 1; + } + } + Ok(tightened) } async fn run_service_override_repair() -> Result { diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index 995fbc85..a50100f8 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -602,11 +602,16 @@ impl PodmanClient { } } +/// Registries we ship with as `--tls-verify=false` because they're internal +/// HTTP mirrors. Add a host:port here only if it's a controlled mirror that +/// the fleet trusts and operators won't ever paste a malicious URL into. +const INSECURE_REGISTRY_HOSTS: &[&str] = &["146.59.87.168:3000"]; + pub fn image_uses_insecure_registry(image: &str) -> bool { - matches!( - image.split('/').next(), - Some("146.59.87.168:3000") | Some("23.182.128.160:3000") - ) + image + .split('/') + .next() + .is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host)) } fn podman_network_settings( @@ -703,7 +708,10 @@ mod tests { assert!(image_uses_insecure_registry( "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" )); - assert!(image_uses_insecure_registry( + // The legacy Hetzner mirror at 23.182.128.160 was decommissioned and + // is no longer trusted — it must NOT bypass TLS even if a stale + // registry config still references it. + assert!(!image_uses_insecure_registry( "23.182.128.160:3000/lfg2025/filebrowser:v2.27.0" )); assert!(!image_uses_insecure_registry( @@ -712,6 +720,12 @@ mod tests { assert!(!image_uses_insecure_registry( "docker.io/library/nginx:latest" )); + // Spoofing immune: an attacker host that prefixes the trusted IP + // string into its own URL still has the attacker host in the + // registry-host slot, so it does NOT match. + assert!(!image_uses_insecure_registry( + "evil.example:80/146.59.87.168:3000/lfg2025/x:latest" + )); } #[test]