Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
23 changes: 21 additions & 2 deletions SECURITY-COMPOSE-HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docker-compose-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion docker-compose.release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions tests/test_compose_network_exposure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading