Repository navigation
v2.2.2
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
ParseFormand readr.Form, and Go fillsr.Formfrom anapplication/x-www-form-urlencodedbody before the query string, so the body's value wins whenever both name a parameter. Under owner isolation,POST /containers/prune,/images/prune,/networks/pruneand/volumes/prunehad the owner filter injected into a query the daemon no longer consulted: a body offilters={}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/createpulled the body'sfromImageinstead of the one checked againstrequest_body.image_pull.allowed_registries, andPOST /buildtookremote,networkmodeandlabelsfrom the body, which defeatsallow_remote_context,allow_host_networkand the owner label on the built image. dockerd's build handler also parses amultipart/form-databody, because it readsr.FormValuewithout callingParseFormfirst, so a multipart body supplied any build parameter the query left out. AndPOST /plugins/createhad 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 callsParseFormon every request and readsr.Formin its tag and untag handlers. Sockguard now refuses, with400and reason coderequest_form_body_refused, any request with anapplication/x-www-form-urlencodedbody, and any request with amultipart/form-databody 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 everyContent-Typeline, in any letter case and with or without parameters, which is wider than Go's own test on purpose: Go still treatsapplication/x-www-form-urlencoded; =badas a form, and folds aU+0130in place of theiinapplicationinto 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 ortext/plainbodies, and Podman's client sends multipart only to/libpod/endpoints, where a multipart build still needsinsecure_allow_body_blind_writes. Two things do change. A hand-rolled caller that sentcurl -d 'x'to an endpoint that ignores its body now gets the400; a formContent-Typeon an empty body (curl -d '',wget --post-data='') is still allowed, because there is nothing for the daemon to parse. And a multipart upload toPOST /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, socurl --data-binary @candidate.yamlagainst/admin/validateis unaffected. -
Under owner isolation, a Docker-compatible image push was authorized against the wrong image.
POST /images/{name}/pushnames 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 ownedrepo:latestcould 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 ordinarydocker push repo:v1whenever no localrepo:latestexisted, 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 repeatedtagis refused, because dockerd takes the first value and Podman the last. Any spelling of the key other than lowercasetagis refused even on its own: Podman folds case and reads?Tag=v1as 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 anapplication/x-www-form-urlencodedbody is refused, because dockerd reads form fields from the body ahead of the query string, so?tag=ownedwith a body oftag=foreignwas 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 lowercasetagand 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 nativePOST /libpod/images/{name}/pushcarries 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}/tagnames two images: the source in the path, and inrepoandtagthe 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 sendPOST /images/mine:1/tag?repo=theirs/app&tag=latestand repointtheirs/app:latestat an image of its choosing, and becauseallow_unowned_imagesdefaults totrue, pointing it at an unlabeled image meant the other owner's next container create ran that image. Podman'sPOST /libpod/images/{name}/tagruns 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 tagrepoalready carries when notagis sent (what docker-py sends forimage.tag("name:v1")),latestwhen 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 withallow_unowned_imageson, and another owner's is a403. 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 defaulttrue, 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: falseis the setting for owners that do not trust each other. A retag whose target sockguard cannot read the way the daemon will is refused: norepo, a repeatedrepoortag, either key in any spelling but lowercase, a query with a semicolon or a bad percent escape, arepocarrying a digest, arepocarrying a tag next to atagparameter (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 (arepowith no slash over 247 characters, which dockerd reads aslibrary/{repo}and answers the inspect of with a400), and arepothat is a digest algorithm name (sha256,sha384orsha512), which a lookup can read as an image ID instead of a name. On Podman's native route arepothat names no registry is checked aslocalhost/{repo}, the name Podman stores it under. On the Docker-compatible route of a Podman upstream that samerepois checked under both names,{repo}:{tag}andlocalhost/{repo}:{tag}, and both have to pass. Which one Podman writes depends oncompat_api_enforce_docker_hubin itscontainers.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 underlocalhost/with no lookup while that inspect still resolves aregistries.confalias first. Checking the short name alone left the second case open, where a client could plant an image under the aliased name and movelocalhost/{repo}:{tag}off another owner's image.upstream.flavordecides it, set or detected, so a dockerd upstream still gets one inspect per target, and arepothat names its registry gets one on either engine. A shortrepoPodman cannot store underlocalhost/, 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 unversionedPOST /images/{name}/tagwhose image is namedlibpodor starts withlibpod/. 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 shortrepounderlocalhost/without looking anything up, while sockguard reads it as a compat request and inspects the short name, which Podman resolves through aregistries.confalias first. Left alone, a client could name one of its imageslibpod, plant another under the aliased name, and move alocalhost/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 lowercaserepoandtagonce 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 holdslocalhost/{repo}:{tag}, and spelling the registry inrepoavoids it. Target denials and the refused shapes are owner verdicts, sowarnandauditrecord them aswould_denyand forward.POST /libpod/images/{name}/untagtakes 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'spin-chart-digestjob no longer reds a successful release when the development branch has already moved past the taggedappVersion.scripts/pin-chart-image-digest.mjsnow 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 oneimage.tagline are still hard errors.
Fixed
-
Homebrew cask generation uses
postflight_stepsto 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 withupstream flavor: GET /version returned HTTP 404when the upstream cannot answerGET /version, andbenchmarks/cmd/mockdocker, the stand-in daemon all three harnesses use, only served/_ping,/containers/jsonand/exec/.scripts/soak.shandscripts/security-testssl.shhave been red since. The mock now serves a dockerd-shaped/versionwith anEnginecomponent, 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 Checkinstalls 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.