Skip to content

fix!: default-deny HostConfig on create; check Mounts and read-only binds (#88) - #92

Merged
abienkowski merged 5 commits into
mainfrom
fix/hostconfig-default-deny
Oct 14, 2026
Merged

abienkowski merged 5 commits into
mainfrom
fix/hostconfig-default-deny

Conversation

@abienkowski

Copy link
Copy Markdown
Collaborator

Description

Closes #88.

On POST /containers/create the proxy checked only the host paths in HostConfig.Binds (and the top-level Volumes keys). HostConfig.Mounts, the bind mode and every other HostConfig field reached the daemon unchanged, so --mount type=bind,src=/,dst=/host, -v /etc/jwt_dir:/jwtdir:rw on a read_write: false volume, --cap-add SYS_ADMIN --pid host, --device and --group-add all went through. A policy without volumes let any bind through, and without a container_config the mutator returned early and did not force Privileged: false.

While testing, one more gap showed up: the daemon matches JSON field names without regard to case and keeps the last match. {"HostConfig":{"privileged":true}} produced a privileged container on Docker 29 even before this change (the mutator wrote "Privileged": false, and Go and Rust serialise privileged after it), and "securityopt": null cleared a forced SecurityOpt. The same applies at the top level ("image", "user", "hostConfig"): on main, {"Image":"chainsafe/lodestar:beacon","image":"alpine:latest"} passes the router and RegistryGate, which read Image, and the Go proxy's re-serialised body puts image last, so the daemon created an alpine:latest container (201 in the pre-fix integration run) and inside NetworkingConfig: with #87 merged, {"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}} passes NetworkEndpointsGate, which reads the exact key only, and the daemon attaches evil-net (checked on Docker 29: NetworkSettings.Networks lists it).

What changes

  • HostConfigGate (new, all three): default-deny on HostConfig. A key outside the allowlist must be absent or zero (null, false, 0, "", [], {}); MaskedPaths/ReadonlyPaths must be absent or null. Any-value fields: NetworkMode, RestartPolicy, LogConfig, CapDrop, ReadonlyRootfs, Init, AutoRemove, ConsoleSize, ShmSize, PortBindings, PublishAllPorts, Dns*, ExtraHosts, Tmpfs, Ulimits, the CPU/memory/pids/blkio-weight limits, ContainerIDFile. SecurityOpt entries must be in the policy's security_options or be no-new-privileges[:true]. PidMode, UTSMode, UsernsMode, Cgroup must be ""; IpcMode ∈ {"", private, shareable, none}; CgroupnsMode ∈ {"", private}; Isolation ∈ {"", default}; OomScoreAdj ≥ 0. The gate runs after the mutator, on the body that is forwarded, so Privileged: true stays neutralised (as documented and integration-tested) rather than denied.
  • Case variants: a key that folds (ASCII case, Kelvin sign, long s, as Go's encoding/json does) to a known field name but is spelled differently is denied, at the top level, in HostConfig, in Mounts entries and BindOptions, and in NetworkingConfig (whether or not the policy forces network_mode). The other nested objects need no check: the mutator replaces RestartPolicy and LogConfig whole when the policy sets them and they are any-value otherwise; no gate reads Healthcheck or an endpoint entry's settings by key.
  • MountSourceGate: no volumes means no binds. Binds options must be ro, rw, z, Z, nocopy, private, rprivate; a read_write: false volume needs ro (and not rw). Mounts: bind with Source equal to a policy volume, ReadOnly: true for read-only volumes, Propagation empty/private/rprivate, ReadOnlyNonRecursive zero; tmpfs without a Source; every other type denied.
  • ContainerConfigMutator: forces Privileged: false on every create; leaves a non-object HostConfig for the gate (Rust panicked on one, TypeScript threw). Rust now applies log_driver/log_options, as Go and TypeScript already did.
  • Spec (spec/docker_socket_policy.qnt, next to Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87's network_endpoints_model): module create_model with flag LEGACY_CREATE, rule property createChecksEveryField, security properties daemonSeesOnlyAllowlistedHostConfig, daemonMountsOnlyWhitelisted and daemonAttachesOnlyForcedNetwork (checked exhaustively over every single-field/bind/mount request of the model and the NetworkingConfig variants), 43 rows plus createSoundTest on create, and create_pre88, which witnesses the reproductions and the endpointsconfig gap. The state machine uses the create rules, with two new P0 invariants (hostConfigInAllowlist, hostMountsInWhitelist).
  • Tests: the same 131 rows, by name, in Go, Rust and TypeScript (including the full Docker CLI 29 create body and a community.docker.docker_container-shaped body), and 17 integration checks against the real daemon. Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87's real-container starts (net-host, net-none) pass through the new gate unchanged.
  • Docs: README HostConfig rules table, read_write enforcement, pipeline; spec/README.md; AGENTS.md and CONTRIBUTING.md counts.

Review notes

  • Release: merging cuts v0.4.0 (breaking, below 1.0 = minor). The BREAKING CHANGE: footer is in the fix!: branch commit itself, so the default squash carries it. Simulated with scripts/release-version.sh v0.3.11 over the branch messages: bump=minor tag=v0.4.0; the same with a plain fix: squash title.
  • Breaking: creates that set a non-allowlisted field, a volume/image mount, a shared/slave propagation, a read-only volume without :ro, or any bind under a policy without volumes are now 403. --runtime, --gpus, --sysctl, --device*, --group-add, --cap-add, --link, --volumes-from, --security-opt systempaths=unconfined and the host namespaces are denied.
  • make verify BACKEND=typescript on the main spec took 737 s locally (14 traces/s) with the create rules in the state machine; worth watching in CI.
  • Order with fix: route image pulls on the query string and normalise image references in all three (#83, #84) #91: the router picks the policy from Body["Image"] and RegistryGate parses that same key; a lone image (no Image) is denied by the router with image field is required, and Image plus a variant passes the router and is denied by HostConfigGate in the chain, so no create with a variant is forwarded whichever gate runs first.
  • Follow-up drafted (not filed): settings inside NetworkingConfig endpoint entries (Links, DriverOpts, IPAMConfig, Aliases) are forwarded as sent under a forced bridge/named network_mode or with no forced mode, so endpoint Links passes while HostConfig.Links is denied.
  • Go keeps its escaped-colon handling in Binds (\:); Rust and TypeScript split on : as the daemon does. The daemon rejects every spec where the two readings differ in the mode, so no row depends on it.
  • The policy's ports field is parsed but enforced nowhere, so PortBindings stays any-value (documented).
  • Not changed here: when a policy does not force network_mode, NetworkMode: host / container:<id> and any EndpointsConfig still pass (any-value, per Container create: default-deny HostConfig, validate Mounts and the bind read-only mode #88 and Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87); the settings inside an endpoint entry (Links, IPAMConfig, DriverOpts, …) are forwarded as sent under a forced mode, as Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87 does.
  • Integration: the read-only Mounts allow check accepts the daemon's 400 (bind source path does not exist): Mounts binds are not auto-created like Binds, and /etc/jwt_dir does not exist on the test daemon's host.

Verification

  • Spec: create passes createSoundTest and 43 rows; create_pre88 asserts the three security properties fail and that the Container create: default-deny HostConfig, validate Mounts and the bind read-only mode #88 reproductions and the endpointsconfig variant were allowed. Mutations: dropping the HostConfig case-variant check fails caseVariantZeroSecurityOptDenied and createSoundTest; dropping the read-only clause for Mounts fails mountBindReadOnlyVolumeWritableDenied and createSoundTest; dropping the NetworkingConfig variant check fails both networkingConfigVariant* rows and createSoundTest.
  • Before the fix (test commit on top of main with Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87 and fix: route image pulls on the query string and normalise image references in all three (#83, #84) #91): 73 rows fail in Go, 74 in Rust and TypeScript (the extra one is the old Rust/TS bind message), plus the two Rust and one TypeScript tests that pinned the old behaviour; the Rust row test panics on "HostConfig": "x" (as_object_mut().unwrap()) and TypeScript throws Cannot create property 'NetworkMode' on string 'x'. Integration on the pre-fix Go proxy:
    FAIL: create with Mounts bind of / -> 403 (expected 403 with "mount source "/" is not in the whitelist", got 404: {"message":"No such image: chainsafe/lodestar:beacon"})
    FAIL: create with read-only volume bound :rw -> 403 (expected 403 with "volume mount "/etc/jwt_dir" must be read-only (:ro)", got 404: ...)
    FAIL: create with CapAdd -> 403 (expected 403 with "HostConfig.CapAdd is not allowed for service beacon", got 404: ...)
    FAIL: create with PidMode host -> 403 (expected 403 with "HostConfig.PidMode value "host" is not allowed", got 404: ...)
    FAIL: create with lower-case privileged -> 403 (expected 403 with "HostConfig.privileged is not allowed", got 404: ...)
    FAIL: create with an extra lower-case image -> 403 (expected 403 with "image is not allowed for service beacon (the daemon reads it as Image)", got 201: {"Id":"c4393c1b…","Warnings":[]})
    FAIL: create with lower-case endpointsconfig -> 403 (expected 403 with "NetworkingConfig.endpointsconfig is not allowed", got 404: ...)
    80 PASSED, 14 FAILED
    
  • This branch (rebased on Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87 and fix: route image pulls on the query string and normalise image references in all three (#83, #84) #91): make test-all Go 119 (131 subtests in TestHostConfigRows), Rust 146, TS 300 (1 skipped); make lint-all, make build-all, make typecheck, make test-spec, make verify BACKEND=typescript, make test-release pass; integration ALL 94 TESTS PASSED for Go, Rust and TS.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Implementation(s) changed

  • Go
  • Rust
  • TypeScript
  • Quint specification
  • CI / infrastructure

Testing

  • Unit tests pass (make test-all)
  • Integration tests pass (94 per implementation)
  • Quint verification passes (make verify, make test-spec)
  • New tests added for the change

Checklist

  • I have read CONTRIBUTING.md
  • My code follows the project's coding style
  • I have updated documentation as needed

🤖 Generated with Claude Code

abienkowski and others added 4 commits October 14, 2026 10:32
Add module create_model to spec/docker_socket_policy.qnt: the mutator,
MountSourceGate and HostConfigGate on a create request's HostConfig.
LEGACY_CREATE models all three before #88 (only Binds host paths were
checked, and not at all without volumes; Privileged was not forced
without a container_config).

- Rule property createChecksEveryField; security properties
  daemonAttachesOnlyForcedNetwork (NetworkingConfig read under any
  spelling of EndpointsConfig attaches only the forced network),
  daemonSeesOnlyAllowlistedHostConfig (what the daemon reads, under
  canonical field names, is allowlisted and keeps every forced value) and
  daemonMountsOnlyWhitelisted (every host mount is a policy volume,
  read-only when the volume is, never propagating).
- Instance create runs the table rows and createSoundTest;
  create_pre88 runs pre88UnsoundTest, which witnesses the #88
  reproductions.
- The state machine uses the create rules: createContainer takes the
  HostConfig, and the P0 invariants hostConfigInAllowlist and
  hostMountsInWhitelist check what the daemon read. Policy gains
  readOnlyVolumes; the TOOLS policy has no volumes and no
  container_config.
- make test-spec runs both instances.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same 131 rows, by name, in Go (TestHostConfigRows), Rust
(test_host_config_rows) and TypeScript: every must-be-zero field with a
non-zero value, every any-value field, zero values, the namespace modes,
SecurityOpt, MaskedPaths [], case variants of field names (top level,
HostConfig, Mounts, NetworkingConfig), Mounts types, sources, ReadOnly
and propagation, bind options and read-only volumes, Privileged forced
false with an empty container_config, and the bodies Docker CLI 29 and
community.docker.docker_container send.

The Rust and TypeScript tests that pinned "no volumes allows any bind" and
"no container_config leaves the body alone" now pin the opposite.

deploy/test.sh gains 17 checks against the real daemon: the #88
reproductions (Mounts bind of /, the read-only volume bound rw or with no
mode, CapAdd, PidMode host, Devices, GroupAdd), MaskedPaths [], an
rshared bind, a volume mount, a lower-case privileged, an extra lower-case
image next to Image (before the fix the daemon ran it: 201), a lower-case
endpointsconfig next to EndpointsConfig (NetworkEndpointsGate reads only
the exact key), and creates that must still pass (read-only binds and
mounts, the Docker CLI 29 body).

On the pre-fix code (main with #87 and #91), 73 rows fail in Go, 74 in Rust and
TypeScript (the extra one is the old Rust/TS bind message without "for
service"); the Rust mutator panics on a non-object HostConfig and the
TypeScript one throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inds (#88)

On POST /containers/create only the host paths of HostConfig.Binds were
checked. Mounts, bind modes and every other HostConfig field reached the
daemon unchanged, so a caller could bind / with --mount, write to a
read_write: false volume, or add capabilities, devices, groups and host
namespaces. In all three:

- HostConfigGate (new, after EnvFileGate, on the mutated body): a key
  outside the allowlist must be absent or zero; MaskedPaths and
  ReadonlyPaths must be absent or null. NetworkMode, RestartPolicy,
  LogConfig, CapDrop, resource limits and similar take any value;
  SecurityOpt entries must be in the policy's security_options or be
  no-new-privileges[:true]; PidMode, UTSMode, UsernsMode, Cgroup must be
  ""; IpcMode, CgroupnsMode, Isolation have short allowlists;
  OomScoreAdj must be >= 0.
- The daemon matches JSON field names without regard to case and keeps
  the last match, so "privileged": true overrode the forced
  "Privileged": false (also before this change). A key that folds to a
  known field name but is spelled differently is denied, at the top
  level, in HostConfig, in Mounts entries and BindOptions, and in
  NetworkingConfig (NetworkEndpointsGate, #87, reads EndpointsConfig
  only; the daemon would attach the networks under endpointsconfig too).
- MountSourceGate: a policy with no volumes allows no binds; Binds
  options must be ro, rw, z, Z, nocopy, private or rprivate, and a
  read_write: false volume needs ro; Mounts entries must be bind (Source a
  policy volume, ReadOnly for read-only volumes, private propagation) or
  tmpfs without a Source.
- ContainerConfigMutator forces Privileged: false on every create, also
  without a container_config, and leaves a non-object HostConfig for the
  gate to deny (Rust used to panic on it, TypeScript to throw). Rust now
  applies log_driver/log_options like Go and TypeScript.

BREAKING CHANGE: creates that set a HostConfig field outside the
allowlist, use Mounts other than allowed bind and tmpfs mounts, bind a
read_write: false volume without :ro, use shared/slave propagation, or
bind anything under a policy without volumes are now denied with 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README: a HostConfig rules table, read_write enforcement in the policy
schema, HostConfigGate in the pipeline, the trust model and the formal
verification summary. spec/README.md: the create model, its rows and
instances, the new P0 invariants and attack scenarios. AGENTS.md and
CONTRIBUTING.md: the new gate and test counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abienkowski abienkowski added Status: Break Change Added to a PR or issue that would cause a breaking change Type: Bug Added to issues and PRs if they are addressing a bug labels Oct 14, 2026
The CI runner's daemon supports API 1.48 at most, so /v1.52/ got a 400
before the proxy's decision mattered. The body is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abienkowski
abienkowski merged commit fd66e22 into main Oct 14, 2026
7 checks passed
@abienkowski
abienkowski deleted the fix/hostconfig-default-deny branch October 14, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Break Change Added to a PR or issue that would cause a breaking change Type: Bug Added to issues and PRs if they are addressing a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Container create: default-deny HostConfig, validate Mounts and the bind read-only mode

1 participant