Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Restricted the bundled standalone Compose PostgreSQL and component-health
published ports to IPv4 loopback so the default developer profile no longer
listens on every host interface when operators have not made an explicit
ingress decision.
- Enforced byte-accurate control-plane limits for multi-byte `memoryview`
chunks using `nbytes`, and rejected malformed non-byte adapter chunks with
bounded body-free diagnostics.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ docker compose up -d --build
curl -fsS localhost:8080/healthz
```

The bundled standalone Compose profile publishes PostgreSQL `5432` and the
component health port `8080` on IPv4 loopback (`127.0.0.1`) only. This keeps the
developer-facing DB and readiness surfaces reachable from the Docker host while
preventing the default profile from listening on every host interface. Remote or
production ingress must be an explicit deployment decision with its own network,
authentication, and exposure policy; do not broaden the standalone mappings as a
shortcut. See [`docs/doctoring/compose-loopback-publishing.md`](docs/doctoring/compose-loopback-publishing.md)
for the primary Docker networking evidence and verification contract.

### 2. Point it at your gateway (config + secret in the DB, not env)

```bash
Expand Down Expand Up @@ -223,6 +232,9 @@ PG_LLM_BATCH_TEST_DSN=postgresql://pgllm:pgllm@localhost:5432/pgllm \

## Docs

- [`docs/doctoring/compose-loopback-publishing.md`](docs/doctoring/compose-loopback-publishing.md)
— default standalone host-network boundary, primary Docker evidence, and
regression/release verification.
- [`docs/doctoring/opentelemetry-operations.md`](docs/doctoring/opentelemetry-operations.md)
— opt-in operation traces/metrics, host ownership, privacy and cardinality
boundaries, verification, and APA 7 references.
Expand Down
8 changes: 6 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ services:
POSTGRES_PASSWORD: pgllm
POSTGRES_DB: pgllm
ports:
- "5432:5432"
# Standalone host access is local-only by default. Remote deployments must
# opt into an explicit ingress/network policy instead of inheriting 0.0.0.0.
- "127.0.0.1:5432:5432"
healthcheck:
# Readiness = DB up AND pg_tiktoken present (mirrors /healthz gate).
test:
Expand Down Expand Up @@ -43,7 +45,9 @@ services:
PG_LLM_BATCH_DSN: "postgresql://pgllm:pgllm@postgres:5432/pgllm"
PG_LLM_BATCH_HEALTH_PORT: "8080"
ports:
- "8080:8080"
# The bundled standalone profile is deliberately host-local. Operators
# expose production health/API surfaces only through reviewed ingress.
- "127.0.0.1:8080:8080"

volumes:
pgdata:
31 changes: 31 additions & 0 deletions docs/doctoring/compose-loopback-publishing.md
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 and rejects the prior all-interface short mappings. 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 a synthetic merge ref; those results are integration evidence and are not relabeled as exact-source proof.

## References

Docker, Inc. (n.d.). *Define services in Docker Compose*. Docker Documentation. Retrieved August 9, 2026, from https://docs.docker.com/reference/compose-file/services/

Docker, Inc. (n.d.). *Port publishing and mapping*. Docker Documentation. Retrieved August 9, 2026, from https://docs.docker.com/engine/network/port-publishing/
128 changes: 128 additions & 0 deletions tests/test_compose_network_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# 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_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)
Loading