From e0a31a771c21ff8d820b1446b6ee7f9463dd20b3 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 14 Oct 2026 21:35:47 +0300 Subject: [PATCH 1/2] Test that 127.0.0.1 ports in stacker.yml stay off the internet A contract test across the CLI and the server: a stacker.yml with ports bound to 127.0.0.1 goes through the CLI's build_project_body and then through derive_public_ports_from_metadata and the compose port conversion. Each half had its own unit test and the bug kept coming back: the CLI drops the host IP, the server expects it with the mapping, and databases bound to 127.0.0.1 ended up open in the firewall and published on every interface. Fails on the current code: 5432, 6379 and 8025 reach the firewall and none is published on 127.0.0.1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f --- src/routes/project/deploy.rs | 89 ++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index 0665694b..cc7e94dc 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -3060,3 +3060,92 @@ mod tests { assert!(embedded_marketplace_compose(&metadata).is_none()); } } + +/// Contract across the CLI and the server for host-local ports. +/// +/// The loopback handling broke more than once because each half was tested +/// alone: the CLI parser was pinned to drop the host IP (`127.0.0.1:1025:25` -> +/// `1025`), while the server expects the IP to arrive with the mapping and +/// skips such ports. Together they opened databases bound to 127.0.0.1 to the +/// internet: the firewall rule allowed 5432/6379 from anywhere and the compose +/// file published them on every interface. This test runs a stacker.yml +/// through the CLI's payload builder and then through what the server does +/// with it. +#[cfg(test)] +mod loopback_ports_contract { + use super::derive_public_ports_from_metadata; + use crate::cli::config_parser::StackerConfig; + use crate::cli::stacker_client::build_project_body; + + const STACKER_YML: &str = r#" +name: loopback-contract +app: + type: custom + image: example/web:1.0 + ports: + - "8000:8000" + - "127.0.0.1:8025:8025" +services: + - name: postgres + image: postgres:16 + ports: + - "127.0.0.1:5432:5432" + - name: redis + image: redis:7 + ports: + - "127.0.0.1:6379:6379/tcp" + - name: proxy + image: nginx:alpine + ports: + - "443:443" +"#; + + fn shared_ports(body: &serde_json::Value) -> Vec { + ["web", "service", "feature"] + .iter() + .filter_map(|group| body["custom"][group].as_array()) + .flatten() + .filter_map(|app| app["shared_ports"].as_array()) + .flatten() + .cloned() + .collect() + } + + #[test] + fn ports_bound_to_loopback_in_stacker_yml_are_not_opened_to_the_internet() { + let config = StackerConfig::from_str(STACKER_YML).expect("stacker.yml parses"); + let body = build_project_body(&config); + + let mut public = derive_public_ports_from_metadata(&body); + public.sort(); + assert_eq!( + public, + vec!["443/tcp".to_string(), "8000/tcp".to_string()], + "only the ports not bound to 127.0.0.1 may reach the firewall" + ); + } + + #[test] + fn ports_bound_to_loopback_in_stacker_yml_stay_on_loopback_in_compose() { + let config = StackerConfig::from_str(STACKER_YML).expect("stacker.yml parses"); + let body = build_project_body(&config); + + let mut loopback_published = Vec::new(); + for raw in shared_ports(&body) { + let port: crate::forms::project::Port = + serde_json::from_value(raw.clone()).expect("shared port"); + let dc: docker_compose_types::Port = (&port).try_into().expect("compose port"); + if let Some(docker_compose_types::PublishedPort::Single(p)) = dc.published { + if dc.host_ip.as_deref() == Some("127.0.0.1") { + loopback_published.push(p); + } + } + } + loopback_published.sort(); + assert_eq!( + loopback_published, + vec![5432, 6379, 8025], + "compose must publish these on 127.0.0.1 only" + ); + } +} From 85d380f9ac01e3c2a12f675099ba6c668dbb09fc Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 14 Oct 2026 21:41:06 +0300 Subject: [PATCH 2/2] Keep the host IP of stacker.yml port mappings in the deploy payload The CLI sent "127.0.0.1:5432:5432" as host_port 5432 / container_port 5432, so the server never saw the loopback binding: it opened 5432 in the cloud firewall and the compose file published it on every interface. The CLI now sends the whole mapping in container_port when it carries a host IP, the form the server already parses (forms::project::port normalize_port_mapping) for the firewall and for compose. parse_port_mapping itself is unchanged. Regression: routes::project::deploy::loopback_ports_contract (previous commit) now passes; lib 2238 passed, BDD 334 scenarios passed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f --- src/cli/stacker_client.rs | 41 ++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 238343ba..4eb304d7 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -3826,6 +3826,26 @@ fn parse_port_mapping(port_str: &str) -> (String, String) { } } +/// The `shared_ports` entry for a port mapping from stacker.yml. +/// +/// A host IP must reach the server: `127.0.0.1:5432:5432` is host-local, and +/// the server reads the IP from `container_port` to keep the port out of the +/// firewall and to publish it on 127.0.0.1 only. Sending just `5432` opened a +/// database bound to loopback to the internet. +fn port_mapping_json(port_str: &str) -> serde_json::Value { + let (host, container) = parse_port_mapping(port_str); + let mapping = port_str.split('/').next().unwrap_or(port_str); + let container_port = if mapping.matches(':').count() >= 2 { + mapping.to_string() + } else { + container + }; + serde_json::json!({ + "host_port": host, + "container_port": container_port, + }) +} + /// Parse a volume mapping string like "./dist:/usr/share/nginx/html" or "data:/var/lib/db" /// into (host_path, container_path, read_only) tuple. /// Handles optional `:ro` / `:rw` suffix (e.g. "/var/run/docker.sock:/var/run/docker.sock:ro"). @@ -3847,17 +3867,8 @@ fn service_to_app_json(svc: &ServiceDefinition, network_ids: &[String]) -> serde let (dockerhub_user, dockerhub_name, dockerhub_tag) = parse_docker_image(&svc.image); let id = generate_app_id(); - let shared_ports: Vec = svc - .ports - .iter() - .map(|p| { - let (host, container) = parse_port_mapping(p); - serde_json::json!({ - "host_port": host, - "container_port": container, - }) - }) - .collect(); + let shared_ports: Vec = + svc.ports.iter().map(|p| port_mapping_json(p)).collect(); let volumes: Vec = svc .volumes @@ -3977,13 +3988,7 @@ fn app_source_to_app_json( .app .ports .iter() - .map(|p| { - let (host, container) = parse_port_mapping(p); - serde_json::json!({ - "host_port": host, - "container_port": container, - }) - }) + .map(|p| port_mapping_json(p)) .collect() };