Skip to content

Percent-encoded container names: Go routes on the decoded path, Rust/TS on the raw path #53

Closed

Description

@abienkowski

Problem

The three implementations disagree about percent-encoded paths. In all three, the router and the daemon can read a request differently:

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 rejects a b). Image references, including :tag@sha256:… and registry:port/…, are sent unescaped, and docker rm a/b sends a raw /.
  • Network names are the exception: the daemon accepts a b and net%x, and the CLI sends GET|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 decoded r.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.qnt gains a daemon-decoding table and a routerSeesWhatDaemonSees property: any path the router does not deny reads the same before and after decoding. A router_pre53 instance 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 a GET with an encoded query → 200.
    • Row names are shared between the spec and the language tests.

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

Which implementation(s) would this affect?

  • Go (handler routes on the escaped path; router check)
  • Rust
  • TypeScript
  • Quint specification (spec/router.qnt)

Activity

  1. added
    Priority: P3Added to issues and PRs relating to a low severity bugs.
    Type: BugAdded to issues and PRs if they are addressing a bug
    on Oct 7, 2026
  2. abienkowski commented on Oct 7, 2026

    @abienkowski
    CollaboratorAuthor

    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. So DELETE /v%D9%A1/containers/foo reaches 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 with url.Parse(...).Path confirmed 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.

  3. abienkowski commented on Oct 7, 2026

    @abienkowski
    CollaboratorAuthor

    Follow-up now that #57 is merged (204f24a): Go denies DELETE /v%D9%A1/containers/foo (403, not forwarded), because only ASCII digits count as version digits. That was confirmed with a throwaway httptest probe against the Go Handler, 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.

  4. added
    Status: Break ChangeAdded to a PR or issue that would cause a breaking change
    Priority: P2Added to issues and PRs relating to a medium severity bugs.
    and removed
    Priority: P3Added to issues and PRs relating to a low severity bugs.
    on Oct 7, 2026
  5. abienkowski commented on Oct 7, 2026

    @abienkowski
    CollaboratorAuthor

    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. Labelled Status: Break Change because 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.

  6. abienkowski commented on Oct 7, 2026

    @abienkowski
    CollaboratorAuthor

    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 %. DELETE and 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".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Priority: P2Added to issues and PRs relating to a medium severity bugs.Status: Break ChangeAdded to a PR or issue that would cause a breaking changeType: BugAdded to issues and PRs if they are addressing a bug

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions