From c8b81a52f5ca48f9ccbf24c0a4b16807e06f3db3 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 14 Oct 2026 08:38:12 -0400 Subject: [PATCH 1/4] spec: model NetworkingConfig endpoints under a forced network_mode (#87) Add network_endpoints_model to the request-handling spec: what the gate and ContainerConfigMutator do with NetworkingConfig.EndpointsConfig when the policy forces network_mode, and the daemon's start check. The flag ENFORCE_ENDPOINTS, the rule extraNetworkDenied, the security property onlyForcedNetwork, the bug property allowedCreatesStart, one run per table row, and network_endpoints_pre87, which witnesses the CLI's {"default": {}} being forwarded under host and failing the start. Co-Authored-By: Claude Opus 5.5 --- Makefile | 2 + spec/README.md | 41 +++++++++- spec/docker_socket_policy.qnt | 139 ++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 96b18de..6905cd9 100644 --- a/Makefile +++ b/Makefile @@ -106,6 +106,8 @@ test-spec: $(QUINT) test $(ROUTER_SPEC) --main=router_pre76 $(QUINT) test $(ROUTER_SPEC) --main=router_pre78 $(QUINT) test $(ROUTER_SPEC) --main=router_pre70 + $(QUINT) test $(SPEC) --main=network_endpoints + $(QUINT) test $(SPEC) --main=network_endpoints_pre87 verify-ts: $(QUINT) run $(SPEC) --max-steps=50 --invariants allInvariants --backend typescript diff --git a/spec/README.md b/spec/README.md index bbb219f..9a0ce50 100644 --- a/spec/README.md +++ b/spec/README.md @@ -6,7 +6,7 @@ This directory contains a [Quint](https://quint-lang.org/) formal specification | File | Purpose | |------|---------| -| `docker_socket_policy.qnt` | Request-handling spec: policy types, state machine, endpoint routing table, 9 invariants (6 P0 / 3 P1), 6 attack scenario simulations | +| `docker_socket_policy.qnt` | Request-handling spec: policy types, state machine, endpoint routing table, 9 invariants (6 P0 / 3 P1), 6 attack scenario simulations. Module `network_endpoints_model` models `NetworkingConfig.EndpointsConfig` under a forced `network_mode` (#87), one `run` test per table row, instances `network_endpoints` (all three after #87) and `network_endpoints_pre87` | | `listener.qnt` | Listening-socket startup: flag/group selection, existing-path checks, single-instance lock, 6 invariants, one `run` test per design-table row. Instances `listener_locked` (Go, Rust) and `listener_unlocked` (TypeScript) | | `listener-design.md` | Design of the listening socket (dockerd parity) that `listener.qnt` models | | `router.qnt` | The percent-encoded path deny, the empty interior segment deny, the dot segment deny, the segment-exact exec deny and `POST /containers/create` route, the segment-exact attach deny, the GET/HEAD-only read passthrough with `/_ping` standing for the read endpoints, container-name extraction and the container-lifecycle branch of the router only, not the full router. The daemon's percent-decoding as a fixed table. One `run` test per table row. Instances `router` (Go, TypeScript, and Rust after #48, #53, #49, #55, #76, #78 and #70), `router_pre48` (Rust's extraction rule before #48), `router_pre53` (Rust and TypeScript before #53, routing on the raw path), `router_pre55` (all three before #55, letting empty interior segments through), `router_readonly` (as `router`, with `--readonly`, #75), `router_pre76` (all three before #76, letting attach through), `router_pre78` (all three before #78, allowing the read endpoints for every method) and `router_pre70` (all three before #70, letting `.` and `..` segments through). Every instance includes the #49 exec and create rule, so the pre instances isolate one earlier rule each rather than being full snapshots | @@ -45,6 +45,10 @@ quint test spec/router.qnt --main=router_pre76 quint test spec/router.qnt --main=router_pre78 quint test spec/router.qnt --main=router_pre70 +# Network endpoints under a forced network_mode (#87): the table tests on both instances +quint test spec/docker_socket_policy.qnt --main=network_endpoints +quint test spec/docker_socket_policy.qnt --main=network_endpoints_pre87 + # Formal model-checking via Apalache (exhaustive, requires Java) quint verify --max-steps=10 --invariants allInvariants spec/docker_socket_policy.qnt ``` @@ -189,6 +193,40 @@ The rows marked "all" above run on every `READONLY = false` instance; `router_re Each implementation's router tests use the same row names in comments (added with the #48, #53, #49, #55, #76, #78 and #70 fixes). `router_pre48` runs `pre48UnsoundTest`, which asserts that `lifecycleOnlyTargetsRealNames` fails and that both `emptyName*Denied` requests are allowed as an unknown container ([#48](https://github.com/ChainSafe/docker-socket-policy/issues/48)). `router_pre53` runs `pre53UnsoundTest`, which asserts that `routerSeesWhatDaemonSees` fails, that `DELETE /containers/%2F` and `POST /containers/%2F/start` are allowed as an unknown container, and that `GET /containers/%2F` is allowed through the passthrough. `router_pre55` runs `pre55UnsoundTest`, which asserts that `emptyInteriorDenied` fails and that `GET //exec/abc` is allowed through the passthrough: its first segment is empty, not `exec`, so the exec check misses it. `router_pre76` runs `pre76UnsoundTest`, which asserts that `attachNeverAllowed` fails and that `GET /containers/mycontainer/attach` is allowed through the passthrough ([#76](https://github.com/ChainSafe/docker-socket-policy/issues/76)). `router_pre78` runs `pre78UnsoundTest`, which asserts that `readPassthroughOnlyForReads` fails and that `POST /_ping` and `DELETE /_ping` are allowed as reads ([#78](https://github.com/ChainSafe/docker-socket-policy/issues/78)). `router_pre70` runs `pre70UnsoundTest`, which asserts that `dotSegmentsDenied` and `dotSegmentsHideNoExec` fail and that `GET /./exec/abc` is allowed through the passthrough ([#70](https://github.com/ChainSafe/docker-socket-policy/issues/70)). +### Network endpoints under a forced `network_mode` (`docker_socket_policy.qnt`, #87) + +`alwaysHostNetwork` covers `HostConfig.NetworkMode` only. The daemon also reads `NetworkingConfig.EndpointsConfig`, and before #87 no implementation touched it: without `--network` the Docker CLI sends `{"default": {}}`, the create succeeded, and the start failed with `network default not found`. The module `network_endpoints_model` models what `NetworkEndpointsGate` and `ContainerConfigMutator` do with `EndpointsConfig` when the policy forces a mode M, as the pure function `handleEndpoints(M, endpoints)`. `EndpointsConfig` is a map from network name to settings (`""` for `{}`, `"a"` for an entry with settings such as `Aliases`); an absent `NetworkingConfig` is the empty map, and `container:x` stands for every `container:` mode. A key other than `default` and M is denied with `network "" is not allowed for service ; policy forces network_mode `. Otherwise `host`, `none` and `container:x` get an empty `EndpointsConfig`, and `bridge` or a named network gets exactly one entry keyed M, with the settings of the caller's M entry, else of its `default` entry, else `{}`. `daemonStarts` is the daemon's start check as observed on Engine 29.8.2 (API 1.41 to 1.56): every endpoint key must name the container's `NetworkMode`. The properties range over the five modes and all 2187 maps over seven keys and two settings: + +- `extraNetworkDenied` holds when every request with a key other than `default` and M is denied (the rule). +- `onlyForcedNetwork` holds when every allowed request names no network but M (the security property: an extra entry would attach the container to a network the policy does not allow). +- `allowedCreatesStart` holds when every allowed request passes `daemonStarts` (the bug). + +`network_endpoints` runs `endpointsSoundTest`, which asserts all three, and one test per row. `network_endpoints_pre87` (all three before #87, `ENFORCE_ENDPOINTS = false`) runs `pre87UnsoundTest`, which asserts that all three properties fail, that `{"default": {}}` under `host` is forwarded unchanged and fails `daemonStarts`, and that an extra entry is allowed. The Go, Rust and TypeScript middleware tests use the same row names in comments. + +| Row (`run Test`) | Forced mode | Request `EndpointsConfig` | Outcome | +|-----|-----|-----|-----| +| `hostNoEndpoints` | `host` | absent | `{}` | +| `hostDefaultCleared` | `host` | `{"default": {}}` | `{}` | +| `hostOwnCleared` | `host` | `{"host": {Aliases}}` | `{}` | +| `hostExtraDenied` | `host` | `{"default": {}, "other": {}}` | deny | +| `noneNoEndpoints` | `none` | absent | `{}` | +| `noneDefaultCleared` | `none` | `{"default": {}}` | `{}` | +| `noneOwnCleared` | `none` | `{"none": {Aliases}}` | `{}` | +| `noneExtraDenied` | `none` | `{"default": {}, "other": {}}` | deny | +| `containerNoEndpoints` | `container:x` | absent | `{}` | +| `containerDefaultCleared` | `container:x` | `{"default": {}}` | `{}` | +| `containerOwnCleared` | `container:x` | `{"container:x": {Aliases}}` | `{}` | +| `containerExtraDenied` | `container:x` | `{"default": {}, "other": {}}` | deny | +| `bridgeNoEndpoints` | `bridge` | absent | `{"bridge": {}}` | +| `bridgeDefaultRekeyed` | `bridge` | `{"default": {Aliases}}` | `{"bridge": {Aliases}}` | +| `bridgeOwnKept` | `bridge` | `{"bridge": {Aliases}}` | `{"bridge": {Aliases}}` | +| `bridgeExtraDenied` | `bridge` | `{"default": {}, "other": {}}` | deny | +| `namedNoEndpoints` | `mynet` | absent | `{"mynet": {}}` | +| `namedDefaultRekeyed` | `mynet` | `{"default": {Aliases}}` | `{"mynet": {Aliases}}` | +| `namedOwnKept` | `mynet` | `{"mynet": {Aliases}}` | `{"mynet": {Aliases}}` | +| `namedExtraDenied` | `mynet` | `{"default": {}, "other": {}}` | deny | +| `namedOwnOverDefault` | `mynet` | `{"default": {Aliases}, "mynet": {}}` | `{"mynet": {}}` | + ### Modeling Notes Two invariants are structurally tautological within the Quint model — they can't be falsified by any action sequence the simulator generates, so they don't get real coverage from `quint run`/`quint verify`: @@ -213,6 +251,7 @@ Two invariants are structurally tautological within the Quint model — they can - **`--readonly` is a router rule (#75).** Before #75 the flag reached only the middleware chain, and the handler runs the chain only on the create route, so lifecycle `POST`s, `DELETE /containers/{name}` and `POST /images/create` were forwarded in read-only mode. That was handler wiring, not routing, so there is no `pre75` instance: the `READONLY = false` instances are the behaviour without the flag, which before the fix was also the behaviour with it for every route but create. `readonlyAllowsOnlyReads` does not mention `READONLY`, so it is not vacuous: setting `router_readonly`'s `READONLY` to `false` makes `readonlySoundTest` and the three `readonly*Denied` rows fail. `METHODS` has no `PUT`; the implementations' tests cover it. - **Attach is a router rule; the upgrade refusal is not modelled (#76).** Before #76, `GET /containers/{name}/attach/ws` with `Upgrade: websocket` fell through to the GET passthrough, and Go relayed the daemon's `101`, giving the caller the container's stdin. The router now denies `attach` as a whole segment after `containers`, for every method, right after the exec check. As with `/containers/exec/…`, a container literally named `attach` is denied too (`attachNamedContainerDenied`). `attachNeverAllowed` fails on `router_pre76`, and setting `router`'s `ALLOW_ATTACH` to `true` makes `attachSoundTest` and three of the four `attach*` rows fail; `attachPostDenied` is a pin, since the lifecycle branch already denies `POST` when the last segment is not an action. The handler also refuses every request with an `Upgrade` header, or a `Connection` header listing `upgrade`, with `protocol upgrade not allowed`, before routing. That check is on headers, which the model does not have, so like the query string it is pinned by the language handler tests and the integration tests, not by the model. - **The read endpoints admit only `GET` and `HEAD`; the `CONNECT` refusal is not modelled (#78).** Before #78 an early clause in all three routers allowed `/_ping`, `/version`, `/info` and any path starting with `/events` for every method, so `POST /_ping` and `DELETE /version` reached the daemon, and `/eventsxyz` matched too. The clause is gone: `GET` and `HEAD` on those paths reach the read passthrough as before, and every other method falls through to the default deny. `readPassthroughOnlyForReads` fails on `router_pre78`, and setting `router`'s `ALLOW_ANY_METHOD_PING` to `true` makes `readPassthroughSoundTest`, `pingPostDenied` and `pingDeleteDenied` fail. The handlers also refuse every `CONNECT` request with `CONNECT is not allowed` before routing, ahead of the upgrade check; `METHODS` has no `CONNECT`, so that is pinned by the language handler tests and the integration tests, not by the model. +- **Network endpoints are a pure function, not part of the state machine (#87).** `createContainer` records the forced `networkMode` but takes no `NetworkingConfig`, and nothing in the state machine models the daemon's start, so the endpoint rule sits in its own module beside it and is checked by `quint test`, not by `make verify`. When the policy does not force `network_mode`, `NetworkingConfig` is forwarded untouched; neither the model nor the implementations check it then. Whether the daemon accepts the settings kept on a re-keyed entry (aliases on the default `bridge` network, for example) is the daemon's decision; the model fixes only the keys. `extraNetworkDenied` and `onlyForcedNetwork` are close to tautologies on `network_endpoints`, since `handleEndpoints` tests the key set directly; `network_endpoints_pre87` shows they are not vacuous. Dropping the extra keys instead of denying would keep `onlyForcedNetwork` and fail `extraNetworkDenied`; that was rejected, because the caller asked for a network the policy does not allow and should be told. A malformed `NetworkingConfig` or `EndpointsConfig` (not a JSON object) is denied by the implementations under a forced mode; the model's maps cannot express it, so the language tests pin it. - **Listener fault bias.** `step` crashes an instance on 1 in 10 draws instead of half of all steps, so random runs actually interleave live instances. Every crash stays reachable from every phase, so the reachable state space is unchanged. ### Attack Scenarios Prevented by Invariants diff --git a/spec/docker_socket_policy.qnt b/spec/docker_socket_policy.qnt index 2d59b2c..cec3426 100644 --- a/spec/docker_socket_policy.qnt +++ b/spec/docker_socket_policy.qnt @@ -42,6 +42,145 @@ module policy { } } +// ─── Module: network_endpoints_model ──────────────────────────────────── +// +// How ContainerConfigMutator and NetworkEndpointsGate treat +// NetworkingConfig.EndpointsConfig when the policy forces +// container_config.network_mode (#87). The state machine below records the +// forced HostConfig.NetworkMode (`alwaysHostNetwork`); this module adds the +// endpoint entries, which the daemon reads alongside it. +// +// EndpointsConfig is a map from network name to that entry's settings: "" +// stands for `{}` and "a" for an entry with settings such as Aliases. An +// absent NetworkingConfig and an empty EndpointsConfig are both `Map()`. +// `container:x` stands for every `container:` mode. +// +// ENFORCE_ENDPOINTS = false models all three before #87: the mutator forced +// HostConfig.NetworkMode and forwarded EndpointsConfig as the caller sent +// it. Without `--network` the Docker CLI sends {"default": {}}, so the +// create succeeded and the start failed with `network default not found`. +// true models all three after the fix: a key other than "default" and the +// forced mode M is denied; otherwise host, none and container:x get no +// entries, and bridge or a named network gets exactly one entry keyed M, +// with the settings of the caller's M entry, else of its "default" entry, +// else `{}`. + +module network_endpoints_model { + const ENFORCE_ENDPOINTS: bool + + type Endpoints = str -> str + type EndpointsOutcome = { allowed: bool, endpoints: Endpoints } + + pure val NETWORK_MODES = Set("host", "none", "container:x", "bridge", "mynet") + pure val ENDPOINT_KEYS = Set("default", "host", "none", "container:x", "bridge", "mynet", "other") + pure val ENDPOINT_SETTINGS = Set("", "a") + + // Every EndpointsConfig over ENDPOINT_KEYS and ENDPOINT_SETTINGS. + pure val ENDPOINT_REQUESTS: Set[Endpoints] = + ENDPOINT_KEYS.powerset().fold(Set(), (acc, ks) => acc.union(ks.setOfMaps(ENDPOINT_SETTINGS))) + + // Modes in which the container joins no network of its own. + pure def detachedMode(m: str): bool = + m == "host" or m == "none" or m == "container:x" + + pure def forcedEntry(m: str, eps: Endpoints): str = + if (eps.keys().contains(m)) eps.get(m) + else if (eps.keys().contains("default")) eps.get("default") + else "" + + pure def forceEndpoints(m: str, eps: Endpoints): Endpoints = + if (detachedMode(m)) Map() else Map(m -> forcedEntry(m, eps)) + + // The create request's endpoints after the gate and the mutator, for a + // policy that forces network mode m. + pure def handleEndpoints(m: str, eps: Endpoints): EndpointsOutcome = + if (not(ENFORCE_ENDPOINTS)) { allowed: true, endpoints: eps } + else if (eps.keys().forall(k => k == "default" or k == m)) + { allowed: true, endpoints: forceEndpoints(m, eps) } + else { allowed: false, endpoints: eps } + + // The daemon starts the container only when every endpoint key names its + // NetworkMode. Checked on Engine 29.8.2, API 1.41 to 1.56: with NetworkMode + // host, none or bridge, a "default" entry fails the start with + // `network default not found`, and an empty EndpointsConfig starts. + pure def daemonStarts(m: str, eps: Endpoints): bool = + eps.keys().forall(k => k == m) + + // Rule: an endpoint key other than "default" and the forced mode is denied. + val extraNetworkDenied = NETWORK_MODES.forall(m => ENDPOINT_REQUESTS.forall(eps => + eps.keys().exists(k => k != "default" and k != m) implies not(handleEndpoints(m, eps).allowed) + )) + + // Security: an allowed create names no network but the forced one. + val onlyForcedNetwork = NETWORK_MODES.forall(m => ENDPOINT_REQUESTS.forall(eps => + val o = handleEndpoints(m, eps) + o.allowed implies o.endpoints.keys().subseteq(Set(m)) + )) + + // The bug: every allowed create also starts. + val allowedCreatesStart = NETWORK_MODES.forall(m => ENDPOINT_REQUESTS.forall(eps => + val o = handleEndpoints(m, eps) + o.allowed implies daemonStarts(m, o.endpoints) + )) +} + +module network_endpoints { + import network_endpoints_model(ENFORCE_ENDPOINTS = true).* + + pure def allowedWith(m: str, eps: Endpoints, want: Endpoints): bool = + handleEndpoints(m, eps) == { allowed: true, endpoints: want } + + pure def denied(m: str, eps: Endpoints): bool = + not(handleEndpoints(m, eps).allowed) + + run endpointsSoundTest = + assert(and { extraNetworkDenied, onlyForcedNetwork, allowedCreatesStart }) + + run hostNoEndpointsTest = assert(allowedWith("host", Map(), Map())) + run hostDefaultClearedTest = assert(allowedWith("host", Map("default" -> ""), Map())) + run hostOwnClearedTest = assert(allowedWith("host", Map("host" -> "a"), Map())) + run hostExtraDeniedTest = assert(denied("host", Map("default" -> "", "other" -> ""))) + + run noneNoEndpointsTest = assert(allowedWith("none", Map(), Map())) + run noneDefaultClearedTest = assert(allowedWith("none", Map("default" -> ""), Map())) + run noneOwnClearedTest = assert(allowedWith("none", Map("none" -> "a"), Map())) + run noneExtraDeniedTest = assert(denied("none", Map("default" -> "", "other" -> ""))) + + run containerNoEndpointsTest = assert(allowedWith("container:x", Map(), Map())) + run containerDefaultClearedTest = assert(allowedWith("container:x", Map("default" -> ""), Map())) + run containerOwnClearedTest = assert(allowedWith("container:x", Map("container:x" -> "a"), Map())) + run containerExtraDeniedTest = assert(denied("container:x", Map("default" -> "", "other" -> ""))) + + run bridgeNoEndpointsTest = assert(allowedWith("bridge", Map(), Map("bridge" -> ""))) + run bridgeDefaultRekeyedTest = assert(allowedWith("bridge", Map("default" -> "a"), Map("bridge" -> "a"))) + run bridgeOwnKeptTest = assert(allowedWith("bridge", Map("bridge" -> "a"), Map("bridge" -> "a"))) + run bridgeExtraDeniedTest = assert(denied("bridge", Map("default" -> "", "other" -> ""))) + + run namedNoEndpointsTest = assert(allowedWith("mynet", Map(), Map("mynet" -> ""))) + run namedDefaultRekeyedTest = assert(allowedWith("mynet", Map("default" -> "a"), Map("mynet" -> "a"))) + run namedOwnKeptTest = assert(allowedWith("mynet", Map("mynet" -> "a"), Map("mynet" -> "a"))) + run namedExtraDeniedTest = assert(denied("mynet", Map("default" -> "", "other" -> ""))) + run namedOwnOverDefaultTest = assert(allowedWith("mynet", Map("default" -> "a", "mynet" -> ""), Map("mynet" -> ""))) +} + +// All three before #87: EndpointsConfig went through as the caller sent it. +// The Docker CLI's {"default": {}} was allowed with network_mode host, and +// the daemon then refused to start the container; an extra entry was allowed +// too. +module network_endpoints_pre87 { + import network_endpoints_model(ENFORCE_ENDPOINTS = false).* + + run pre87UnsoundTest = + assert(and { + not(extraNetworkDenied), + not(onlyForcedNetwork), + not(allowedCreatesStart), + handleEndpoints("host", Map("default" -> "")) == { allowed: true, endpoints: Map("default" -> "") }, + not(daemonStarts("host", Map("default" -> ""))), + handleEndpoints("host", Map("default" -> "", "other" -> "")).allowed, + }) +} + // ─── Module: docker_socket_policy ─────────────────────────────────────── module docker_socket_policy { From 33af9bd207f206ba1204ea16d51e353e00932ae7 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 14 Oct 2026 08:43:12 -0400 Subject: [PATCH 2/4] test: pin NetworkingConfig endpoints under a forced network_mode (#87) The same 26-row table in Go, Rust and TS: the 21 rows of the network_endpoints spec module (host, none, container:x, bridge and a named network, each with no NetworkingConfig, the CLI's default entry, the mode's own entry with aliases, and an extra entry that is denied), null and malformed NetworkingConfig, and a control row without a forced mode. 23 rows fail on the pre-fix code in all three. deploy/test.sh pulls two small images through the proxy and creates and starts real containers the way the Docker CLI does, with EndpointsConfig {"default": {}}, under two new deploy/config policies that force host and none; then it removes them. Before the fix both starts fail with "network default not found" and an extra endpoint is created instead of denied. Co-Authored-By: Claude Opus 5.5 --- deploy/config/net-host.yaml | 9 ++ deploy/config/net-none.yaml | 9 ++ deploy/test.sh | 58 ++++++- go/internal/middleware/chain_test.go | 96 ++++++++++++ rs/src/middleware.rs | 219 +++++++++++++++++++++++++++ ts/src/middleware.test.ts | 75 +++++++++ 6 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 deploy/config/net-host.yaml create mode 100644 deploy/config/net-none.yaml diff --git a/deploy/config/net-host.yaml b/deploy/config/net-host.yaml new file mode 100644 index 0000000..c9fb655 --- /dev/null +++ b/deploy/config/net-host.yaml @@ -0,0 +1,9 @@ +# Integration test policy for #87: a forced network_mode of host. hello-world +# is small and exits at once, so deploy/test.sh can pull, create and start a +# real container through the proxy. The container is named after the service. +service_name: net-host +allowed_image_prefixes: + - hello-world + +container_config: + network_mode: host diff --git a/deploy/config/net-none.yaml b/deploy/config/net-none.yaml new file mode 100644 index 0000000..cc609f2 --- /dev/null +++ b/deploy/config/net-none.yaml @@ -0,0 +1,9 @@ +# Integration test policy for #87: a forced network_mode of none. The pause +# image is small, so deploy/test.sh can pull, create and start a real +# container through the proxy. The container is named after the service. +service_name: net-none +allowed_image_prefixes: + - registry.k8s.io/pause + +container_config: + network_mode: none diff --git a/deploy/test.sh b/deploy/test.sh index 1a9e8ba..a7dfd4a 100755 --- a/deploy/test.sh +++ b/deploy/test.sh @@ -2,8 +2,9 @@ # Integration tests for docker-socket-policy. # Sends HTTP requests directly to the proxy's Docker API and checks status codes, # and the deny message on every expected proxy 403. -# The proxy's RegistryGate, MountSourceGate, EnvFileGate, CmdGate, and -# ContainerConfigMutator are all exercised; exec and attach are denied by the +# The proxy's RegistryGate, MountSourceGate, EnvFileGate, CmdGate, +# NetworkEndpointsGate and ContainerConfigMutator are all exercised, the last +# two by starting real containers (#87); exec and attach are denied by the # router, protocol upgrades by the handler (#76), non-read methods on the read # endpoints by the router and CONNECT by the handler (#78), and every write on a # second proxy run with --readonly (#75). @@ -530,6 +531,59 @@ check_denied "GET /x/../exec/*/json -> 403 (dot segment)" "$S" "$BODY" 'dot path S=$(dot_get "$PROXY/containers/./json") check_denied "GET /containers/./json -> 403 (dot segment)" "$S" "$BODY" 'dot path segment not allowed' +# ─── Forced network_mode and NetworkingConfig (#87) ────── + +# A policy that forces network_mode must also make the endpoint entries match +# it. Without --network the Docker CLI sends EndpointsConfig {"default": {}}; +# before #87 the proxy forwarded that as sent, the create succeeded, and the +# start failed with "network default not found". These create and START real +# containers through the proxy, as the CLI does, under the net-host (host) and +# net-none (none) policies in deploy/config, then remove them. +echo "" +echo "--- Forced network_mode (#87) ---" + +# pull IMAGE TAG: pull through the proxy. The router reads the body (and the +# query string, #83); the daemon reads the query string. A pull streams until +# it completes, so it gets a longer timeout. +pull() { + : >"$BODY" + out=$(curl -s -o "$BODY" -w '%{http_code}' --max-time 120 --connect-timeout 2 --unix-socket "$PROXY_SOCK" \ + -X POST -H "Content-Type: application/json" -d "{\"fromImage\":\"$1:$2\"}" \ + "$PROXY/images/create?fromImage=$1&tag=$2" 2>/dev/null || true) + echo "${out:-000}" +} + +# start_forced NAME IMAGE MODE: create NAME as the CLI does, start it, and +# remove it. A leftover from an earlier run is removed first. +start_forced() { + delete_status "$PROXY/containers/$1?force=true" >/dev/null + S=$(post_json "{\"Image\":\"$2\",\"HostConfig\":{},\"NetworkingConfig\":{\"EndpointsConfig\":{\"default\":{}}}}" \ + "$PROXY/containers/create?name=$1") + check "create $1 with EndpointsConfig {\"default\":{}} -> 201 (network_mode $3)" "201" "$S" + S=$(post_empty "$PROXY/containers/$1/start") + check "start $1 -> 204 (network_mode $3)" "204" "$S" + [ "$S" = "204" ] || echo " start body: $(head -c 200 "$BODY")" + S=$(delete_status "$PROXY/containers/$1?force=true") + check "remove $1 -> 204" "204" "$S" +} + +S=$(pull hello-world latest) +check "pull hello-world (net-host policy) -> 200" "200" "$S" +start_forced net-host hello-world:latest host + +S=$(pull registry.k8s.io/pause 3.10) +check "pull registry.k8s.io/pause (net-none policy) -> 200" "200" "$S" +start_forced net-none registry.k8s.io/pause:3.10 none + +# An extra endpoint entry would attach the container to a network the policy +# does not allow, so it is denied, not dropped. +S=$(post_json '{"Image":"hello-world:latest","NetworkingConfig":{"EndpointsConfig":{"default":{},"bridge":{}}}}' \ + "$PROXY/containers/create?name=net-host") +check_denied "create with an extra endpoint under network_mode host -> 403" "$S" "$BODY" \ + 'network "bridge" is not allowed for service net-host; policy forces network_mode host' +# Without the deny the create succeeds; do not leave the container behind. +delete_status "$PROXY/containers/net-host?force=true" >/dev/null + # ─── Read-only mode (#75) ──────────────────────────────── # The same config behind --readonly. Every request whose method is not GET or diff --git a/go/internal/middleware/chain_test.go b/go/internal/middleware/chain_test.go index d414859..35afe77 100644 --- a/go/internal/middleware/chain_test.go +++ b/go/internal/middleware/chain_test.go @@ -1,8 +1,11 @@ package middleware import ( + "encoding/json" + "fmt" "net/http" "net/url" + "reflect" "strings" "testing" @@ -411,3 +414,96 @@ func TestMountSourceGate_DeniesTopLevelVolumes(t *testing.T) { t.Fatal("expected non-whitelisted top-level Volumes key to be denied") } } + +// TestChain_NetworkEndpoints: when the policy forces network_mode, the +// endpoint entries in NetworkingConfig.EndpointsConfig are made consistent +// with it (#87). Before #87 they went through as sent, so the Docker CLI's +// {"default": {}} made the start fail with "network default not found", and +// an extra entry attached the container to a network the policy does not +// allow. Rows mirror the runs in the network_endpoints module of +// spec/docker_socket_policy.qnt; mynet stands for a named network and +// container:beacon for container:x. +func TestChain_NetworkEndpoints(t *testing.T) { + const aliases = `{"Aliases":["a"]}` + ec := func(entries string) string { return `{"EndpointsConfig":{` + entries + `}}` } + extra := func(mode string) string { + return `network "other" is not allowed for service test; policy forces network_mode ` + mode + } + malformed := "NetworkingConfig.EndpointsConfig must be a JSON object; policy forces network_mode host" + cases := []struct { + row string + mode string // "" means the policy does not force network_mode + req string // NetworkingConfig as sent; "" means absent + want string // NetworkingConfig forwarded, when allowed + deny string // deny reason substring, when denied + }{ + {"hostNoEndpoints", "host", "", ec(""), ""}, + {"hostDefaultCleared", "host", ec(`"default":{}`), ec(""), ""}, + {"hostOwnCleared", "host", ec(`"host":` + aliases), ec(""), ""}, + {"hostExtraDenied", "host", ec(`"default":{},"other":{}`), "", extra("host")}, + {"noneNoEndpoints", "none", "", ec(""), ""}, + {"noneDefaultCleared", "none", ec(`"default":{}`), ec(""), ""}, + {"noneOwnCleared", "none", ec(`"none":` + aliases), ec(""), ""}, + {"noneExtraDenied", "none", ec(`"default":{},"other":{}`), "", extra("none")}, + {"containerNoEndpoints", "container:beacon", "", ec(""), ""}, + {"containerDefaultCleared", "container:beacon", ec(`"default":{}`), ec(""), ""}, + {"containerOwnCleared", "container:beacon", ec(`"container:beacon":` + aliases), ec(""), ""}, + {"containerExtraDenied", "container:beacon", ec(`"default":{},"other":{}`), "", extra("container:beacon")}, + {"bridgeNoEndpoints", "bridge", "", ec(`"bridge":{}`), ""}, + {"bridgeDefaultRekeyed", "bridge", ec(`"default":` + aliases), ec(`"bridge":` + aliases), ""}, + {"bridgeOwnKept", "bridge", ec(`"bridge":` + aliases), ec(`"bridge":` + aliases), ""}, + {"bridgeExtraDenied", "bridge", ec(`"default":{},"other":{}`), "", extra("bridge")}, + {"namedNoEndpoints", "mynet", "", ec(`"mynet":{}`), ""}, + {"namedDefaultRekeyed", "mynet", ec(`"default":` + aliases), ec(`"mynet":` + aliases), ""}, + {"namedOwnKept", "mynet", ec(`"mynet":` + aliases), ec(`"mynet":` + aliases), ""}, + {"namedExtraDenied", "mynet", ec(`"default":{},"other":{}`), "", extra("mynet")}, + {"namedOwnOverDefault", "mynet", ec(`"default":` + aliases + `,"mynet":{}`), ec(`"mynet":{}`), ""}, + // #87: a null NetworkingConfig or EndpointsConfig counts as absent. + {"#87 null NetworkingConfig", "host", "null", ec(""), ""}, + {"#87 null EndpointsConfig", "bridge", `{"EndpointsConfig":null}`, ec(`"bridge":{}`), ""}, + // #87: a NetworkingConfig or EndpointsConfig that is not an object is denied. + {"#87 EndpointsConfig array", "host", `{"EndpointsConfig":[]}`, "", malformed}, + {"#87 NetworkingConfig string", "host", `"default"`, "", malformed}, + // #87 control: without a forced network_mode, NetworkingConfig is untouched. + {"#87 no forced mode", "", ec(`"default":{},"other":{}`), ec(`"default":{},"other":{}`), ""}, + } + var mismatches []string + for _, tc := range cases { + p := testPolicy(nil) + p.ContainerConfig.NetworkMode = tc.mode + body := map[string]interface{}{"Image": "chainsafe/lodestar:next"} + if tc.req != "" { + var nc interface{} + if err := json.Unmarshal([]byte(tc.req), &nc); err != nil { + t.Fatalf("%s: bad request JSON: %v", tc.row, err) + } + body["NetworkingConfig"] = nc + } + result := NewChain().Execute(testRequest("POST", "/containers/create"), p, body) + if tc.deny != "" { + if result.Allowed || !strings.Contains(result.Reason, tc.deny) { + mismatches = append(mismatches, fmt.Sprintf("%s: got allowed=%v %q, want deny %q", tc.row, result.Allowed, result.Reason, tc.deny)) + } + continue + } + if !result.Allowed { + mismatches = append(mismatches, fmt.Sprintf("%s: got deny %q, want %s", tc.row, result.Reason, tc.want)) + continue + } + var out map[string]interface{} + if err := json.Unmarshal(result.ModifiedBody, &out); err != nil { + t.Fatalf("%s: bad modified body: %v", tc.row, err) + } + var want interface{} + if err := json.Unmarshal([]byte(tc.want), &want); err != nil { + t.Fatalf("%s: bad want JSON: %v", tc.row, err) + } + if got := out["NetworkingConfig"]; !reflect.DeepEqual(got, want) { + gotJSON, _ := json.Marshal(got) + mismatches = append(mismatches, fmt.Sprintf("%s: got %s, want %s", tc.row, gotJSON, tc.want)) + } + } + if len(mismatches) > 0 { + t.Fatalf("%d mismatches:\n%s", len(mismatches), strings.Join(mismatches, "\n")) + } +} diff --git a/rs/src/middleware.rs b/rs/src/middleware.rs index d0095dc..f440d0a 100644 --- a/rs/src/middleware.rs +++ b/rs/src/middleware.rs @@ -842,4 +842,223 @@ mod tests { })); assert!(gate.check("POST", "/containers/create", &policy, &body).is_ok()); } + + /// When the policy forces network_mode, the endpoint entries in + /// NetworkingConfig.EndpointsConfig are made consistent with it (#87). + /// Before #87 they went through as sent, so the Docker CLI's + /// {"default": {}} made the start fail with "network default not found", + /// and an extra entry attached the container to a network the policy does + /// not allow. Rows mirror the runs in the network_endpoints module of + /// spec/docker_socket_policy.qnt; mynet stands for a named network and + /// container:beacon for container:x. + #[test] + fn test_chain_network_endpoints() { + let aliases = r#"{"Aliases":["a"]}"#; + let ec = |entries: &str| format!(r#"{{"EndpointsConfig":{{{}}}}}"#, entries); + let extra = |mode: &str| { + format!( + r#"network "other" is not allowed for service test; policy forces network_mode {}"#, + mode + ) + }; + let malformed = "NetworkingConfig.EndpointsConfig must be a JSON object; policy forces network_mode host"; + let both = ec(r#""default":{},"other":{}"#); + // (row, forced mode ("" = none), NetworkingConfig sent ("" = absent), + // NetworkingConfig forwarded when allowed, deny reason when denied) + let cases: Vec<(&str, &str, String, String, String)> = vec![ + ("hostNoEndpoints", "host", String::new(), ec(""), String::new()), + ( + "hostDefaultCleared", + "host", + ec(r#""default":{}"#), + ec(""), + String::new(), + ), + ( + "hostOwnCleared", + "host", + ec(&format!(r#""host":{}"#, aliases)), + ec(""), + String::new(), + ), + ("hostExtraDenied", "host", both.clone(), String::new(), extra("host")), + ("noneNoEndpoints", "none", String::new(), ec(""), String::new()), + ( + "noneDefaultCleared", + "none", + ec(r#""default":{}"#), + ec(""), + String::new(), + ), + ( + "noneOwnCleared", + "none", + ec(&format!(r#""none":{}"#, aliases)), + ec(""), + String::new(), + ), + ("noneExtraDenied", "none", both.clone(), String::new(), extra("none")), + ( + "containerNoEndpoints", + "container:beacon", + String::new(), + ec(""), + String::new(), + ), + ( + "containerDefaultCleared", + "container:beacon", + ec(r#""default":{}"#), + ec(""), + String::new(), + ), + ( + "containerOwnCleared", + "container:beacon", + ec(&format!(r#""container:beacon":{}"#, aliases)), + ec(""), + String::new(), + ), + ( + "containerExtraDenied", + "container:beacon", + both.clone(), + String::new(), + extra("container:beacon"), + ), + ( + "bridgeNoEndpoints", + "bridge", + String::new(), + ec(r#""bridge":{}"#), + String::new(), + ), + ( + "bridgeDefaultRekeyed", + "bridge", + ec(&format!(r#""default":{}"#, aliases)), + ec(&format!(r#""bridge":{}"#, aliases)), + String::new(), + ), + ( + "bridgeOwnKept", + "bridge", + ec(&format!(r#""bridge":{}"#, aliases)), + ec(&format!(r#""bridge":{}"#, aliases)), + String::new(), + ), + ( + "bridgeExtraDenied", + "bridge", + both.clone(), + String::new(), + extra("bridge"), + ), + ( + "namedNoEndpoints", + "mynet", + String::new(), + ec(r#""mynet":{}"#), + String::new(), + ), + ( + "namedDefaultRekeyed", + "mynet", + ec(&format!(r#""default":{}"#, aliases)), + ec(&format!(r#""mynet":{}"#, aliases)), + String::new(), + ), + ( + "namedOwnKept", + "mynet", + ec(&format!(r#""mynet":{}"#, aliases)), + ec(&format!(r#""mynet":{}"#, aliases)), + String::new(), + ), + ("namedExtraDenied", "mynet", both.clone(), String::new(), extra("mynet")), + ( + "namedOwnOverDefault", + "mynet", + ec(&format!(r#""default":{},"mynet":{{}}"#, aliases)), + ec(r#""mynet":{}"#), + String::new(), + ), + // #87: a null NetworkingConfig or EndpointsConfig counts as absent. + ( + "#87 null NetworkingConfig", + "host", + "null".into(), + ec(""), + String::new(), + ), + ( + "#87 null EndpointsConfig", + "bridge", + r#"{"EndpointsConfig":null}"#.into(), + ec(r#""bridge":{}"#), + String::new(), + ), + // #87: a NetworkingConfig or EndpointsConfig that is not an object is denied. + ( + "#87 EndpointsConfig array", + "host", + r#"{"EndpointsConfig":[]}"#.into(), + String::new(), + malformed.into(), + ), + ( + "#87 NetworkingConfig string", + "host", + r#""default""#.into(), + String::new(), + malformed.into(), + ), + // #87 control: without a forced network_mode, NetworkingConfig is untouched. + ("#87 no forced mode", "", both.clone(), both.clone(), String::new()), + ]; + let chain = Chain::new(); + let mut mismatches = Vec::new(); + for (row, mode, req, want, deny) in cases { + let mut policy = make_policy("test", vec!["alpine"]); + policy.container_config = Some(ContainerConfig { + network_mode: if mode.is_empty() { None } else { Some(mode.into()) }, + restart_policy: Some("unless-stopped".into()), + security_options: None, + user: None, + log_driver: None, + log_options: None, + }); + let mut body = body_from_json(serde_json::json!({"Image": "alpine:3.20"})); + if !req.is_empty() { + body.insert("NetworkingConfig".into(), serde_json::from_str(&req).unwrap()); + } + let result = chain.execute("POST", "/containers/create", &policy, &body); + if !deny.is_empty() { + if result.allowed || !result.reason.contains(&deny) { + mismatches.push(format!( + "{}: got allowed={} {:?}, want deny {:?}", + row, result.allowed, result.reason, deny + )); + } + continue; + } + if !result.allowed { + mismatches.push(format!("{}: got deny {:?}, want {}", row, result.reason, want)); + continue; + } + let out: HashMap = + serde_json::from_slice(&result.modified_body.unwrap()).unwrap(); + let want_v: serde_json::Value = serde_json::from_str(&want).unwrap(); + let got = out.get("NetworkingConfig").cloned().unwrap_or(serde_json::Value::Null); + if got != want_v { + mismatches.push(format!("{}: got {}, want {}", row, got, want)); + } + } + assert!( + mismatches.is_empty(), + "{} mismatches:\n{}", + mismatches.len(), + mismatches.join("\n") + ); + } } diff --git a/ts/src/middleware.test.ts b/ts/src/middleware.test.ts index 841b31f..d2c691d 100644 --- a/ts/src/middleware.test.ts +++ b/ts/src/middleware.test.ts @@ -263,4 +263,79 @@ describe("Chain", () => { assert.ok(result.allowed); assert.ok(result.modifiedBody); }); + + // When the policy forces network_mode, the endpoint entries in + // NetworkingConfig.EndpointsConfig are made consistent with it (#87). Before + // #87 they went through as sent, so the Docker CLI's {"default": {}} made the + // start fail with "network default not found", and an extra entry attached + // the container to a network the policy does not allow. Rows mirror the runs + // in the network_endpoints module of spec/docker_socket_policy.qnt; mynet + // stands for a named network and container:beacon for container:x. + it("makes NetworkingConfig endpoints consistent with a forced network_mode", () => { + const aliases = { Aliases: ["a"] }; + const ec = (entries: Record) => ({ EndpointsConfig: entries }); + const extra = (mode: string) => + `network "other" is not allowed for service test-svc; policy forces network_mode ${mode}`; + const malformed = "NetworkingConfig.EndpointsConfig must be a JSON object; policy forces network_mode host"; + const both = ec({ default: {}, other: {} }); + // [row, forced mode ("" = none), NetworkingConfig sent (undefined = absent), + // NetworkingConfig forwarded when allowed, deny reason when denied] + const cases: [string, string, unknown, unknown, string | undefined][] = [ + ["hostNoEndpoints", "host", undefined, ec({}), undefined], + ["hostDefaultCleared", "host", ec({ default: {} }), ec({}), undefined], + ["hostOwnCleared", "host", ec({ host: aliases }), ec({}), undefined], + ["hostExtraDenied", "host", both, undefined, extra("host")], + ["noneNoEndpoints", "none", undefined, ec({}), undefined], + ["noneDefaultCleared", "none", ec({ default: {} }), ec({}), undefined], + ["noneOwnCleared", "none", ec({ none: aliases }), ec({}), undefined], + ["noneExtraDenied", "none", both, undefined, extra("none")], + ["containerNoEndpoints", "container:beacon", undefined, ec({}), undefined], + ["containerDefaultCleared", "container:beacon", ec({ default: {} }), ec({}), undefined], + ["containerOwnCleared", "container:beacon", ec({ "container:beacon": aliases }), ec({}), undefined], + ["containerExtraDenied", "container:beacon", both, undefined, extra("container:beacon")], + ["bridgeNoEndpoints", "bridge", undefined, ec({ bridge: {} }), undefined], + ["bridgeDefaultRekeyed", "bridge", ec({ default: aliases }), ec({ bridge: aliases }), undefined], + ["bridgeOwnKept", "bridge", ec({ bridge: aliases }), ec({ bridge: aliases }), undefined], + ["bridgeExtraDenied", "bridge", both, undefined, extra("bridge")], + ["namedNoEndpoints", "mynet", undefined, ec({ mynet: {} }), undefined], + ["namedDefaultRekeyed", "mynet", ec({ default: aliases }), ec({ mynet: aliases }), undefined], + ["namedOwnKept", "mynet", ec({ mynet: aliases }), ec({ mynet: aliases }), undefined], + ["namedExtraDenied", "mynet", both, undefined, extra("mynet")], + ["namedOwnOverDefault", "mynet", ec({ default: aliases, mynet: {} }), ec({ mynet: {} }), undefined], + // #87: a null NetworkingConfig or EndpointsConfig counts as absent. + ["#87 null NetworkingConfig", "host", null, ec({}), undefined], + ["#87 null EndpointsConfig", "bridge", { EndpointsConfig: null }, ec({ bridge: {} }), undefined], + // #87: a NetworkingConfig or EndpointsConfig that is not an object is denied. + ["#87 EndpointsConfig array", "host", { EndpointsConfig: [] }, undefined, malformed], + ["#87 NetworkingConfig string", "host", "default", undefined, malformed], + // #87 control: without a forced network_mode, NetworkingConfig is untouched. + ["#87 no forced mode", "", both, both, undefined], + ]; + const mismatches: string[] = []; + for (const [row, mode, req, want, deny] of cases) { + const policy = makePolicy({ + container_config: { network_mode: mode || undefined, restart_policy: "unless-stopped" }, + }); + const body: Record = { Image: "nginx:latest" }; + if (req !== undefined) body["NetworkingConfig"] = JSON.parse(JSON.stringify(req)); + const result = new Chain().execute("POST", "/containers/create", policy, body); + if (deny !== undefined) { + if (result.allowed || !result.reason.includes(deny)) { + mismatches.push(`${row}: got allowed=${result.allowed} ${result.reason}, want deny ${deny}`); + } + continue; + } + if (!result.allowed) { + mismatches.push(`${row}: got deny ${result.reason}, want ${JSON.stringify(want)}`); + continue; + } + const out = JSON.parse(result.modifiedBody!.toString()) as Record; + try { + assert.deepStrictEqual(out["NetworkingConfig"], want); + } catch { + mismatches.push(`${row}: got ${JSON.stringify(out["NetworkingConfig"])}, want ${JSON.stringify(want)}`); + } + } + assert.deepEqual(mismatches, []); + }); }); From 76b6a8d35362ed787a506dbd4491c4dee491a0e8 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 14 Oct 2026 08:43:23 -0400 Subject: [PATCH 3/4] fix: make NetworkingConfig endpoints match a forced network_mode (#87) When a policy sets container_config.network_mode, ContainerConfigMutator overwrote HostConfig.NetworkMode but forwarded NetworkingConfig.EndpointsConfig as sent. Without --network the Docker CLI sends {"default": {}}, so the create succeeded and the start failed with "network default not found". With a forced mode M, in Go, Rust and TS: - NetworkEndpointsGate denies an endpoint key other than "default" and M ("network X is not allowed for service S; policy forces network_mode M"), and a NetworkingConfig or EndpointsConfig that is not a JSON object. An extra entry would attach the container to a network the policy does not allow, so it is denied, not dropped. - ContainerConfigMutator sets EndpointsConfig to {} for host, none and container:, and to exactly one entry keyed M otherwise, keeping the settings of the caller's M entry, else its "default" entry. It leaves a request the gate denies unchanged, so the gate, which runs after it, sees what the caller sent. Without a forced network_mode, NetworkingConfig is untouched. Co-Authored-By: Claude Opus 5.5 --- go/internal/middleware/chain.go | 1 + go/internal/middleware/containerconfig.go | 1 + go/internal/middleware/networkendpoints.go | 99 +++++++++++++++++++ rs/src/middleware.rs | 106 +++++++++++++++++++++ ts/src/middleware.ts | 68 +++++++++++++ 5 files changed, 275 insertions(+) create mode 100644 go/internal/middleware/networkendpoints.go diff --git a/go/internal/middleware/chain.go b/go/internal/middleware/chain.go index d9bfda1..55f8378 100644 --- a/go/internal/middleware/chain.go +++ b/go/internal/middleware/chain.go @@ -33,6 +33,7 @@ func NewChain() *Chain { c.gates = append(c.gates, &MountSourceGate{}) c.gates = append(c.gates, &EnvFileGate{}) c.gates = append(c.gates, &CmdGate{}) + c.gates = append(c.gates, &NetworkEndpointsGate{}) c.mutators = append(c.mutators, &ContainerConfigMutator{}) diff --git a/go/internal/middleware/containerconfig.go b/go/internal/middleware/containerconfig.go index 3be17a8..e6e6a91 100644 --- a/go/internal/middleware/containerconfig.go +++ b/go/internal/middleware/containerconfig.go @@ -22,6 +22,7 @@ func (m *ContainerConfigMutator) Mutate(r *http.Request, p *policy.Policy, body if cc.NetworkMode != "" { hostConfig["NetworkMode"] = cc.NetworkMode + forceEndpoints(cc.NetworkMode, body) } if cc.RestartPolicy != "" { diff --git a/go/internal/middleware/networkendpoints.go b/go/internal/middleware/networkendpoints.go new file mode 100644 index 0000000..f60536f --- /dev/null +++ b/go/internal/middleware/networkendpoints.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "fmt" + "net/http" + "sort" + "strings" + + "github.com/ChainSafe/docker-socket-policy/go/internal/policy" +) + +// endpointsConfig returns NetworkingConfig.EndpointsConfig of a create body: +// nil when absent or null, ok=false when NetworkingConfig or EndpointsConfig +// is not an object. +func endpointsConfig(body map[string]interface{}) (eps map[string]interface{}, ok bool) { + nc, present := body["NetworkingConfig"] + if !present || nc == nil { + return nil, true + } + ncMap, isMap := nc.(map[string]interface{}) + if !isMap { + return nil, false + } + raw, present := ncMap["EndpointsConfig"] + if !present || raw == nil { + return nil, true + } + eps, isMap = raw.(map[string]interface{}) + return eps, isMap +} + +// detachedNetworkMode reports a network mode in which the container joins no +// network of its own. +func detachedNetworkMode(mode string) bool { + return mode == "host" || mode == "none" || strings.HasPrefix(mode, "container:") +} + +// NetworkEndpointsGate: when the policy forces network_mode M, every key in +// NetworkingConfig.EndpointsConfig must be "default" or M (#87). Any other key +// would attach the container to a network the policy does not allow. +// ContainerConfigMutator leaves such a request unchanged, so this gate sees +// what the caller sent. +type NetworkEndpointsGate struct{} + +func (g *NetworkEndpointsGate) Check(r *http.Request, p *policy.Policy, body map[string]interface{}) error { + mode := p.ContainerConfig.NetworkMode + if mode == "" { + return nil + } + eps, ok := endpointsConfig(body) + if !ok { + return fmt.Errorf("NetworkingConfig.EndpointsConfig must be a JSON object; policy forces network_mode %s", mode) + } + keys := make([]string, 0, len(eps)) + for k := range eps { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if k != "default" && k != mode { + return fmt.Errorf("network %q is not allowed for service %s; policy forces network_mode %s", k, p.ServiceName, mode) + } + } + return nil +} + +// forceEndpoints makes NetworkingConfig.EndpointsConfig consistent with the +// forced network mode (#87): no entries for host, none and container:; +// otherwise exactly one entry keyed by the mode, with the settings of the +// caller's entry for the mode, else of its "default" entry, else {}. A request +// that NetworkEndpointsGate denies is left unchanged. +func forceEndpoints(mode string, body map[string]interface{}) { + eps, ok := endpointsConfig(body) + if !ok { + return + } + for k := range eps { + if k != "default" && k != mode { + return + } + } + forced := map[string]interface{}{} + if !detachedNetworkMode(mode) { + entry, present := eps[mode] + if !present { + entry, present = eps["default"] + } + if !present { + entry = map[string]interface{}{} + } + forced[mode] = entry + } + nc, isMap := body["NetworkingConfig"].(map[string]interface{}) + if !isMap { + nc = map[string]interface{}{} + body["NetworkingConfig"] = nc + } + nc["EndpointsConfig"] = forced +} diff --git a/rs/src/middleware.rs b/rs/src/middleware.rs index f440d0a..9bbefec 100644 --- a/rs/src/middleware.rs +++ b/rs/src/middleware.rs @@ -38,6 +38,7 @@ impl Chain { gates.push(Box::new(MountSourceGate)); gates.push(Box::new(EnvFileGate)); gates.push(Box::new(CmdGate)); + gates.push(Box::new(NetworkEndpointsGate)); let mutators: Vec> = vec![Box::new(ContainerConfigMutator)]; @@ -294,6 +295,108 @@ impl Gate for CmdGate { } } +/// The network mode the policy forces, if any (#87). +fn forced_network_mode(policy: &Policy) -> Option<&str> { + policy + .container_config + .as_ref() + .and_then(|cc| cc.network_mode.as_deref()) + .filter(|m| !m.is_empty()) +} + +/// NetworkingConfig.EndpointsConfig of a create body: Ok(None) when absent +/// or null, Err when NetworkingConfig or EndpointsConfig is not an object. +fn endpoints_config( + body: &HashMap, +) -> std::result::Result>, ()> { + match body.get("NetworkingConfig") { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Object(nc)) => match nc.get("EndpointsConfig") { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Object(eps)) => Ok(Some(eps)), + Some(_) => Err(()), + }, + Some(_) => Err(()), + } +} + +/// A network mode in which the container joins no network of its own. +fn detached_network_mode(mode: &str) -> bool { + mode == "host" || mode == "none" || mode.starts_with("container:") +} + +/// When the policy forces network_mode M, every key in +/// NetworkingConfig.EndpointsConfig must be "default" or M (#87). Any other +/// key would attach the container to a network the policy does not allow. +/// ContainerConfigMutator leaves such a request unchanged, so this gate +/// sees what the caller sent. +pub struct NetworkEndpointsGate; +impl Gate for NetworkEndpointsGate { + fn check( + &self, + _method: &str, + _path: &str, + policy: &Policy, + body: &HashMap, + ) -> std::result::Result<(), String> { + let mode = match forced_network_mode(policy) { + Some(m) => m, + None => return Ok(()), + }; + let eps = match endpoints_config(body) { + Ok(Some(eps)) => eps, + Ok(None) => return Ok(()), + Err(()) => { + return Err(format!( + "NetworkingConfig.EndpointsConfig must be a JSON object; policy forces network_mode {}", + mode + )) + } + }; + if let Some(k) = eps.keys().find(|k| k.as_str() != "default" && k.as_str() != mode) { + return Err(format!( + "network {:?} is not allowed for service {}; policy forces network_mode {}", + k, policy.service_name, mode + )); + } + Ok(()) + } +} + +/// Make NetworkingConfig.EndpointsConfig consistent with the forced network +/// mode (#87): no entries for host, none and container:; otherwise +/// exactly one entry keyed by the mode, with the settings of the caller's +/// entry for the mode, else of its "default" entry, else {}. A request that +/// NetworkEndpointsGate denies is left unchanged. +fn force_endpoints(mode: &str, body: &mut HashMap) { + let eps = match endpoints_config(body) { + Ok(Some(eps)) => eps.clone(), + Ok(None) => serde_json::Map::new(), + Err(()) => return, + }; + if eps.keys().any(|k| k.as_str() != "default" && k.as_str() != mode) { + return; + } + let mut forced = serde_json::Map::new(); + if !detached_network_mode(mode) { + let entry = eps + .get(mode) + .or_else(|| eps.get("default")) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + forced.insert(mode.to_string(), entry); + } + let nc = body + .entry("NetworkingConfig".to_string()) + .or_insert_with(|| serde_json::json!({})); + if !nc.is_object() { + *nc = serde_json::json!({}); + } + if let Some(nc) = nc.as_object_mut() { + nc.insert("EndpointsConfig".to_string(), serde_json::Value::Object(forced)); + } +} + pub struct ContainerConfigMutator; impl Mutator for ContainerConfigMutator { fn mutate(&self, policy: &Policy, body: &mut HashMap) { @@ -324,6 +427,9 @@ impl Mutator for ContainerConfigMutator { if let Some(u) = &cc.user { body.insert("User".to_string(), serde_json::Value::String(u.clone())); } + if let Some(mode) = forced_network_mode(policy) { + force_endpoints(mode, body); + } } } diff --git a/ts/src/middleware.ts b/ts/src/middleware.ts index 836ea07..3a7da45 100644 --- a/ts/src/middleware.ts +++ b/ts/src/middleware.ts @@ -20,6 +20,7 @@ export class Chain { this.gates.push(new MountSourceGate()); this.gates.push(new EnvFileGate()); this.gates.push(new CmdGate()); + this.gates.push(new NetworkEndpointsGate()); } execute( @@ -181,6 +182,72 @@ export class CmdGate implements Gate { } } +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** NetworkingConfig.EndpointsConfig of a create body: null when absent or null, "malformed" when not an object. */ +function endpointsConfig(body: Record): Record | null | "malformed" { + const nc = body["NetworkingConfig"]; + if (nc === undefined || nc === null) return null; + if (!isObject(nc)) return "malformed"; + const eps = nc["EndpointsConfig"]; + if (eps === undefined || eps === null) return null; + return isObject(eps) ? eps : "malformed"; +} + +/** A network mode in which the container joins no network of its own. */ +function detachedNetworkMode(mode: string): boolean { + return mode === "host" || mode === "none" || mode.startsWith("container:"); +} + +/** + * When the policy forces network_mode M, every key in + * NetworkingConfig.EndpointsConfig must be "default" or M (#87). Any other key + * would attach the container to a network the policy does not allow. + * ContainerConfigMutator leaves such a request unchanged, so this gate sees + * what the caller sent. + */ +export class NetworkEndpointsGate implements Gate { + check(_method: string, _path: string, policy: Policy, body: Record): string | null { + const mode = policy.container_config?.network_mode; + if (!mode) return null; + const eps = endpointsConfig(body); + if (eps === null) return null; + if (eps === "malformed") { + return `NetworkingConfig.EndpointsConfig must be a JSON object; policy forces network_mode ${mode}`; + } + const extra = Object.keys(eps) + .sort() + .find((k) => k !== "default" && k !== mode); + if (extra !== undefined) { + return `network "${extra}" is not allowed for service ${policy.service_name}; policy forces network_mode ${mode}`; + } + return null; + } +} + +/** + * Make NetworkingConfig.EndpointsConfig consistent with the forced network + * mode (#87): no entries for host, none and container:; otherwise exactly + * one entry keyed by the mode, with the settings of the caller's entry for the + * mode, else of its "default" entry, else {}. A request that + * NetworkEndpointsGate denies is left unchanged. + */ +function forceEndpoints(mode: string, body: Record): void { + const sent = endpointsConfig(body); + if (sent === "malformed") return; + const eps = sent ?? {}; + if (Object.keys(eps).some((k) => k !== "default" && k !== mode)) return; + const forced: Record = {}; + if (!detachedNetworkMode(mode)) { + forced[mode] = Object.hasOwn(eps, mode) ? eps[mode] : Object.hasOwn(eps, "default") ? eps["default"] : {}; + } + const nc = isObject(body["NetworkingConfig"]) ? body["NetworkingConfig"] : {}; + nc["EndpointsConfig"] = forced; + body["NetworkingConfig"] = nc; +} + export class ContainerConfigMutator implements Mutator { mutate(policy: Policy, body: Record): void { const cc = policy.container_config; @@ -197,5 +264,6 @@ export class ContainerConfigMutator implements Mutator { if (cc.log_driver) { hostConfig["LogConfig"] = { Type: cc.log_driver, Config: cc.log_options ?? {} }; } + if (cc.network_mode) forceEndpoints(cc.network_mode, body); } } From 61003bf27be6c3cf0ffb52ebdf7534227319a977 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 14 Oct 2026 08:44:45 -0400 Subject: [PATCH 4/4] docs: document NetworkingConfig endpoints under a forced network_mode (#87) README middleware table and prose, formal-verification note and counts; AGENTS.md architecture line, test-spec instances and test counts. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 16 ++++++++-------- README.md | 15 ++++++++++----- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 60b5a47..ef6a44b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,13 +21,13 @@ deploy/ — Docker Compose + integration tests - `make fmt-all` / `make fmt-go` / `make fmt-rs` / `make fmt-ts` — apply gofmt / rustfmt / prettier (each `lint-*` target checks formatting) - `make hooks` — enable `.githooks` (pre-commit format check, pre-push `make lint-all`) - `make verify` — Quint spec simulation (request handling + listener) -- `make test-spec` — Quint `run` tests for `spec/listener.qnt` and `spec/router.qnt` +- `make test-spec` — Quint `run` tests for `spec/listener.qnt`, `spec/router.qnt` and the network endpoints module of `spec/docker_socket_policy.qnt` - `make test-integration` — Docker Compose integration tests - `make test-integration-sock` — listening-socket integration tests (`IMPL=rs|ts` for the others) ## Architecture (same across all 3 languages) - `policy/` — Policy types + Manager (loads YAML from config dir) -- `middleware/` — Gate trait + Chain: RegistryGate, MountSourceGate, EnvFileGate, CmdGate, ContainerConfigMutator (`--readonly` and exec are denied by the router) +- `middleware/` — Gate trait + Chain: RegistryGate, MountSourceGate, EnvFileGate, CmdGate, NetworkEndpointsGate, ContainerConfigMutator (`--readonly` and exec are denied by the router) - `proxy/` — Router (default-deny), Handler (orchestrates chain), Transport (forwards to Docker socket) - `audit/` — JSON-structured logging @@ -39,17 +39,17 @@ deploy/ — Docker Compose + integration tests - Zero external deps where possible (Go: yaml.v3, Rust: tokio/hyper/serde/clap, TS: yaml) ## Test Coverage -- Go: 113 unit tests (main/listener: 24, policy: 10, middleware: 25, proxy: 50, audit: 4) -- Rust: 145 unit tests (main/listener: 23, policy: 15, middleware: 41, proxy: 49, handler: 11, audit: 4, transport: 2) -- TypeScript: 164 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 32, proxy: 36, policy: 10, handler: 15, shutdown: 5, transport: 5, audit: 4) -- Integration, per implementation: 63 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose) -- Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests (instances `listener_locked`, `listener_unlocked`) and the `spec/router.qnt` `run` tests (instances `router`, `router_pre48`, `router_pre53`, `router_pre55`, `router_readonly`, whose `readonly*` tests run with `--match`, `router_pre76`, `router_pre78` and `router_pre70`) +- Go: 114 unit tests (main/listener: 24, policy: 10, middleware: 26, proxy: 50, audit: 4) +- Rust: 146 unit tests (main/listener: 23, policy: 15, middleware: 42, proxy: 49, handler: 11, audit: 4, transport: 2) +- TypeScript: 165 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 33, proxy: 36, policy: 10, handler: 15, shutdown: 5, transport: 5, audit: 4) +- Integration, per implementation: 72 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose) +- Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests (instances `listener_locked`, `listener_unlocked`) and the `spec/router.qnt` `run` tests (instances `router`, `router_pre48`, `router_pre53`, `router_pre55`, `router_readonly`, whose `readonly*` tests run with `--match`, `router_pre76`, `router_pre78` and `router_pre70`), and the `spec/docker_socket_policy.qnt` `run` tests (instances `network_endpoints`, `network_endpoints_pre87`) ## Test Conventions - Go: stdlib `testing` package, `go test ./...` - Rust: `#[cfg(test)]` inline modules, `cargo test` - TypeScript: `node:test` framework, `npm run build && node --test dist/*.test.js` -- Integration: `make test-integration` (63 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose +- Integration: `make test-integration` (72 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose ## Contribution Workflow diff --git a/README.md b/README.md index 6102aa7..7a82e07 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,9 @@ All three implementations expose the same API surface, share the same [Quint spe | Language | Directory | Tests | Stack | |----------|-----------|-------|-------| -| Go | [go/](go/) | 113 unit + 63 integration + 15 socket | stdlib net/http + yaml.v3 | -| Rust | [rs/](rs/) | 145 unit + 63 integration + 15 socket | tokio, hyper, serde, clap | -| TypeScript | [ts/](ts/) | 164 unit (1 skipped) + 63 integration + 15 socket | Node 22 ESM, built-in http | +| Go | [go/](go/) | 114 unit + 72 integration + 15 socket | stdlib net/http + yaml.v3 | +| Rust | [rs/](rs/) | 146 unit + 72 integration + 15 socket | tokio, hyper, serde, clap | +| TypeScript | [ts/](ts/) | 165 unit (1 skipped) + 72 integration + 15 socket | Node 22 ESM, built-in http | ### Build All @@ -192,11 +192,14 @@ docker pull attacker/malware:latest # denied: image not in allowlist | Middleware | Type | What it checks | |------------|------|----------------| -| ContainerConfigMutator | Mutator | Forces `network_mode`, `user`, `security_options`, `restart_policy` from policy | +| ContainerConfigMutator | Mutator | Forces `network_mode`, `user`, `security_options`, `restart_policy` from policy, and makes `NetworkingConfig.EndpointsConfig` match a forced `network_mode` | | RegistryGate | Gate | Validates image ref against `allowed_image_prefixes` | | MountSourceGate | Gate | Validates volume binds against whitelist | | EnvFileGate | Gate | Strips `Env` field from create body; env must come from locked `env_file` | | CmdGate | Gate | Validates each CLI flag in `Cmd` array against allowlist + denylist | +| NetworkEndpointsGate | Gate | With a forced `network_mode`, denies `NetworkingConfig.EndpointsConfig` entries for other networks | + +When a policy forces `network_mode` M, the endpoint entries in `NetworkingConfig.EndpointsConfig` must agree with it, or the daemon creates the container and then refuses to start it with `network default not found`: without `--network`, the Docker CLI sends an entry for `default` ([#87](https://github.com/ChainSafe/docker-socket-policy/issues/87)). Entries may be keyed `default` or M; any other key is denied with `network "" is not allowed for service ; policy forces network_mode `, since it would attach the container to a network the policy does not allow. For `host`, `none` and `container:` the proxy then forwards an empty `EndpointsConfig`; for `bridge` or a named network it forwards exactly one entry, keyed M, with the settings (aliases, for example) of the caller's M entry, else of its `default` entry. A `NetworkingConfig` that is not a JSON object is denied. Without a forced `network_mode`, `NetworkingConfig` is forwarded as sent. The chain runs only on container create. `--readonly` is not a gate: the router denies every request that is not `GET` or `HEAD`, on every path, before any other check ([#75](https://github.com/ChainSafe/docker-socket-policy/issues/75)). Attach is denied by the router for every method, so a websocket attach (`GET /containers/{name}/attach/ws`) cannot reach a container's stdin, with or without `--readonly` ([#76](https://github.com/ChainSafe/docker-socket-policy/issues/76)). @@ -451,13 +454,15 @@ not at the socket. This project includes a [Quint](https://quint-lang.org/) formal specification that models the security invariants as a state machine. Random-simulation verification runs 10,000 sampled traces of up to 100 steps each, checking all 9 invariants on every state transition. A second module, `spec/listener.qnt`, models listening-socket startup (group selection, existing-path checks, the single-instance lock) with 6 more invariants. A third module, `spec/router.qnt`, models the router's denies for writes under `--readonly` ([#75](https://github.com/ChainSafe/docker-socket-policy/issues/75)), percent-encoded paths ([#53](https://github.com/ChainSafe/docker-socket-policy/issues/53)), empty interior segments ([#55](https://github.com/ChainSafe/docker-socket-policy/issues/55)), dot segments ([#70](https://github.com/ChainSafe/docker-socket-policy/issues/70)), exec ([#49](https://github.com/ChainSafe/docker-socket-policy/issues/49)) and attach ([#76](https://github.com/ChainSafe/docker-socket-policy/issues/76)), the GET/HEAD-only read passthrough ([#78](https://github.com/ChainSafe/docker-socket-policy/issues/78)), the `POST /containers/create` route, container-name extraction and the container-lifecycle branch, not the full routing table ([#24](https://github.com/ChainSafe/docker-socket-policy/issues/24), [#48](https://github.com/ChainSafe/docker-socket-policy/issues/48)). Its 12 properties are checked exhaustively over the methods `GET`, `HEAD`, `POST`, `DELETE` and every path of 1 to 3 segments over the model's word set; exec is denied by the router, not by a middleware gate. +The request-handling spec also has a module, `network_endpoints_model`, for the endpoint entries under a forced `network_mode` ([#87](https://github.com/ChainSafe/docker-socket-policy/issues/87)). Its 3 properties (extra networks are denied, an allowed create names no network but the forced one, and every allowed create starts) are checked over five network modes and every endpoint map over seven keys; `network_endpoints_pre87` shows all three failing before the fix. + The CI pipeline runs verification on every push and PR. A violation blocks the build. ```bash make typecheck # Quint type-check (proves type safety) make verify # Random-simulation verification (default evaluator) make verify BACKEND=rust # Same, using the faster Rust backend -make test-spec # Quint `run` tests for listener.qnt and router.qnt (one per table row) +make test-spec # Quint `run` tests for listener.qnt, router.qnt and network_endpoints (one per table row) make validate # All checks: typecheck + verify + go vet + go test ```