Skip to content

v2.2.2

Choose a tag to compare

@github-actions github-actions released this 02 Oct 13:47
· 540 commits to main since this release
c31398b

v2.2.2

[2.2.2] - 2026-10-02

Security

  • A form body could replace the query parameters sockguard had just evaluated. Every sockguard layer reads request parameters from the URL query, and the ones that enforce by rewriting write back to it. Both engines read further than that. dockerd's handlers call ParseForm and read r.Form, and Go fills r.Form from an application/x-www-form-urlencoded body before the query string, so the body's value wins whenever both name a parameter. Under owner isolation, POST /containers/prune, /images/prune, /networks/prune and /volumes/prune had the owner filter injected into a query the daemon no longer consulted: a body of filters={} made the prune run unscoped, across every owner's stopped containers and unused images, networks and volumes. The same body shape got around the filter layer's query checks. POST /images/create pulled the body's fromImage instead of the one checked against request_body.image_pull.allowed_registries, and POST /build took remote, networkmode and labels from the body, which defeats allow_remote_context, allow_host_network and the owner label on the built image. dockerd's build handler also parses a multipart/form-data body, because it reads r.FormValue without calling ParseForm first, so a multipart body supplied any build parameter the query left out. And POST /plugins/create had its own version of the split: sockguard inspected the parts of a multipart upload while dockerd ignores the framing and untars the raw body, so a privileged plugin tar placed ahead of the first boundary went uninspected. Podman calls ParseForm on every request and reads r.Form in its tag and untag handlers. Sockguard now refuses, with 400 and reason code request_form_body_refused, any request with an application/x-www-form-urlencoded body, and any request with a multipart/form-data body outside Podman's native /libpod/ API. The check runs immediately before the filter, so no rule, body inspector, visibility or ownership decision sees such a request, and it is a hard rejection in every rollout mode. It looks at every Content-Type line, in any letter case and with or without parameters, which is wider than Go's own test on purpose: Go still treats application/x-www-form-urlencoded; =bad as a form, and folds a U+0130 in place of the i in application into the ASCII spelling. A fuzz target holds the guard to net/http's actual parser. The form-encoded, multipart-build and media-type behaviours were confirmed against dockerd 29.5.2. No Docker or Podman client is narrowed: the Docker CLI, Go SDK and Compose put every parameter in the query and send JSON, tar or text/plain bodies, and Podman's client sends multipart only to /libpod/ endpoints, where a multipart build still needs insecure_allow_body_blind_writes. Two things do change. A hand-rolled caller that sent curl -d 'x' to an endpoint that ignores its body now gets the 400; a form Content-Type on an empty body (curl -d '', wget --post-data='') is still allowed, because there is nothing for the daemon to parse. And a multipart upload to POST /plugins/create, which the inspector used to accept, is refused; send the tar as the body. Sockguard's own admin, health and metrics endpoints are answered before the check, so curl --data-binary @candidate.yaml against /admin/validate is unaffected.

  • Under owner isolation, a Docker-compatible image push was authorized against the wrong image. POST /images/{name}/push names the repository in the path and the tag in ?tag=, and ownership only ever read the path. It inspected the bare repository, which the daemon resolves to :latest, so a client that owned repo:latest could push any other tag of that repository, including one carrying another owner's label, and a push with no tag at all sent every local tag. The same misread denied the ordinary docker push repo:v1 whenever no local repo:latest existed, because the inspect came back empty. The check now inspects exactly {name}:{tag} (#521, thanks @MgSrdEer), and refuses every request shape where the tag sockguard reads and the tag the daemon reads can differ. A push with no tag or an empty one is refused, because dockerd pushes every tag of the repository and one inspect cannot cover that. A repeated tag is refused, because dockerd takes the first value and Podman the last. Any spelling of the key other than lowercase tag is refused even on its own: Podman folds case and reads ?Tag=v1 as the tag, while dockerd sees no tag and pushes everything. A query with a semicolon or a bad percent escape is refused instead of having the broken pair silently dropped. And a push with an application/x-www-form-urlencoded body is refused, because dockerd reads form fields from the body ahead of the query string, so ?tag=owned with a body of tag=foreign was checked as one tag and pushed as the other. A tag has to match the image reference grammar as it arrived, with no trimming, and a path that already carries a tag or digest is refused, because dockerd replaces that tag with the query's instead of appending to it. The body, case-variant and tag-replacement behaviours were confirmed against dockerd 29.5.2. The docker CLI always sends a lowercase tag and no body and docker-py sends a JSON {}, so no real client is narrowed, with one exception: a direct API caller that pushed a whole repository by leaving the tag off now has to push each tag by name. Podman's native POST /libpod/images/{name}/push carries the full reference in the path, so this tag check does not apply to it (the form-body refusal above still does).

  • Under owner isolation, an image retag could take a name away from another owner's image. POST /images/{name}/tag names two images: the source in the path, and in repo and tag the reference to point at it. Both engines move that reference off whatever image already holds it, and ownership only ever checked the source. A client that owned any image could send POST /images/mine:1/tag?repo=theirs/app&tag=latest and repoint theirs/app:latest at an image of its choosing, and because allow_unowned_images defaults to true, pointing it at an unlabeled image meant the other owner's next container create ran that image. Podman's POST /libpod/images/{name}/tag runs on the same handler and had the same hole. The check now builds the target the way the daemon does and inspects it after the source: repo:tag, the tag repo already carries when no tag is sent (what docker-py sends for image.tag("name:v1")), latest when there is neither, and a registry port is not read as a tag. A target that no image holds yet is allowed. One that resolves has to be the caller's own, or unlabeled with allow_unowned_images on, and another owner's is a 403. An unlabeled target follows the option on purpose. It already decides whether a client may use, push, tag and (on Podman) untag an unlabeled image, so a stricter rule here would not hold on Podman and would give the option a second meaning. With the default true, a name held by an unlabeled image is therefore still movable by any owner, and an image pulled through the proxy is unlabeled; allow_unowned_images: false is the setting for owners that do not trust each other. A retag whose target sockguard cannot read the way the daemon will is refused: no repo, a repeated repo or tag, either key in any spelling but lowercase, a query with a semicolon or a bad percent escape, a repo carrying a digest, a repo carrying a tag next to a tag parameter (dockerd replaces the tag, Podman concatenates both), a name or tag outside the image reference grammar, a name that is only too long once the engine completes it (a repo with no slash over 247 characters, which dockerd reads as library/{repo} and answers the inspect of with a 400), and a repo that is a digest algorithm name (sha256, sha384 or sha512), which a lookup can read as an image ID instead of a name. On Podman's native route a repo that names no registry is checked as localhost/{repo}, the name Podman stores it under. On the Docker-compatible route of a Podman upstream that same repo is checked under both names, {repo}:{tag} and localhost/{repo}:{tag}, and both have to pass. Which one Podman writes depends on compat_api_enforce_docker_hub in its containers.conf, which sockguard cannot see: on, the default, it tags the name a local lookup finds, the same image the inspect of the short name answers with, and off it stores the name under localhost/ with no lookup while that inspect still resolves a registries.conf alias first. Checking the short name alone left the second case open, where a client could plant an image under the aliased name and move localhost/{repo}:{tag} off another owner's image. upstream.flavor decides it, set or detected, so a dockerd upstream still gets one inspect per target, and a repo that names its registry gets one on either engine. A short repo Podman cannot store under localhost/, one over 245 characters or with an upper-case first component, is refused on a Podman upstream. One Docker-compatible path is refused as well: an unversioned POST /images/{name}/tag whose image is named libpod or starts with libpod/. Podman tells its two APIs apart by the third /-separated piece of the URL, not by the route it matched, so it reads that request as a native one and stores a short repo under localhost/ without looking anything up, while sockguard reads it as a compat request and inspects the short name, which Podman resolves through a registries.conf alias first. Left alone, a client could name one of its images libpod, plant another under the aliased name, and move a localhost/ name off another owner's image. The versioned path is a compat request to Podman too and is unaffected. The dockerd behavior was confirmed against dockerd 29.5.2 and the Podman behavior read from Podman 5.8.6. The docker CLI and the Go SDK send lowercase repo and tag once each on a versioned path, so no real client is narrowed, with one exception: a tag onto a name that another owner's image already holds now fails where it used to succeed. A Podman upstream on the default setting has a second: a short name that resolves through an alias to the caller's own image is refused while another owner holds localhost/{repo}:{tag}, and spelling the registry in repo avoids it. Target denials and the refused shapes are owner verdicts, so warn and audit record them as would_deny and forward. POST /libpod/images/{name}/untag takes the same parameters and gets no target check, because Podman removes a name only from the image the path resolves (the form-body refusal above still applies to it).

