From 2df9c720331ad80efdb6ede1aa0c2420ad31074b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 18:21:05 -0500 Subject: [PATCH] security(compose): loopback-bind control-center in release configs Follow-up to PR #44's release-compose hardening. SECURITY-COMPOSE-HARDENING.md already claimed control-center was loopback-only alongside lims and nginx-router -- a re-audit of the remaining ${HOST_IP:-0.0.0.0} bindings found that was only true of docker-compose.yml (dev); both release files had drifted to ${HOST_IP:-0.0.0.0}:7070:7070. Not a live authentication bypass: control-center's /docker, /services, /summary, /config routes are independently gated by require_permission("platform.manage_infra") at the FastAPI app level (omnibioai-control-center@8720377) regardless of this binding. This restores the documented/intended defense-in-depth binding and closes the doc/code inaccuracy. The broader question -- whether workbench/tes/auth-service/rag/model-registry should also move off ${HOST_IP:-0.0.0.0} -- remains open per SECURITY-COMPOSE-HARDENING.md SS7 item 1: it requires a product/deployment decision (would remove the direct-LAN-access path HOST_IP exists for) and is explicitly not made in this change. Co-Authored-By: Claude Sonnet 5 --- SECURITY-COMPOSE-HARDENING.md | 23 ++++++++++++-- docker-compose-release.yml | 5 ++- docker-compose.release.yml | 13 +++++++- tests/test_compose_network_exposure.py | 44 ++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/SECURITY-COMPOSE-HARDENING.md b/SECURITY-COMPOSE-HARDENING.md index 1622c28..725d39e 100644 --- a/SECURITY-COMPOSE-HARDENING.md +++ b/SECURITY-COMPOSE-HARDENING.md @@ -224,8 +224,27 @@ Found during this work; **not fixed in this change** and not claimed to be: `model-registry`, and others. These do enforce IAM authorization, so this is materially different from an unauthenticated datastore — but the gateway-first design would be better served by binding them to loopback - and routing through `nginx-router`, as `lims`, `control-center`, and - `nginx-router` itself already do. Larger change; needs its own assessment. + and routing through `nginx-router`, as `lims` and `nginx-router` itself + already do. Larger change (breaks the direct-LAN-access path `HOST_IP` + exists for); needs a product/deployment decision, not made here. + + **`control-center` follow-up (2026-08-13, separate branch):** this + document previously claimed `control-center` was *already* loopback-only + alongside `lims`/`nginx-router` — a re-audit found that claim was true + only of `docker-compose.yml` (dev); both release files had drifted to + `${HOST_IP:-0.0.0.0}:7070:7070`. Fixed to `127.0.0.1:7070:7070` in both + release files, restoring the binding this document already described + (`tests/test_compose_network_exposure.py`'s + `test_control_center_is_loopback_bound_in_release_configs` / + `test_dev_compose_control_center_still_loopback_bound` pin it going + forward). Not a live-authentication-bypass fix — `control-center`'s + `/docker`, `/services`, `/summary`, `/config` routes are independently + gated by `require_permission("platform.manage_infra")` at the FastAPI + app level (`omnibioai-control-center`, commit `8720377`) regardless of + this binding — this closes an unintended defense-in-depth gap and a + documentation inaccuracy, not an unauthenticated exposure. The general + `workbench`/`tes`/`auth-service`/`rag`/`model-registry` question above is + otherwise unchanged and still open. 2. **`docker-compose-release.yml`'s `security-audit` block lacks `AUDIT_DATABASE_URL` and the corresponding `depends_on: mysql: condition: service_healthy`** that `docker-compose.release.yml` has (both files wire diff --git a/docker-compose-release.yml b/docker-compose-release.yml index d52235b..061e1be 100644 --- a/docker-compose-release.yml +++ b/docker-compose-release.yml @@ -440,7 +440,10 @@ services: control-center: image: ghcr.io/omnibioai/omnibioai-control-center:latest ports: - - "${HOST_IP:-0.0.0.0}:7070:7070" + # Loopback-only -- see docker-compose.release.yml's identical comment + # (this file is kept in exposure parity with it; see + # SECURITY-COMPOSE-HARDENING.md). + - "127.0.0.1:7070:7070" environment: WORKSPACE_ROOT: /workspace OMNIBIOAI_BASE: /workspace/omnibioai diff --git a/docker-compose.release.yml b/docker-compose.release.yml index 945e406..81ce684 100644 --- a/docker-compose.release.yml +++ b/docker-compose.release.yml @@ -455,7 +455,18 @@ services: control-center: image: ghcr.io/omnibioai/omnibioai-control-center:latest ports: - - "${HOST_IP:-0.0.0.0}:7070:7070" + # Loopback-only, matching docker-compose.yml's (dev) own binding and + # SECURITY-COMPOSE-HARDENING.md's documented boundary -- this file had + # drifted to ${HOST_IP:-0.0.0.0} while the dev file (and the doc) + # already said "loopback." nginx-router's own internal Docker-network + # route (upstream control { server control-center:7070; }) and every + # browser/API path (/_svc/control, JWT-gated) are unaffected -- neither + # depends on this host port. control-center's own /docker, /services, + # /summary, /config routes are independently gated by + # require_permission("platform.manage_infra") regardless of this + # binding, so this is defense-in-depth restoring the documented + # design, not the closure of a live authentication bypass. + - "127.0.0.1:7070:7070" environment: WORKSPACE_ROOT: /workspace OMNIBIOAI_BASE: /workspace/omnibioai diff --git a/tests/test_compose_network_exposure.py b/tests/test_compose_network_exposure.py index 22c3663..04c774f 100644 --- a/tests/test_compose_network_exposure.py +++ b/tests/test_compose_network_exposure.py @@ -128,6 +128,50 @@ def test_datastore_still_reachable_internally(release_compose, service): ) +def test_control_center_is_loopback_bound_in_release_configs(release_compose): + """control-center is documented (SECURITY-COMPOSE-HARDENING.md SS7 item 1, + docker-compose.yml's own inline comment) as loopback-only -- unlike the + other backend services that legitimately publish on + ${HOST_IP:-0.0.0.0} and enforce their own IAM authorization, nginx-router + is meant to be control-center's only externally-reachable path + (/_svc/control, JWT-gated via auth_request). Both release files had + drifted to ${HOST_IP:-0.0.0.0} while docker-compose.yml (dev) already + used 127.0.0.1 -- this pins the documented/intended binding so it can't + silently drift back. control-center's own /docker, /services, /summary, + /config routes are independently gated by + require_permission('platform.manage_infra') regardless of this binding + (see omnibioai-control-center's main.py), so this is defense-in-depth + restoring the documented design, not the closure of a live + authentication bypass.""" + svc = release_compose["services"]["control-center"] + published = svc.get("ports") or [] + assert published, "control-center must still publish its port to the host" + for mapping in published: + assert str(mapping).startswith("127.0.0.1:"), ( + f"control-center must be loopback-bound (127.0.0.1), not host-IP " + f"configurable -- found {mapping!r}. It is documented and " + f"intended to be reachable only via nginx-router's JWT-gated " + f"/_svc/control route, matching docker-compose.yml's (dev) own " + f"binding." + ) + + +def test_dev_compose_control_center_still_loopback_bound(): + """The dev compose file is the documented source of truth this fix + restores parity with -- pin it too, so a future edit can't quietly + loosen the dev file while leaving the release files matching it (or + vice versa).""" + dev = _load(DEV_COMPOSE) + published = dev["services"]["control-center"].get("ports") or [] + assert published, "control-center must still publish its port to the host in the dev stack" + for mapping in published: + assert str(mapping).startswith("127.0.0.1:"), ( + f"docker-compose.yml's control-center binding is the documented " + f"source of truth the release files were just brought into " + f"parity with -- found {mapping!r}, expected 127.0.0.1:*" + ) + + def test_consumers_still_point_at_internal_datastore_hostnames(release_compose): """Backend services must still address mysql/redis by their internal compose service names. A regression here (e.g. someone 'fixing' a