Skip to content

fix!: deny percent-encoded request paths (#53) - #59

Merged
abienkowski merged 8 commits into
mainfrom
fix/deny-percent-encoded-paths
Oct 7, 2026
Merged

abienkowski merged 8 commits into
mainfrom
fix/deny-percent-encoded-paths

Conversation

@abienkowski

Copy link
Copy Markdown
Collaborator

Description

The three implementations disagreed about percent-encoded paths, and in all of them the proxy could read a request differently from the daemon, which percent-decodes before routing:

Rule: any request whose path contains % is denied with 403 (percent-encoded path not allowed). This applies to every method, GET and HEAD included. It is the first check in Route in all three implementations, before the API-version strip and the GET/HEAD passthrough. The query string is not inspected. Go's handler now routes on r.URL.EscapedPath() instead of the decoded path.

Closes #53

Why this rule (evidence in #53)

A wire capture of Docker CLI 29.4.0 shows:

  • % is routine in query strings: ps --filter, cp, tag.
  • In paths, % appears only for network names that need escaping. Container and volume names are limited to [a-zA-Z0-9][a-zA-Z0-9_.-], and image references are sent unescaped.

Routing on the decoded path (the issue's original proposal) was rejected. It would need every decoding rule to match the daemon's exactly, and no legitimate container or image path needs %.

Breaking change

docker network inspect <name> through the proxy now gets 403 for network names that need percent-encoding (for example a space or %). Inspecting by network ID still works, and other network operations were already denied. Release note: the release pipeline always bumps the patch version and --generate-notes won't surface the BREAKING CHANGE: footer (#54), so state this change by hand in the release notes. Go also denies paths with raw characters it must re-encode, such as non-ASCII bytes or {; the Docker CLI never sends these. Documented in the README "Endpoint Access" section.

Spec first (spec/router.qnt)

  • A daemon-decoding table, and the percent guard as the first step of route.
  • A new property routerSeesWhatDaemonSees: any path the router does not deny reads the same after the daemon decodes it. It ranges over GET, POST and DELETE, which is why the rule covers GET.
  • A new instance router_pre53 (Rust/TS before this fix, routing raw paths), where pre53UnsoundTest asserts that the property fails.
  • Six shared row names, reused by the language tests:
    • percentSlashDeleteDenied
    • percentLowerSlashDeleteDenied
    • percentReservedDeleteDenied (%6A%73%6F%6E = json)
    • percentNameStartDenied
    • percentSubpathStartDenied (a pin)
    • percentGetDenied
  • Non-vacuity: with the guard switched off in router, percentSoundTest and 5 of the 6 rows fail.
quint test spec/listener.qnt --main=listener_locked     11 passing
quint test spec/listener.qnt --main=listener_unlocked   10 passing
quint test spec/router.qnt --main=router                16 passing
quint test spec/router.qnt --main=router_pre48           7 passing
quint test spec/router.qnt --main=router_pre53           7 passing

Tests (written first; RED commits fc17c2e, 32aacf6)

  • Router table, identical in Go, Rust and TS:
    • The 6 spec rows.
    • DELETE /v1.45/containers/foo%25, DELETE /v%31/containers/foo, GET /networks/a%20b and HEAD /containers/%2F, all → Deny.
    • The control DELETE /containers/foo → Allow.
    • Every Deny row also asserts the reason contains percent-encoded. So before the fix every deny row failed for the Percent-encoded container names: Go routes on the decoded path, Rust/TS on the raw path #53 reason, including the rows that some other rule already denied.
    • TS-only: query rows GET /containers/json?filters=%7B%7D and DELETE /containers/foo?force=%31 → Allow. Only TS's router receives the query.
  • Handler tests in all three languages, through the full handler with a recording transport:
    • DELETE /containers/foo%20bar → 403, not forwarded. This catches a Go fix that only touches the router while the handler still decodes.
    • DELETE /containers/%2F → 403, not forwarded.
    • An encoded-query GET /containers/json?filters=… → forwarded, with the query unchanged.
  • deploy/test.sh, 36 → 39 checks:
    • DELETE /containers/no-such%20x → 403
    • DELETE /containers/%2F → 403
    • an encoded-query GET /v1.45/containers/json?filters=… → 200
  • Before the fix, integration: Go 38 passed / 1 failed; Rust and TS 37 passed / 2 failed. The encoded-query GET passed everywhere.

Docker CLI smoke test (Go proxy → OrbStack daemon)

  • docker ps --filter status=running → exit 0.
  • docker network inspect 'a b' → percent-encoded path not allowed.
  • docker version → exit 0.

Follow-ups recorded

Squash-merge required: the RED commits fail by design.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Implementation(s) changed

  • Go
  • Rust
  • TypeScript
  • Quint specification
  • CI / infrastructure (deploy/test.sh, Makefile test-spec)

Testing

  • Unit tests pass (make test-all): Go 107, Rust 144, TS 161 (160 pass, 1 skipped)
  • Integration tests pass:
    == make test-integration
      PASS: DELETE /containers/no-such%20x -> 403 (percent-encoded path)
      PASS: DELETE /containers/%2F -> 403 (percent-encoded path)
      PASS: GET /v1.45/containers/json?filters=<encoded> -> 200 (query not inspected)
      ALL 39 TESTS PASSED
    == make test-integration-rs
      PASS: DELETE /containers/no-such%20x -> 403 (percent-encoded path)
      PASS: DELETE /containers/%2F -> 403 (percent-encoded path)
      PASS: GET /v1.45/containers/json?filters=<encoded> -> 200 (query not inspected)
      ALL 39 TESTS PASSED
    == make test-integration-ts
      PASS: DELETE /containers/no-such%20x -> 403 (percent-encoded path)
      PASS: DELETE /containers/%2F -> 403 (percent-encoded path)
      PASS: GET /v1.45/containers/json?filters=<encoded> -> 200 (query not inspected)
      ALL 39 TESTS PASSED
    
  • Quint: make test-spec (above). make verify exit 0 locally (Task 1) and runs in CI.
  • Lint passes: make lint-all exits 0
  • New tests added for the change

Checklist

  • I have read CONTRIBUTING.md
  • My code follows the project's coding style
  • I have updated documentation as needed (README Endpoint Access, spec/README.md, AGENTS.md counts)

The Docker daemon decodes the request path before routing, so an escaped
path such as DELETE /containers/%2F or /containers/foo%20bar reached a
different endpoint or container than the one the proxy's router checked.

The router now denies any request whose path, with the query string
removed, contains '%'. It is the first check in Route, ahead of the
API-version strip and the GET/HEAD passthrough, and applies to every
method. The query string is never inspected, so encoded filters still
work. The Go handler routes on r.URL.EscapedPath() instead of the
already-decoded r.URL.Path, and logs/audits that same path.

BREAKING CHANGE: requests whose path contains '%' are denied with 403 for every method; network names containing spaces or '%' can no longer be inspected or removed through the proxy.
@abienkowski abienkowski added Priority: P2 Added to issues and PRs relating to a medium severity bugs. Status: Break Change Added to a PR or issue that would cause a breaking change Type: Bug Added to issues and PRs if they are addressing a bug labels Oct 7, 2026
@abienkowski abienkowski self-assigned this Oct 7, 2026
@abienkowski
abienkowski merged commit b2cf1b2 into main Oct 7, 2026
6 checks passed
@abienkowski
abienkowski deleted the fix/deny-percent-encoded-paths branch October 7, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority: P2 Added to issues and PRs relating to a medium severity bugs. Status: Break Change Added to a PR or issue that would cause a breaking change Type: Bug Added to issues and PRs if they are addressing a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant