Skip to content

fix: route image pulls on the query string and normalise image references in all three (#83, #84) - #91

Merged
abienkowski merged 4 commits into
mainfrom
fix/image-pull-query
Oct 14, 2026
Merged

abienkowski merged 4 commits into
mainfrom
fix/image-pull-query

Conversation

@abienkowski

Copy link
Copy Markdown
Collaborator

Description

Closes #83.
Closes #84.

Every docker pull through the proxy was denied. The router read fromImage from the JSON body, but the Docker Engine API sends fromImage and tag in the query string with an empty body, so the proxy answered 403 empty request body. The same mistake had a security side. The daemon reads the pull parameters with Go's r.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 a fromSrc import 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 pulled alpine.

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=latest with form body fromImage=alpine&tag=3.19 pulls alpine: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 its Content-Type or framing (chunked included), is denied with image 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 prefix alpine, and the first : was read as the tag separator, so localhost:5000/team/app:1.2 was checked as image localhost with tag 5000/team/app:1.2.

What changes

  • Pull routing (all three). The router gets the request target (path and query). Go and Rust now pass it the way TypeScript already did, and the router splits off the query before the path checks, so the Percent-encoded container names: Go routes on the decoded path, Rust/TS on the raw path #53 rules (%, //, dot segments, version strip) still read only the raw path. POST /images/create reads fromImage and tag from the query (decoded like Go's url.ParseQuery, which the daemon uses) and denies any request body. The handler tells the router whether the raw body is non-empty (Router.RouteRequest in Go, Router::route_request in Rust, the hasBody argument of route in TypeScript); the router makes the decision, so there is one decision point per language. --readonly still denies every pull first. The image gets the create checks: a policy prefix on the normalised repository, then that policy's image_tag_pattern, and image_digest_allowed for a digest. The CLI's digest in tag (tag=sha256:…) is read as a digest.
  • New pull denials: 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.
  • Normalisation (all three). One parser per language (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 is localhost), 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 (invalid image reference "…"). It is used for create's Image, the pull, the registry gate and every allowed_image_prefixes entry at load time. The prefix match stays on path-component boundaries.
  • Spec (spec/router.qnt): new pull_model for the POST /images/create branch, with the body (none, JSON or form) and the daemon reading a form body over the query, flags READ_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) and ALLOW_PULL_BODY (the first version of the fix, which forwarded the body), normalisation as a fixed table CANONICAL, the security property pullForwardsOnlyAllowedImages (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 properties pullWithBodyNeverForwarded, cliPullOfAllowedImageForwarded and pullDecidedOnCanonicalImage, 12 rows, and instances pull, pull_pre83, pull_body_ignored and pull_pre84. make test-spec runs them. spec/README.md states 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.
  • Tests: the pull tests that sent {"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 of docker.io/library/busybox:latest (new deploy/config/pull-allowed.yaml allows the bare prefix busybox with tag pattern ^latest$, so the check also covers library/ normalisation) that must finish with 200 and no error in the stream. alpine is the unlisted image for the deny and body checks; no deploy/config policy allows it. The body checks send a JSON body, a form body (query busybox, body alpine) and the same form body chunked.
  • Docs: README endpoint and middleware tables, a section on pull routing and normalisation, test counts; spec/README.md; AGENTS.md counts.

Review notes

  • Release: patch. Simulated over 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.
  • Departure from the brief: a pull with no tag and no digest is denied, not treated as latest. The daemon does not default to latest for a name-only fromImage; it pulls every tag of the repository (that is how docker pull --all-tags works). The tag pattern cannot be checked against every tag, so the pull is denied. The CLI always sends tag=latest for docker pull alpine, and docker-py and Ansible send latest by default, so normal use is not affected.
  • Stricter than before, possibly visible to operators: a policy prefix that is not a valid repository name (upper case, a trailing /) or that carries a tag or digest now fails the load with invalid allowed_image_prefixes entry. Such a prefix never matched before. The issues are not labelled Status: Break Change, so the commits carry no !; say if you want one.
  • Create with an untagged Image is unchanged: the tag pattern is checked only when a tag is present, as before. Only the pull path treats a missing tag specially.
  • Duplicate parameters are denied rather than taking the first value, so no reading of the query can disagree with the daemon's.
  • GetByImage keeps 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.
  • Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87's integration helper pull sent fromImage in both a JSON body and the query; with the body deny those pulls got 403, so it now calls pull_query (query only, no body). Its policies allow hello-world and registry.k8s.io/pause; this PR's pull-allowed.yaml allows only busybox, so no image matches two policies.
  • deploy/test.sh now pulls busybox from 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

  • Spec mutation: pull with READ_PULL_FROM_BODY = true fails pullSoundTest and six rows; with NORMALIZE_IMAGES = false it fails pullSoundTest and three rows; with ALLOW_PULL_BODY = true it fails pullSoundTest, pullFormBodyDenied and pullJsonBodyDenied. pull_body_ignored asserts that pullForwardsOnlyAllowedImages and pullWithBodyNeverForwarded fail and that a form body naming attacker/malware with fromImage=alpine is forwarded. pull_pre83 asserts that pullForwardsOnlyAllowedImages and cliPullOfAllowedImageForwarded fail, and that a body naming alpine forwards a query pull of attacker/malware and a fromSrc import. pull_pre84 asserts that the security property still holds and the other two fail.
  • Before the fix (the test commit, measured on the pre-rebase base 3130b0a; the rebased test commit is 9c0a98b): Go fails TestRoutePullQuery, TestRouteImageNormalization, TestRegistryGate_ParsesReferences and TestHandler_PullDeniesBody; Rust fails test_route_pull_query, test_route_image_normalization, test_registry_gate_parses_references and test_handler_pull_denies_body (141 passed, 4 failed); TS fails the same four (# fail 4). Integration on the pre-fix Go code:
    FAIL: pull allowed image the CLI way (expected 200 and a clean pull, got 403: empty request body)
    FAIL: pull unlisted image the CLI way -> 403 (expected 403 with "image docker.io/library/alpine not allowed by any policy", got 403: empty request body)
    FAIL: pull alpine (unlisted, short name) -> 403 (expected 403 with "image alpine not allowed by any policy", got 403: empty request body)
    FAIL: pull allowed image with a tag outside the pattern -> 403 (expected 403 with "image tag "musl" does not match pattern", got 403: empty request body)
    FAIL: pull with allowed image in JSON body, unlisted in query -> 403 (expected 403 with "image pull must not have a request body", got 200: {"status":"Pulling from library/alpine","id":"latest"}
    FAIL: pull with allowed image in query, unlisted in form body -> 403 (expected 403 with "image pull must not have a request body", got 403: empty request body)
    FAIL: pull with a chunked form body -> 403 (expected 403 with "image pull must not have a request body", got 403: empty request body)
    FAIL: image import (fromSrc) -> 403 (expected 403 with "image import (fromSrc) is not allowed", got 403: empty request body)
    60 PASSED, 8 FAILED
    
  • The first version of the fix (query routing, body forwarded unchanged; the earlier head of this branch with the new tests): the form-body checks reach the daemon, which pulls alpine:3.19:
    FAIL: pull with allowed image in JSON body, unlisted in query -> 403 (expected 403 with "image pull must not have a request body", got 403: image alpine not allowed by any policy)
    FAIL: pull with allowed image in query, unlisted in form body -> 403 (expected 403 with "image pull must not have a request body", got 200: {"status":"Pulling from library/alpine","id":"3.19"}
    FAIL: pull with a chunked form body -> 403 (expected 403 with "image pull must not have a request body", got 200: {"status":"Pulling from library/alpine","id":"3.19"}
    65 PASSED, 3 FAILED
    
  • This branch (rebased on 4536f9c, Containers created with a policy network_mode do not start: NetworkingConfig still names the "default" network #87): make test-all Go 118, Rust 145, TS 169 (1 skipped); make lint-all, make build-all, make typecheck, make test-spec (16 runs), make verify BACKEND=typescript and make test-release pass; integration ALL 77 TESTS PASSED for 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 of hello-world and registry.k8s.io/pause, now sent the CLI way); socket suite ALL 15 TESTS PASSED (Go, run before the body change, which does not touch the socket suite).

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 (77 per implementation; socket suite 15, Go only)
  • 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 09:59
…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>
…84)

README endpoint table, middleware table and a section on pull routing
and reference normalisation; AGENTS.md and README test counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abienkowski abienkowski added the Type: Bug Added to issues and PRs if they are addressing a bug label Oct 14, 2026
@abienkowski
abienkowski merged commit fd05353 into main Oct 14, 2026
7 checks passed
@abienkowski
abienkowski deleted the fix/image-pull-query branch October 14, 2026 14:25
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Bug Added to issues and PRs if they are addressing a bug

Projects

None yet

1 participant