-
Notifications
You must be signed in to change notification settings - Fork 0
fix(compose): rebuild loopback publishing on current main #138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
9abb15a
test(compose): require loopback-only standalone publishing
seonghobae d48d836
fix(compose): bind standalone ports to loopback
seonghobae f92a00a
docs: record standalone loopback boundary
seonghobae 4ca3661
docs: record loopback publishing fix
seonghobae f9e6233
merge: reconcile compose hardening with protected main
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Standalone Compose loopback publishing | ||
|
|
||
| ## Decision | ||
|
|
||
| The bundled `docker-compose.yml` is a standalone/developer profile. Its published PostgreSQL (`5432`) and component health (`8080`) ports bind to IPv4 loopback (`127.0.0.1`) by default. | ||
|
|
||
| Remote access is not silently inherited from this profile. A production deployment that needs external ingress must define an explicit deployment-specific network and authorization boundary rather than broadening the bundled standalone mappings. | ||
|
|
||
| ## Security rationale | ||
|
|
||
| Docker's Compose service reference defines the short port syntax as `[HOST:]CONTAINER[/PROTOCOL]` and warns that omitting the host IP binds the published port to all host interfaces (`0.0.0.0`). Docker's port-publishing documentation likewise states that a mapping that includes `127.0.0.1` is accessible only from the Docker host. The previous `"5432:5432"` and `"8080:8080"` mappings therefore created a broader default host-network surface than the standalone workflow requires. | ||
|
|
||
| The loopback binding preserves the documented local commands: | ||
|
|
||
| - host-side PostgreSQL access through `localhost:5432`; | ||
| - host-side readiness checks through `localhost:8080`; | ||
| - Compose-internal service-to-service traffic over the project network. | ||
|
|
||
| It intentionally does not claim that loopback binding replaces authentication, tenant isolation, firewalling, ingress policy, or production deployment hardening. | ||
|
|
||
| ## Verification contract | ||
|
|
||
| `tests/test_compose_network_boundary.py` requires the exact loopback mappings, rejects unexpected host-published services, and verifies that the obsolete shell-era `PG_LLM_BATCH_HEALTH_PORT` environment override is absent. CI also renders the Compose model with `docker compose config` and builds both component and PostgreSQL images. | ||
|
|
||
| Release evidence must be taken from the exact source head under review. Organization-provided security/SAST workflows may independently exercise integration refs; those results remain distinct from exact-source proof. | ||
|
|
||
| ## References | ||
|
|
||
| Docker, Inc. (n.d.). *Define services in Docker Compose*. Docker Documentation. Retrieved August 12, 2026, from https://docs.docker.com/reference/compose-file/services/ | ||
|
|
||
| Docker, Inc. (n.d.). *Port publishing and mapping*. Docker Documentation. Retrieved August 12, 2026, from https://docs.docker.com/engine/network/port-publishing/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """Security contracts for standalone Docker Compose host publishing.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from pathlib import Path | ||
| import shutil | ||
| import subprocess | ||
| from typing import Any | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| _ROOT = Path(__file__).resolve().parents[1] | ||
| _COMPOSE_PATH = _ROOT / "docker-compose.yml" | ||
| _EXPECTED_PUBLISHED_PORTS = {"postgres": 5432, "component": 8080} | ||
|
|
||
|
|
||
| def _compose_model() -> dict[str, Any]: | ||
| """Return Docker Compose's normalized JSON model for the standalone stack.""" | ||
| docker = shutil.which("docker") | ||
| assert docker is not None, "Docker CLI is required to validate Compose security" | ||
| result = subprocess.run( | ||
| [ | ||
| docker, | ||
| "compose", | ||
| "-f", | ||
| str(_COMPOSE_PATH), | ||
| "config", | ||
| "--format", | ||
| "json", | ||
| ], | ||
| cwd=_ROOT, | ||
| check=True, | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| model = json.loads(result.stdout) | ||
| assert isinstance(model, dict) | ||
| return model | ||
|
|
||
|
|
||
| def _assert_only_loopback_port( | ||
| model: dict[str, Any], service_name: str, port_number: int | ||
| ) -> None: | ||
| """Require one exact IPv4-loopback TCP publication for a service port.""" | ||
| services = model.get("services") | ||
| assert isinstance(services, dict) | ||
| service = services.get(service_name) | ||
| assert isinstance(service, dict) | ||
| ports = service.get("ports") | ||
| assert isinstance(ports, list) | ||
| assert len(ports) == 1 | ||
|
|
||
| published = ports[0] | ||
| assert isinstance(published, dict) | ||
| assert published.get("host_ip") == "127.0.0.1" | ||
| assert int(published.get("published")) == port_number | ||
| assert published.get("target") == port_number | ||
| assert published.get("protocol", "tcp") == "tcp" | ||
|
|
||
|
|
||
| def _assert_standalone_port_contract(model: dict[str, Any]) -> None: | ||
| """Require exactly the reviewed database and health host publications.""" | ||
| services = model.get("services") | ||
| assert isinstance(services, dict) | ||
|
|
||
| published_services: set[str] = set() | ||
| for service_name, service in services.items(): | ||
| assert isinstance(service_name, str) | ||
| assert isinstance(service, dict) | ||
| ports = service.get("ports") | ||
| if ports is None: | ||
| continue | ||
| assert isinstance(ports, list) | ||
| if ports: | ||
| published_services.add(service_name) | ||
|
|
||
| assert published_services == set(_EXPECTED_PUBLISHED_PORTS) | ||
| for service_name, port_number in _EXPECTED_PUBLISHED_PORTS.items(): | ||
| _assert_only_loopback_port(model, service_name, port_number) | ||
|
|
||
|
|
||
| def test_standalone_compose_publishes_database_and_health_only_on_loopback() -> None: | ||
| """Canonical Compose ports must expose only the two intended loopback sockets.""" | ||
| _assert_standalone_port_contract(_compose_model()) | ||
|
|
||
|
|
||
| def test_standalone_compose_does_not_override_legacy_health_port_environment() -> None: | ||
| """The standalone profile must not advertise an inert shell-era health-port knob.""" | ||
| model = _compose_model() | ||
| services = model.get("services") | ||
| assert isinstance(services, dict) | ||
| component = services.get("component") | ||
| assert isinstance(component, dict) | ||
| environment = component.get("environment") | ||
| assert isinstance(environment, dict) | ||
|
|
||
| assert "PG_LLM_BATCH_HEALTH_PORT" not in environment | ||
|
|
||
|
|
||
| def test_standalone_port_contract_rejects_an_unexpected_published_service() -> None: | ||
| """Adding another host-published service must fail the standalone boundary.""" | ||
| model: dict[str, Any] = { | ||
| "services": { | ||
| "postgres": { | ||
| "ports": [ | ||
| { | ||
| "host_ip": "127.0.0.1", | ||
| "published": "5432", | ||
| "target": 5432, | ||
| "protocol": "tcp", | ||
| } | ||
| ] | ||
| }, | ||
| "component": { | ||
| "ports": [ | ||
| { | ||
| "host_ip": "127.0.0.1", | ||
| "published": "8080", | ||
| "target": 8080, | ||
| "protocol": "tcp", | ||
| } | ||
| ] | ||
| }, | ||
| "rogue": { | ||
| "ports": [ | ||
| { | ||
| "host_ip": "0.0.0.0", | ||
| "published": "9090", | ||
| "target": 9090, | ||
| "protocol": "tcp", | ||
| } | ||
| ] | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| with pytest.raises(AssertionError): | ||
| _assert_standalone_port_contract(model) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: ContextualWisdomLab/pg-llm-batch
Length of output: 11486
🌐 Web query:
Docker Engine port publishing localhost L2 access 28.0.0 release notes💡 Result:
In Docker Engine 28.0.0, a significant security improvement addresses how ports bound to the loopback interface (localhost) are accessed [1][2]. Historically, in versions older than 28.0.0, ports published to localhost (e.g., -p 127.0.0.1:8080:80) could still be accessed by other hosts on the same Layer 2 (L2) network segment due to the way Docker managed iptables rules [1][3]. This meant that traffic destined for 127.0.0.1 could bypass expected host-level restrictions and be reached by remote devices on the local area network [3][2]. Docker Engine 28.0.0 resolves this by hardening container networking, ensuring that ports explicitly bound to the localhost interface are not accessible from external hosts on the LAN [1][2]. Additionally, Docker 28.0.0 introduces a default "secure by default" posture where unpublished container ports are no longer accessible from remote LAN hosts, even if the host's filter-FORWARD policy is set to ACCEPT [2][4]. For further details, refer to the official Docker Engine 28 release notes and the associated documentation on port publishing [5][1].
Citations:
Docker Engine 28.0.0 이상을 요구하고 강제하세요.
Docker Engine 28.0.0 미만에서는
127.0.0.1에 게시한 포트가 같은 L2 네트워크의 원격 호스트에서 접근될 수 있습니다.docker-compose.yml의local-only주석을 지원 런타임 조건에 맞게 수정하세요.🧰 Tools
🪛 ast-grep (0.45.1)
[error] 23-37: Command coming from incoming request
Context: subprocess.run(
[
docker,
"compose",
"-f",
str(_COMPOSE_PATH),
"config",
"--format",
"json",
],
cwd=_ROOT,
check=True,
capture_output=True,
text=True,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(subprocess-from-request)
📍 Affects 3 files
tests/test_compose_network_boundary.py#L20-L41(this comment)docker-compose.yml#L17-L19docs/doctoring/compose-loopback-publishing.md#L11-L11🤖 Prompt for AI Agents