containerd 2.4.1 turns example.com/../other into the registry other
example.com/../other/image:latest does not name example.com once containerd’s own reference parser has finished with it. On the v2.4.1 module, Parse returned a locator of other/image and a hostname of other. The same call on v2.4.2 returned invalid reference and no locator. Both results came from the tagged module, not from a reimplementation of the function, and neither call opened a registry connection.
That split is easy to miss if the only check is a CRI pull error. containerd’s CRI PullImage parses the name with distribution.ParseDockerRef before it asks the transfer service or client.Pull to fetch anything. github.com/distribution/reference v0.6.0, the version required by containerd v2.4.1, already rejects a .. path component. A pod that fails there never reaches the parser that rewrites the host. ctr, the transfer service, and client.Pull do not take that detour. The docker resolver builds its registry host from reference.Parse in resolveDockerBase. On an unfixed module, the host it builds is the host left after path.Join, not the host that was written in the reference.
The comparison used Go 1.27.3 on Linux amd64. It linked a small program against github.com/containerd/containerd/v2/pkg/reference at v2.4.2, v2.4.1, v2.3.7, v2.3.6, v2.2.10, v2.2.9, and v2.0.14, and against github.com/containerd/containerd/reference at v1.7.24. The same inputs were given to ParseDockerRef from github.com/distribution/reference v0.6.0. Module sums were recorded from go.sum after go mod tidy. No image was pulled, and no containerd daemon was started for the parse.
The written host is not the host the resolver uses
Parse refuses a string that contains ://, then parses the rest as if it were a URL path under a dummy scheme. A colon or @ in that path splits off the tag or digest. What remains is joined with the URL host by path.Join. path.Join cleans the result. A .. component is not preserved as text. It climbs.
On v2.4.1 the climb is visible in the returned spec:
input example.com/../other/image:latest
locator other/image
host other
object latest
string other/image:latest
The hostname helper takes the locator up to the first slash. After the join, that prefix is other, so the helper agrees with the locator. A later reader of Hostname() cannot recover example.com. The normalized string printed by String() is other/image:latest. A log line that records the normalized form will not show the reference that was submitted.
Percent-encoding does not save the written host. example.com/%2e%2e/other/image:latest and example.com/%2E%2E/other/image:latest produced the same locator and the same hostname on v2.4.1. url.Parse decodes %2e and %2E before path.Join runs. A scan that looks for the two-character sequence .. in the original string does not see those inputs, and the parser still rewrites them.
A host with a port is not special. registry.example:5000/../other/image:latest also came back as host other, locator other/image. The port was part of the host that was discarded, not a reason for the parser to stop.
Not every .. changes the registry. example.com/library/../image:latest stayed on example.com. The locator was example.com/image. The library component was removed and the host was not. example.com/foo/../bar/image@sha256:abcdef stayed on example.com as well, with the object @sha256:abcdef. That digest is not a complete digest. The parser does not check digest syntax. It only splits on @. A shortened path on the same host is still a different repository name than the one that was written, and an admission rule that compares the submitted string with the locator will see the difference only if it parses.
Two further climbs left the written host entirely. example.com/foo/../../other/image:latest returned host other. The extra .. is what crosses from a same-host cleanup into a different registry. Counting slashes in the original string does not tell those cases apart, because the encoded form has the same shape as the literal form after decoding, and a port makes the written host longer without changing the result.
The October check rejects the component, not only the rewrite
v2.4.2, v2.3.7, and v2.2.10 rejected every input above that contained a .. path component, including the same-host shortening and the encoded forms. The error text was invalid reference. That is the same error Parse already returns for a :// prefix. A caller that only prints the error string cannot tell a scheme rejection from a .. rejection. The input has to be kept beside the error.
The check is a search for a path segment equal to .. after URL decoding and before path.Join. It is not a search for those two characters anywhere in the reference. The comparison showed the difference on three inputs that still parsed on v2.4.2:
example.com/myimage:v1..2 host=example.com object=v1..2
example.com/./image:latest host=example.com locator=example.com/image
example.com/foo/..../image:latest host=example.com locator=example.com/foo/..../image
example.com/foo/..bar/image:latest host=example.com locator=example.com/foo/..bar/image
A tag may contain ... The split that removes the tag happens before the segment check, so v1..2 is an object, not a path segment. A single dot is not ... path.Join still removes it, which is why example.com/./image:latest normalized to example.com/image:latest on both the fixed and unfixed modules. Four dots, and a segment that merely begins with two dots, are not the segment ... The new check leaves them alone.
ParseDockerRef does not agree with that leftover set. On distribution/reference v0.6.0 it rejected ./, ...., and ..bar as invalid reference format, and it accepted example.com/myimage:v1..2. It also rejected every .. path component. For example.com/%2E%2E/other/image:latest the error was not the generic format error. It was invalid reference format: repository name (%2E%2E/other/image) must be lowercase. The uppercase percent-encoding took the case check. The lowercase form %2e%2e took the format check. Both were rejections. Neither is the containerd rewrite, and neither is evidence that containerd’s own parser decoded the same string and changed the host. That second fact showed up only when the containerd module was called.
A control reference, docker.io/library/redis:latest, parsed on every module tested, with host docker.io and object latest. http://example.com/library/redis:latest was invalid reference on every containerd module, because of the scheme check that predates the .. change. A passing parse of an ordinary name, or a rejection of a URL scheme, says nothing about ...
A release that fixed the disk advisory did not gain this check
containerd 2.4.2, 2.3.7, and 2.2.10 were published on 13 October 2026. Each of those notes lists the .. hardening under image distribution. The parser results match the notes: v2.4.1, v2.3.6, and v2.2.9 still rewrote the host, and the next patch on each of those three lines rejected the component.
containerd 2.0.14 was published the same day. Its notes list GHSA-hc94-p972-ch66, the unbounded layer-stream advisory, and they do not list the .. hardening. The v2.0.14 parser agreed with that omission. example.com/../other/image:latest still returned host other. Treating 2.0.14 as the fixed point for every 13 October hardening item will accept a module that still rewrites the registry. The disk advisory and the reference check are not one patch set. GHSA-hc94-p972-ch66 names 2.0.14, 2.2.10, 2.3.7, and 2.4.2 as patched for the unbounded stream. Only the last three of those also rejected .. in this comparison.
The other 13 October crash advisories are narrower still. CVE-2026-95839 and GHSA-8647-2jxj-p2rc name 2.2, 2.3, and 2.4 ranges. They do not name 1.7 or 2.0. This comparison did not build a malformed manifest, did not send a crafted Content-Length, and did not pull a layer. Those advisories are a reason to leave an affected 2.x daemon. They are not a test of Parse, and a 1.7 daemon being outside those ranges is not a reason to treat its reference parser as the 2.4.2 parser.
v1.7.24 rewrote the host in the same way v2.4.1 did, including the encoded form and the host:port form. The 1.7 release branch, read at the time of the comparison, still joins the host and the path without a .. segment check. A floor such as “2.4.2 or newer” therefore answers the wrong question for every line except 2.4. It calls a fixed 2.2.10 and a fixed 2.3.7 old, and it says nothing about 1.7.24 or 2.0.14.
The installed client on the Debian 13 host used for the module build printed 1.7.24~ds1 and package version 1.7.24~ds1-6+deb13u1. The Debian changelog for that package does not mention the .. check. Its newest entry predates the July 2026 parser change. The client could not read a server version, because it could not connect to the local daemon socket, so this is not a claim about a running daemon’s binary. It is a claim about the upstream module at the version the package name identifies, plus the absence of that check in the installed changelog. A distribution can backport a parser change into a package whose version string still says 1.7.24. This package’s changelog does not record that backport. If a later package does, repeat the module or binary comparison against that package rather than trusting the upstream tag alone.
Verify the parser you will link
Save the following program as parse_ref.go next to a module file that requires the containerd module you intend to ship. The import path is the v2 path. For a 1.7 module, import github.com/containerd/containerd/reference instead. The rest of the program is the same, because both packages expose Parse, Hostname, and String.
package main
import (
"fmt"
"os"
ref "github.com/containerd/containerd/v2/pkg/reference"
)
func main() {
for _, s := range os.Args[1:] {
spec, err := ref.Parse(s)
if err != nil {
fmt.Printf("ERR\t%s\t%s\n", s, err)
continue
}
fmt.Printf("OK\t%s\thost=%s\tlocator=%s\n", s, spec.Hostname(), spec.Locator)
}
}
Pin the module to the version the binary will actually link. go list -m after the build is the version the results describe. A go.mod that says v2.4.2 while the build used a replace directive, or a vendor directory from an older commit, is a different parser. Run the program on a normal name, on the host-rewrite form, on the encoded form, and on the tag form:
go run parse_ref.go \
docker.io/library/redis:latest \
example.com/../other/image:latest \
example.com/%2e%2e/other/image:latest \
example.com/myimage:v1..2
On v2.4.2, v2.3.7, and v2.2.10 the first input printed host docker.io, the next two printed ERR and invalid reference, and the tag input printed host example.com with object v1..2. On v2.4.1, v2.3.6, v2.2.9, v2.0.14, and v1.7.24 the host-rewrite input printed host other and locator other/image. The encoded input did the same. Those two lines are the result that matters. An exit code alone is not, because the program above exits 0 even when Parse returns an error. The text is the result.
If the job’s reference is handed to CRI PullImage, also run ParseDockerRef on the same strings. A CRI rejection means the distribution parser refused the name before the pull. It does not mean reference.Parse would have refused it, and it does not mean a ctr or transfer-service caller using the raw string is covered. The v2.4.1 tree calls distribution.ParseDockerRef at the start of CRI PullImage, then uses the normalized string. The docker resolver, used once a reference reaches the remote, calls reference.Parse without that distribution parse. A reference that never passed through CRI still takes the second function.
Do not turn this check into a pull. The parser result is available without contacting a registry. Pulling a reference whose host was rewritten would ask a different registry for an image, which is the outcome the check is there to avoid, and it is not required to see the rewrite. The October crash advisories are also not demonstrated by this program. A successful parse does not say the daemon is patched for CVE-2026-95839, for the parallel-fetch Content-Length panic, or for the unbounded layer stream.
A string search still has a use, as a cheap inventory pass, if it decodes percent-escapes before it looks for a .. segment and if it ignores the tag. It will miss the cases the new check also misses, and it will not tell you whether the linked module rewrites or rejects. Keep it as a filter for submitted text. Keep the module call as the test of the parser that will run.
What to change, and what not to infer
Move a 2.4 daemon to 2.4.2, a 2.3 daemon to 2.3.7, or a 2.2 daemon to 2.2.10 if the goal is this parser check. Those are the first tags on those lines that rejected the component in this comparison, and they are the tags whose notes list the hardening. 2.0.14 is the wrong destination if the goal is the parser check. It is a documented destination for GHSA-hc94-p972-ch66. Leaving the 2.0 line for a later minor is a separate upgrade, with its own skew and plugin questions, and this parse does not answer those questions.
For a caller that builds a reference and passes it to the transfer service or to client.Pull, reject a decoded .. path segment before the call when the linked module is one of the versions that still rewrite. Do that even if a Kubernetes admission webhook already uses distribution’s grammar. The webhook covers the CRI path. It does not cover a controller, a CI job, or a ctr images pull that never calls ParseDockerRef.
Do not treat a tag containing .. as the bug the October check closed. v2.4.2 accepts example.com/myimage:v1..2. Do not treat a cleaned single-dot path as proof the new check ran. Both the fixed and unfixed modules normalized example.com/./image:latest to example.com/image:latest. The distinguishing inputs are a path segment that is exactly .. and the percent-encoded form of that segment.
The result is narrow. containerd’s reference parser at v2.4.1, v2.3.6, v2.2.9, v2.0.14, and v1.7.24 turns example.com/../other/image:latest into a locator whose host is other, and it does the same for the percent-encoded form and for a host that includes a port. v2.4.2, v2.3.7, and v2.2.10 reject those inputs with invalid reference and also reject a .. segment that would only have shortened the path on the same host. A tag containing .. still parses. distribution’s ParseDockerRef already rejected the path form, including on the v0.6.0 module that v2.4.1 requires, so a CRI error is not the same observation as a resolver rewrite. 2.0.14 fixed a different advisory and still rewrites.