refactor(security): tighten capability + TLS-bypass surface
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) <noreply@anthropic.com>
This commit is contained in:
@@ -70,19 +70,30 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
"--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(),
|
||||
|
||||
@@ -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<u32> {
|
||||
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<bool> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user