Repository navigation
fix: route image pulls on the query string and normalise image references in all three (#83, #84) - #91
Merged
Conversation
…ation (#83, #84) Add pull_model to spec/router.qnt: the POST /images/create branch, with the request body (none, JSON or form) and the image it names, the fromImage query parameter and fromSrc. The daemon reads the parameters with r.Form: a form body wins over the query, a JSON body is ignored. READ_PULL_FROM_BODY models all three before #83; NORMALIZE_IMAGES = false models all three before #84, with normalisation as a fixed table from spelling to repository; ALLOW_PULL_BODY models the first #83 fix, which routed on the query but forwarded the body. Properties: pullForwardsOnlyAllowedImages (a forwarded request makes the daemon pull an allowed image, reading the form body over the query, and never import), pullWithBodyNeverForwarded, cliPullOfAllowedImageForwarded and pullDecidedOnCanonicalImage. pull_pre83 witnesses that an allowed image in a JSON body forwarded a pull of any image in the query, or an import; pull_body_ignored witnesses that a form body naming any image was forwarded with an allowed query; pull_pre84 witnesses the extra denials. make test-spec runs the four new instances. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll three (#83, #84) Replace the pull tests that sent fromImage in a JSON body, a contract the Docker Engine API does not have, with one table in Go, Rust and TS that sends the request the way the Docker CLI does: fromImage and tag in the query string, no body. Rows reuse the pull* names from spec/router.qnt and add the tag and digest rules, fromSrc, ambiguity, duplicates, the all-tags pull, and a pull with a JSON body, which is denied. A handler test per language sends a pull with a form, JSON or text body (and, in Go, a chunked one) and expects 403 "image pull must not have a request body" with nothing forwarded: the daemon reads a form body's fromImage over the query's. Add the #84 normalisation table on the create route (alpine, library/alpine, docker.io/library/alpine, index.docker.io/..., registry ports, upper case) with prefixes written unnormalised in the policy files, and a registry gate table for registry ports and Docker Hub spellings. deploy/test.sh pulls docker.io/library/busybox:latest through the proxy with no body and query parameters (deploy/config/pull-allowed.yaml allows busybox), and checks the denials for an unlisted image, a tag outside the pattern, a JSON body, a form body naming another image (plain and chunked), and fromSrc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nces in all three (#83, #84) POST /images/create read fromImage from the JSON body, but the Docker Engine API sends fromImage and tag in the query string with an empty body, so every docker pull was denied ("empty request body"). Worse, a body naming an allowed image forwarded whatever the query named: a pull of any image, or a fromSrc import that stores any tarball under an allowed name. The router now takes the request target (path and query) in Go and Rust, as it already did in TypeScript, and splits off the query before the path checks, so the #53 raw-path rules are unchanged. The pull reads fromImage and tag from the query (URL-decoded, as url.ParseQuery does). A pull with a body of any Content-Type is denied ("image pull must not have a request body"): the daemon reads a form body's fromImage over the query's, so forwarding a body would let it name any image. The handler passes the router whether the raw body is non-empty (Router.RouteRequest, Router::route_request, the hasBody argument of route in TypeScript), and the router decides. It also denies fromSrc, a missing fromImage, a tag in both fromImage and tag, repeated parameters, a malformed query, and a pull with neither tag nor digest (the daemon would pull every tag). The CLI's digest in tag is read as a digest. The image then gets the create checks: a policy prefix, the tag pattern and image_digest_allowed. One reference parser per language (policy.ParseImageRef, policy::parse_image_ref, parseImageRef) splits [registry/]repo[:tag] [@digest] the Docker way, drops docker.io, index.docker.io, registry-1.docker.io and library/ before a single-component name, lower-cases only the registry host and rejects upper case in the path. It is used for create's Image, the pull, the registry gate (so a registry port is no longer read as a tag) and each policy prefix at load time; an invalid prefix, or one with a tag or digest, fails the load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 of 16 tasks
abienkowski
added a commit
that referenced
this pull request
Oct 14, 2026
…inds (#88) (#92) * spec: model the HostConfig default-deny on container create (#88) 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> * test: pin the HostConfig default-deny, Mounts and bind modes (#88) 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> * fix!: default-deny HostConfig on create; check Mounts and read-only binds (#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> * docs: HostConfig rules, read_write enforcement and counts (#88) 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> * test: send the Docker CLI 29 create body on API v1.45 (#88) 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> --------- 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 #83.
Closes #84.
Every
docker pullthrough the proxy was denied. The router readfromImagefrom the JSON body, but the Docker Engine API sendsfromImageandtagin the query string with an empty body, so the proxy answered403 empty request body. The same mistake had a security side. The daemon reads the pull parameters with Go'sr.Form, from the query and, for a form body (application/x-www-form-urlencoded), from the body, which wins over the query. It ignores a JSON body. So a JSON body that named an allowed image let through a pull of any image in the query, or afromSrcimport that stores any tarball under an allowed name (for example?fromSrc=http://…/rootfs.tar&repo=chainsafe/lodestar&tag=x), which a later create would accept. The integration run on the pre-fix code shows the pull: the daemon pulledalpine.Routing on the query alone is not enough either. If the proxy checks the query and forwards the body, an allowed image in the query and a form body naming any other image make the daemon pull the body's image:
POST /images/create?fromImage=busybox&tag=latestwith form bodyfromImage=alpine&tag=3.19pullsalpine:3.19(checked against Engine 29.8.2 directly, and through this branch's first version of the fix, whose integration run is below). So a pull must have no body: any non-empty body, whatever itsContent-Typeor framing (chunked included), is denied withimage pull must not have a request body. The Docker CLI, docker-py and community.docker send no body.Separately (#84), image names were matched as sent:
docker.io/library/alpine(how the CLI sends a pull) did not match the prefixalpine, and the first:was read as the tag separator, solocalhost:5000/team/app:1.2was checked as imagelocalhostwith tag5000/team/app:1.2.What changes
%,//, dot segments, version strip) still read only the raw path.POST /images/createreadsfromImageandtagfrom the query (decoded like Go'surl.ParseQuery, which the daemon uses) and denies any request body. The handler tells the router whether the raw body is non-empty (Router.RouteRequestin Go,Router::route_requestin Rust, thehasBodyargument ofroutein TypeScript); the router makes the decision, so there is one decision point per language.--readonlystill denies every pull first. The image gets the create checks: a policy prefix on the normalised repository, then that policy'simage_tag_pattern, andimage_digest_allowedfor a digest. The CLI's digest intag(tag=sha256:…) is read as a digest.image pull must not have a request body;fromImage query parameter is required for image pull;image import (fromSrc) is not allowed;fromImage X already has a tag or digest; the tag query parameter is ambiguous;duplicate fromImage|tag|fromSrc query parameter;invalid query string;image pull without a tag or digest pulls every tag and is not allowed.policy.ParseImageRef,policy::parse_image_ref,parseImageRef) splits[registry/]repo[:tag][@digest]the Docker way (the tag follows the last:after the last/; the first component is a registry when it has.or:or islocalhost), dropsdocker.io,index.docker.io,registry-1.docker.ioandlibrary/before a single-component name, lower-cases only the registry host, and rejects upper case in the path (invalid image reference "…"). It is used for create'sImage, the pull, the registry gate and everyallowed_image_prefixesentry at load time. The prefix match stays on path-component boundaries.spec/router.qnt): newpull_modelfor thePOST /images/createbranch, with the body (none, JSON or form) and the daemon reading a form body over the query, flagsREAD_PULL_FROM_BODY(before docker pull through the proxy is always denied: fromImage is read from the body, but the Engine API sends it in the query string #83),NORMALIZE_IMAGES(before Image references are not normalized before policy matching (docker.io/library/..., registry ports) #84) andALLOW_PULL_BODY(the first version of the fix, which forwarded the body), normalisation as a fixed tableCANONICAL, the security propertypullForwardsOnlyAllowedImages(a forwarded request makes the daemon pull an allowed image, reading the form body over the query, and never import; it mentions no flag), the rule propertiespullWithBodyNeverForwarded,cliPullOfAllowedImageForwardedandpullDecidedOnCanonicalImage, 12 rows, and instancespull,pull_pre83,pull_body_ignoredandpull_pre84.make test-specruns them.spec/README.mdstates the boundary: the parser's rules, the prefix-boundary match and the tag and digest rules are pinned by the language tables, not the model.{"fromImage": …}bodies are replaced by one 26-row table (Go, Rust, TS) sent the CLI way, JSON-body rows now denied; a 17-row create normalisation table with prefixes written unnormalised in the policy files; an 8-row registry gate table; a 27-row parser table and a prefix load test; a handler test per language that the query reaches the router and is forwarded unchanged; a handler test per language that a form, JSON or text body (and, in Go, a chunked one) gets 403 with nothing forwarded.deploy/test.sh: the three JSON-body pull checks are replaced by eight query-string checks, including a real pull ofdocker.io/library/busybox:latest(newdeploy/config/pull-allowed.yamlallows the bare prefixbusyboxwith tag pattern^latest$, so the check also coverslibrary/normalisation) that must finish with 200 and no error in the stream.alpineis the unlisted image for the deny and body checks; nodeploy/configpolicy allows it. The body checks send a JSON body, a form body (querybusybox, bodyalpine) and the same form body chunked.spec/README.md; AGENTS.md counts.Review notes
origin/main..HEAD:bump=patch; that is v0.3.13 if fix: make NetworkingConfig endpoints match a forced network_mode (#87) #89 is released as v0.3.12 first, as expected, else v0.3.12.latest. The daemon does not default tolatestfor a name-onlyfromImage; it pulls every tag of the repository (that is howdocker pull --all-tagsworks). The tag pattern cannot be checked against every tag, so the pull is denied. The CLI always sendstag=latestfordocker pull alpine, and docker-py and Ansible sendlatestby default, so normal use is not affected./) or that carries a tag or digest now fails the load withinvalid allowed_image_prefixes entry. Such a prefix never matched before. The issues are not labelledStatus: Break Change, so the commits carry no!; say if you want one.Imageis unchanged: the tag pattern is checked only when a tag is present, as before. Only the pull path treats a missing tag specially.GetByImagekeeps its signature (a reference string) so the policy-selection work in Policy selection is nondeterministic when several policies allow one image; add container_names routing and deny on ambiguity #85/Lifecycle actions and DELETE are allowed on containers that no policy covers #86 can build on it; the router calls it with the normalised name.pullsentfromImagein both a JSON body and the query; with the body deny those pulls got 403, so it now callspull_query(query only, no body). Its policies allowhello-worldandregistry.k8s.io/pause; this PR'spull-allowed.yamlallows onlybusybox, so no image matches two policies.deploy/test.shnow pullsbusyboxfrom Docker Hub, so the integration suite needs registry access. Before the fix it already relied on the daemon's response to an allowed create; this is the first check that needs the network.Verification
pullwithREAD_PULL_FROM_BODY = truefailspullSoundTestand six rows; withNORMALIZE_IMAGES = falseit failspullSoundTestand three rows; withALLOW_PULL_BODY = trueit failspullSoundTest,pullFormBodyDeniedandpullJsonBodyDenied.pull_body_ignoredasserts thatpullForwardsOnlyAllowedImagesandpullWithBodyNeverForwardedfail and that a form body namingattacker/malwarewithfromImage=alpineis forwarded.pull_pre83asserts thatpullForwardsOnlyAllowedImagesandcliPullOfAllowedImageForwardedfail, and that a body namingalpineforwards a query pull ofattacker/malwareand afromSrcimport.pull_pre84asserts that the security property still holds and the other two fail.3130b0a; the rebased test commit is9c0a98b): Go failsTestRoutePullQuery,TestRouteImageNormalization,TestRegistryGate_ParsesReferencesandTestHandler_PullDeniesBody; Rust failstest_route_pull_query,test_route_image_normalization,test_registry_gate_parses_referencesandtest_handler_pull_denies_body(141 passed, 4 failed); TS fails the same four (# fail 4). Integration on the pre-fix Go code:alpine:3.19:4536f9c, Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87):make test-allGo 118, Rust 145, TS 169 (1 skipped);make lint-all,make build-all,make typecheck,make test-spec(16 runs),make verify BACKEND=typescriptandmake test-releasepass; integrationALL 77 TESTS PASSEDfor Go, Rust and TS (including Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87's real pulls ofhello-worldandregistry.k8s.io/pause, now sent the CLI way); socket suiteALL 15 TESTS PASSED(Go, run before the body change, which does not touch the socket suite).Type of change
Implementation(s) changed
Testing
make test-all)make verify,make test-spec)Checklist
🤖 Generated with Claude Code