Repository navigation
Percent-encoded container names: Go routes on the decoded path, Rust/TS on the raw path #53
Copy link
Copy link
Closed
Labels
Priority: P2Added to issues and PRs relating to a medium severity bugs.Added to issues and PRs relating to a medium severity bugs.Status: Break ChangeAdded to a PR or issue that would cause a breaking changeAdded to a PR or issue that would cause a breaking changeType: BugAdded to issues and PRs if they are addressing a bugAdded to issues and PRs if they are addressing a bug
Description
Problem
The three implementations disagree about percent-encoded paths. In all three, the router and the daemon can read a request differently:
- Go: the handler routes on
r.URL.Path(go/internal/proxy/handler.go:53), which is percent-decoded.DELETE /containers/%2Fbecomes/containers//, the name is empty, and the request is denied (post-Rust extract_container_name treats empty segment as a container name (DELETE /containers/ is forwarded) #48).DELETE /v%D9%A1/containers/fooreaches the router as/v١/…; it was allowed before Go/Rust strip any /v…/ first segment as an API version (/volumes/… routed as a container action) #57. - Rust: hyper's
uri.path()is raw, so the name is the literal%2F. Rust treats that as an unknown container, allows the request and forwards it. - TypeScript: Node's
req.urlis raw. Same outcome as Rust (checked against builtts/dist:DELETE /containers/%2F→ Allow).
The daemon percent-decodes on its own side. So what Rust and TS forward as "a container named %2F", the daemon reads as a request about /.
Evidence: what a real client sends (Docker CLI 29.4.0, wire capture)
%appears in the query string all the time:ps --filter→?filters=%7B%22label%22…,cp→?path=%2Ftmp%2Fa+b%25c,tag→?repo=localhost%3A5000%2Fx%2Fy.%appears in the path only for names that need escaping. Container and volume names are restricted to[a-zA-Z0-9][a-zA-Z0-9_.-](the daemon rejectsa b). Image references, including:tag@sha256:…andregistry:port/…, are sent unescaped, anddocker rm a/bsends a raw/.- Network names are the exception: the daemon accepts
a bandnet%x, and the CLI sendsGET|DELETE /networks/a%20b.
Proposed solution (decided)
Deny any request whose raw path contains %, for every method, GET included. The check looks only at the path, after the query string is removed, so encoded query strings are unaffected. It runs first in Router.route, before the API-version strip and the GET passthrough, in all three languages.
- Go: the handler routes on
r.URL.EscapedPath()instead of the decodedr.URL.Path. Without a%, the two are identical. Go's re-encoding of raw non-ASCII bytes also gets denied, which fails closed. - Rust / TS: add the check on the raw path they already route on.
- Spec:
spec/router.qntgains a daemon-decoding table and arouterSeesWhatDaemonSeesproperty: any path the router does not deny reads the same before and after decoding. Arouter_pre53instance records that the property fails when%is allowed. - Tests:
- Router rows:
%2F,%6A%73%6F%6E(json), lowercase%2f,beacon%2Fstart, after a version strip, an encoded version segment, and GET. - Handler tests in all three, sending encoded requests through the full handler and checking whether they are forwarded, including an encoded query that must still be forwarded.
- Integration:
DELETE /containers/%2F→ 403, and aGETwith an encoded query → 200. - Row names are shared between the spec and the language tests.
- Router rows:
On-the-wire change (Status: Break Change): GET/DELETE /networks/<name> for network names containing spaces or % now get 403 through the proxy.
Alternatives considered
- Route on the decoded path in all three, as Go does today. Rejected. Decoding is where the proxy and daemon interpretations drift (
%2Fsplitting segments, encoded reserved words such as%6A%73%6F%6E=json, encoded version segments). Every decoding rule would need to match the daemon's exactly. No legitimate container or image path needs%, so refusing it removes the whole class. - Deny
%only for non-GET. Rejected. GET is let through without inspecting the path, so encoded GETs add no direct privilege. But "no%in the path" with no exceptions is easier to state, audit and model, and the only cost is network names with spaces or%. - Leave it documented. Rejected. Go router doesn't exclude reserved path segments in extractContainerName (cross-language parity) #24, Rust extract_container_name treats empty segment as a container name (DELETE /containers/ is forwarded) #48 and Go/Rust strip any /v…/ first segment as an API version (/volumes/… routed as a container action) #57 set the precedent that routing divergences get fixed.
Which implementation(s) would this affect?
- Go (handler routes on the escaped path; router check)
- Rust
- TypeScript
- Quint specification (
spec/router.qnt)
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
Priority: P2Added to issues and PRs relating to a medium severity bugs.Added to issues and PRs relating to a medium severity bugs.Status: Break ChangeAdded to a PR or issue that would cause a breaking changeAdded to a PR or issue that would cause a breaking changeType: BugAdded to issues and PRs if they are addressing a bugAdded to issues and PRs if they are addressing a bug
Type
Fields
Priority
None yet
Activity
abienkowski commentedon Oct 7, 2026
A related data point from the #57 work. Go routes
r.URL.Path(go/internal/proxy/handler.go:53), which net/http has already percent-decoded. SoDELETE /v%D9%A1/containers/fooreaches the Go router as/v١/containers/foo, with a raw Arabic-Indic digit. Go's current strip then allows it as a container delete. A probe withurl.Parse(...).Pathconfirmed it. Rust and TS route the raw request target and see%D9%A1. #57 makes Go deny it, because only ASCII digits count toward a version, but this is one more case where decoding before routing changes what the router sees.abienkowski commentedon Oct 7, 2026
Follow-up now that #57 is merged (
204f24a): Go deniesDELETE /v%D9%A1/containers/foo(403, not forwarded), because only ASCII digits count as version digits. That was confirmed with a throwawayhttptestprobe against the GoHandler, but no committed handler-level test pins it. When this issue settles which path form is canonical (decoded or raw), add a handler-level test in all three languages that sends percent-encoded requests through the full handler, not just the router.abienkowski commentedon Oct 7, 2026
Decision recorded (2026-10-07). The proposed solution has changed from "route on the decoded path" to "deny any
%in the request path, for every method", and the issue body is rewritten with the wire-capture evidence and the rejected alternatives. The query string is not affected. LabelledStatus: Break Changebecause network names that contain spaces or%can no longer be inspected or removed through the proxy. Raised to P2, matching #57: like #57, this is a case where the proxy and the daemon read the same request differently.abienkowski commentedon Oct 7, 2026
Correction to the "On-the-wire change" line in the body: only
GET /networks/<name>(inspect) is newly denied for network names that contain a space or%.DELETEand the other network write operations never matched an allow rule. No router has a network route, so they were always denied by default with or without%. The README and the squash message for the fix say "inspected", not "inspected or removed".fix!: deny percent-encoded request paths (#53) (#59)
fix: route image pulls on the query string and normalise image refere…