Changed

  • release-from-tag.yml's pin-chart-digest job no longer reds a successful release when the development branch has already moved past the tagged appVersion. scripts/pin-chart-image-digest.mjs now reports that version skew as a ::warning:: and exits zero without writing, and RELEASING.md describes the by-hand pin for that case. A digest that disagrees with what the release job pushed, a malformed digest, and a values file without exactly one image.tag line are still hard errors.

Fixed

  • Homebrew cask generation uses postflight_steps to avoid Homebrew 7 deprecation warnings while retaining the macOS quarantine removal.

  • The weekly soak, the testssl.sh scan, and the synthetic benchmark boot again. Sockguard made the upstream flavor probe fatal in 4eda1ca0 (2026-09-03): startup now fails with upstream flavor: GET /version returned HTTP 404 when the upstream cannot answer GET /version, and benchmarks/cmd/mockdocker, the stand-in daemon all three harnesses use, only served /_ping, /containers/json and /exec/. scripts/soak.sh and scripts/security-testssl.sh have been red since. The mock now serves a dockerd-shaped /version with an Engine component, so the harnesses go through the real probe instead of a config override. Every wait loop in the three scripts also prints the mockdocker or sockguard log when it times out, which is what was hiding this.

  • Go CI / Qlty Check installs again. The unpinned shfmt plugin had drifted to 3.13.1, which needs a newer Go than qlty bundles, and the toolchain download it triggered is blocked by the job's egress allowlist. shfmt is pinned to 3.8.0.