Repository navigation
fix!: default-deny HostConfig on create; check Mounts and read-only binds (#88) - #92
Merged
Merged
Conversation
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>
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>
15 of 16 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Closes #88.
On
POST /containers/createthe proxy checked only the host paths inHostConfig.Binds(and the top-levelVolumeskeys).HostConfig.Mounts, the bind mode and every otherHostConfigfield reached the daemon unchanged, so--mount type=bind,src=/,dst=/host,-v /etc/jwt_dir:/jwtdir:rwon aread_write: falsevolume,--cap-add SYS_ADMIN --pid host,--deviceand--group-addall went through. A policy withoutvolumeslet any bind through, and without acontainer_configthe mutator returned early and did not forcePrivileged: 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 serialiseprivilegedafter it), and"securityopt": nullcleared a forcedSecurityOpt. 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 readImage, and the Go proxy's re-serialised body putsimagelast, so the daemon created analpine:latestcontainer (201 in the pre-fix integration run) and insideNetworkingConfig: with #87 merged,{"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}}passesNetworkEndpointsGate, which reads the exact key only, and the daemon attachesevil-net(checked on Docker 29:NetworkSettings.Networkslists it).What changes
HostConfig. A key outside the allowlist must be absent or zero (null,false,0,"",[],{});MaskedPaths/ReadonlyPathsmust be absent ornull. 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.SecurityOptentries must be in the policy'ssecurity_optionsor beno-new-privileges[:true].PidMode,UTSMode,UsernsMode,Cgroupmust be"";IpcMode∈ {"",private,shareable,none};CgroupnsMode∈ {"",private};Isolation∈ {"",default};OomScoreAdj≥ 0. The gate runs after the mutator, on the body that is forwarded, soPrivileged: truestays neutralised (as documented and integration-tested) rather than denied.encoding/jsondoes) to a known field name but is spelled differently is denied, at the top level, inHostConfig, inMountsentries andBindOptions, and inNetworkingConfig(whether or not the policy forcesnetwork_mode). The other nested objects need no check: the mutator replacesRestartPolicyandLogConfigwhole when the policy sets them and they are any-value otherwise; no gate readsHealthcheckor an endpoint entry's settings by key.volumesmeans no binds.Bindsoptions must bero,rw,z,Z,nocopy,private,rprivate; aread_write: falsevolume needsro(and notrw).Mounts:bindwithSourceequal to a policy volume,ReadOnly: truefor read-only volumes,Propagationempty/private/rprivate,ReadOnlyNonRecursivezero;tmpfswithout aSource; every other type denied.Privileged: falseon every create; leaves a non-objectHostConfigfor the gate (Rust panicked on one, TypeScript threw). Rust now applieslog_driver/log_options, as Go and TypeScript already did.spec/docker_socket_policy.qnt, next to Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87'snetwork_endpoints_model): modulecreate_modelwith flagLEGACY_CREATE, rule propertycreateChecksEveryField, security propertiesdaemonSeesOnlyAllowlistedHostConfig,daemonMountsOnlyWhitelistedanddaemonAttachesOnlyForcedNetwork(checked exhaustively over every single-field/bind/mount request of the model and theNetworkingConfigvariants), 43 rows pluscreateSoundTestoncreate, andcreate_pre88, which witnesses the reproductions and theendpointsconfiggap. The state machine uses the create rules, with two new P0 invariants (hostConfigInAllowlist,hostMountsInWhitelist).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.read_writeenforcement, pipeline;spec/README.md; AGENTS.md and CONTRIBUTING.md counts.Review notes
BREAKING CHANGE:footer is in thefix!:branch commit itself, so the default squash carries it. Simulated withscripts/release-version.sh v0.3.11over the branch messages:bump=minor tag=v0.4.0; the same with a plainfix:squash title.:ro, or any bind under a policy withoutvolumesare now 403.--runtime,--gpus,--sysctl,--device*,--group-add,--cap-add,--link,--volumes-from,--security-opt systempaths=unconfinedand the host namespaces are denied.make verify BACKEND=typescripton the main spec took 737 s locally (14 traces/s) with the create rules in the state machine; worth watching in CI.Body["Image"]and RegistryGate parses that same key; a loneimage(noImage) is denied by the router withimage field is required, andImageplus 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.NetworkingConfigendpoint entries (Links,DriverOpts,IPAMConfig,Aliases) are forwarded as sent under a forced bridge/namednetwork_modeor with no forced mode, so endpointLinkspasses whileHostConfig.Linksis denied.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.portsfield is parsed but enforced nowhere, soPortBindingsstays any-value (documented).network_mode,NetworkMode: host/container:<id>and anyEndpointsConfigstill 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.Mountsallow check accepts the daemon's 400 (bind source path does not exist):Mountsbinds are not auto-created likeBinds, and/etc/jwt_dirdoes not exist on the test daemon's host.Verification
createpassescreateSoundTestand 43 rows;create_pre88asserts the three security properties fail and that the Container create: default-deny HostConfig, validate Mounts and the bind read-only mode #88 reproductions and theendpointsconfigvariant were allowed. Mutations: dropping the HostConfig case-variant check failscaseVariantZeroSecurityOptDeniedandcreateSoundTest; dropping the read-only clause forMountsfailsmountBindReadOnlyVolumeWritableDeniedandcreateSoundTest; dropping theNetworkingConfigvariant check fails bothnetworkingConfigVariant*rows andcreateSoundTest.mainwith 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 throwsCannot create property 'NetworkMode' on string 'x'. Integration on the pre-fix Go proxy:make test-allGo 119 (131 subtests inTestHostConfigRows), Rust 146, TS 300 (1 skipped);make lint-all,make build-all,make typecheck,make test-spec,make verify BACKEND=typescript,make test-releasepass; integrationALL 94 TESTS PASSEDfor Go, Rust and TS.Type of change
Implementation(s) changed
Testing
make test-all)make verify,make test-spec)Checklist
🤖 Generated with Claude Code