From 6c21c76d54587edc4b7dee3e454c65083d14fed7 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 15:12:10 -0400 Subject: [PATCH 1/8] spec: model percent-encoded paths and the daemon's decoding (#53) --- AGENTS.md | 2 +- Makefile | 1 + spec/README.md | 44 ++++++++++++----- spec/router.qnt | 128 ++++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 141 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ce3dd2..27b8ae1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ deploy/ — Docker Compose + integration tests - Rust: 140 unit tests (main/listener: 23, policy: 15, middleware: 50, proxy: 42, handler: 4, audit: 4, transport: 2) - TypeScript: 157 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 41, proxy: 29, policy: 10, handler: 6, shutdown: 5, transport: 5, audit: 4) - Integration, per implementation: 36 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose) -- Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests (instances `listener_locked`, `listener_unlocked`) and the `spec/router.qnt` `run` tests (instances `router`, `router_pre48`) +- Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests (instances `listener_locked`, `listener_unlocked`) and the `spec/router.qnt` `run` tests (instances `router`, `router_pre48`, `router_pre53`) ## Test Conventions - Go: stdlib `testing` package, `go test ./...` diff --git a/Makefile b/Makefile index 109e3ce..d98aa56 100644 --- a/Makefile +++ b/Makefile @@ -84,6 +84,7 @@ test-spec: $(QUINT) test $(LISTENER_SPEC) --main=listener_unlocked $(QUINT) test $(ROUTER_SPEC) --main=router $(QUINT) test $(ROUTER_SPEC) --main=router_pre48 + $(QUINT) test $(ROUTER_SPEC) --main=router_pre53 verify-ts: $(QUINT) run $(SPEC) --max-steps=50 --invariants allInvariants --backend typescript diff --git a/spec/README.md b/spec/README.md index ab789ca..f3d37f1 100644 --- a/spec/README.md +++ b/spec/README.md @@ -9,7 +9,7 @@ This directory contains a [Quint](https://quint-lang.org/) formal specification | `docker_socket_policy.qnt` | Request-handling spec: policy types, state machine, endpoint routing table, 9 invariants (6 P0 / 3 P1), 6 attack scenario simulations | | `listener.qnt` | Listening-socket startup: flag/group selection, existing-path checks, single-instance lock, 6 invariants, one `run` test per design-table row. Instances `listener_locked` (Go, Rust) and `listener_unlocked` (TypeScript) | | `listener-design.md` | Design of the listening socket (dockerd parity) that `listener.qnt` models | -| `router.qnt` | Container-name extraction and the container-lifecycle branch of the router only, not the full router. One `run` test per table row. Instances `router` (the extraction rule of Go, TypeScript, and Rust after #48) and `router_pre48` (Rust's rule before #48) | +| `router.qnt` | The percent-encoded path deny, container-name extraction and the container-lifecycle branch of the router only, not the full router. The daemon's percent-decoding as a fixed table. One `run` test per table row. Instances `router` (Go, TypeScript, and Rust after #48 and #53), `router_pre48` (Rust's extraction rule before #48) and `router_pre53` (Rust and TypeScript before #53, routing on the raw path) | ## How to Run @@ -34,10 +34,11 @@ quint run spec/listener.qnt --main=listener_locked --max-steps=30 --invariant al quint test spec/listener.qnt --main=listener_locked quint test spec/listener.qnt --main=listener_unlocked -# Router model: typecheck, run the table tests on both instances +# Router model: typecheck, run the table tests on all three instances quint typecheck spec/router.qnt quint test spec/router.qnt --main=router quint test spec/router.qnt --main=router_pre48 +quint test spec/router.qnt --main=router_pre53 # Formal model-checking via Apalache (exhaustive, requires Java) quint verify --max-steps=10 --invariants allInvariants spec/docker_socket_policy.qnt @@ -83,20 +84,35 @@ quint run spec/listener.qnt --main=listener_unlocked --max-steps=30 --invariant ### Router path parsing (`router.qnt`) -A path is a list of segments, so `/containers/` is `["containers", ""]` and `/containers//start` is `["containers", "", "start"]`. The second segment is a container name unless it is empty or one of `create`, `json`, `exec`. `lifecycleOnlyTargetsRealNames` checks every method in `GET`, `POST`, `DELETE` against every path of 1 to 3 segments. It holds when each lifecycle allow (`allowKnown` or `allowUnknown`) targets a non-empty, non-reserved name. `soundTest` asserts it on `router`. +A path is a list of raw, still percent-encoded segments with the query string removed, so `/containers/` is `["containers", ""]`, `/containers//start` is `["containers", "", "start"]` and `/containers/%2F` is `["containers", "%2F"]`. The first check in `route` denies any path with an encoded segment, for every method (#53). After it, the second segment is a container name unless it is empty or one of `create`, `json`, `exec`. Both properties check every method in `GET`, `POST`, `DELETE` against every path of 1 to 3 segments: + +- `lifecycleOnlyTargetsRealNames` holds when each lifecycle allow (`allowKnown` or `allowUnknown`) targets a non-empty, non-reserved name. +- `routerSeesWhatDaemonSees` holds when every request the router does not deny reads the same to the daemon: decoding leaves its path unchanged. The daemon's decoding is the table `DAEMON_DECODING`: `%2F` and `%2f` decode to `["", ""]` (a `/` splits the segment), `%6A%73%6F%6E` to `["json"]`, and `beacon%2Fstart` to `["beacon", "start"]`. + +`soundTest` asserts both on `router`. | Row (`run Test`) | Request | Outcome | Instances | |-----|---------|---------|-----------| | `emptyNameDeleteDenied` | `DELETE /containers/` | deny | `router` | | `emptyNameStartDenied` | `POST /containers//start` | deny | `router` | -| `emptyNameGetAllowed` | `GET /containers/` | allow (passthrough) | both | -| `reservedJsonDeleteDenied` | `DELETE /containers/json` | deny | both | -| `reservedCreateDeleteDenied` | `DELETE /containers/create` | deny | both | -| `reservedExecDeleteDenied` | `DELETE /containers/exec` | deny | both | -| `realNameDeleteAllowed` | `DELETE /containers/mycontainer` | allow (unknown container) | both | -| `reservedInSubpathAllowed` | `GET /containers/mycontainer/json` | allow | both | +| `emptyNameGetAllowed` | `GET /containers/` | allow (passthrough) | all | +| `reservedJsonDeleteDenied` | `DELETE /containers/json` | deny | all | +| `reservedCreateDeleteDenied` | `DELETE /containers/create` | deny | all | +| `reservedExecDeleteDenied` | `DELETE /containers/exec` | deny | all | +| `realNameDeleteAllowed` | `DELETE /containers/mycontainer` | allow (unknown container) | all | +| `reservedInSubpathAllowed` | `GET /containers/mycontainer/json` | allow | all | + +Rows for the percent-encoded path deny ([#53](https://github.com/ChainSafe/docker-socket-policy/issues/53)), all on `router`: + +| Row (`run Test`) | Request | Outcome | The daemon reads | +|-----|---------|---------|------------------| +| `percentSlashDeleteDenied` | `DELETE /containers/%2F` | deny | `DELETE /containers//` | +| `percentLowerSlashDeleteDenied` | `DELETE /containers/%2f` | deny | `DELETE /containers//` | +| `percentReservedDeleteDenied` | `DELETE /containers/%6A%73%6F%6E` | deny | `DELETE /containers/json` | +| `percentSubpathStartDenied` | `POST /containers/beacon%2Fstart` | deny | `POST /containers/beacon/start` | +| `percentGetDenied` | `GET /containers/%2F` | deny | `GET /containers//` | -Each implementation's router tests use the same row names in comments (added with the #48 fix). `router_pre48` runs `pre48UnsoundTest`, which asserts that the property fails and that both `emptyName*Denied` requests are allowed as an unknown container ([#48](https://github.com/ChainSafe/docker-socket-policy/issues/48)). +Each implementation's router tests use the same row names in comments (added with the #48 and #53 fixes). `router_pre48` runs `pre48UnsoundTest`, which asserts that `lifecycleOnlyTargetsRealNames` fails and that both `emptyName*Denied` requests are allowed as an unknown container ([#48](https://github.com/ChainSafe/docker-socket-policy/issues/48)). `router_pre53` runs `pre53UnsoundTest`, which asserts that `routerSeesWhatDaemonSees` fails, that `DELETE /containers/%2F` is allowed as an unknown container, and that `GET /containers/%2F` is allowed through the passthrough. ### Modeling Notes @@ -108,11 +124,15 @@ Two invariants are structurally tautological within the Quint model — they can - **`listener.qnt` checks the design, not the code.** Nothing in the model is derived from the Go, Rust or TypeScript sources. Conformance rests on each implementation's unit and integration tests, which carry the same names as the Quint `run`s (`groupDefaultPresent`, `pathStaleReplaced`, …) so every design-table row can be traced across all four. `raceWithoutLockTest` (in `listener_unlocked`) is the formal record of the TypeScript gap: Node has no `flock`, so two TypeScript instances starting together can orphan one another's socket ([#46](https://github.com/ChainSafe/docker-socket-policy/issues/46)). - **`lifecycleOnlyTargetsRealNames` is close to a tautology.** `route` only allows by name when `hasContainerName` holds, and `hasContainerName` is nearly the property itself. It is not vacuous: on `router_pre48`, where an empty segment counts as a name, the property fails, and `pre48UnsoundTest` asserts that. Most of the evidence comes from the table rows, whose names the language router tests reuse (added with the #48 fix). -- **`router.qnt` is not the full router.** `route` models only the container-lifecycle branch. It leaves out the checks that the routers run before that branch, such as the exec, build and commit denials and `POST /containers/create`. For paths that those checks catch, the model's outcome can differ from what an implementation does: +- **`routerSeesWhatDaemonSees` is also close to a tautology on `router`.** The percent check denies exactly the paths with a segment that is a key of `DAEMON_DECODING`, and those are exactly the paths that decoding changes. It is not vacuous: on `router_pre53` the property fails, and `pre53UnsoundTest` asserts that. Setting `router`'s `ALLOW_PERCENT` to `true` makes `soundTest` and four of the five `percent*` rows fail. `percentSubpathStartDenied` still passes then, because the lifecycle branch denies `POST` when the last segment (`beacon%2Fstart`) is not an action. That row pins the outcome but does not discriminate the percent check. `lifecycleOnlyTargetsRealNames` holds on `router_pre53` too: `%2F` is a non-empty, non-reserved segment. That is why #53 needs a property that compares the router's reading with the daemon's. +- **Decoding is a fixed table.** Quint cannot inspect the characters of a string, so the model cannot find a `%` or decode one. `DAEMON_DECODING` lists the encoded tokens in the path universe and the segments the daemon reads them as; a segment is encoded iff it is a key. The implementations check for any `%` in the path. The table covers the shapes that matter: an encoded `/` (upper and lower case) that splits a segment, a fully encoded reserved word, and an encoded `/` inside a name that adds a lifecycle action. +- **The query string is outside the model.** Paths are segment lists with the query string already removed, so the model says nothing about `%` in the query. The implementations never inspect the query string for this rule; encoded queries such as `?filters=%7B…%7D` are routine and must still be forwarded. That is pinned by the language handler tests and the integration tests, not by the model. +- **GET is covered.** The percent check runs before the GET passthrough, and `routerSeesWhatDaemonSees` ranges over `GET` as well as `POST` and `DELETE`. On `router_pre53` the passthrough lets `GET /containers/%2F` through, and the property fails for it too. +- **`router.qnt` is not the full router.** `route` models the percent check, which runs first in all three routers, and the container-lifecycle branch. It leaves out the API-version strip and the checks that the routers run between those two, such as the exec, build and commit denials and `POST /containers/create`. For paths that those checks catch, the model's outcome can differ from what an implementation does: - `DELETE /containers/mycontainer/exec` is `allowUnknown` in the model. Rust and TypeScript deny it with their exec checks. Go's exec check matches `exec` only in the name position, so Go sends this path down the lifecycle branch and allows it. This divergence between the languages belongs to the same family as [#24](https://github.com/ChainSafe/docker-socket-policy/issues/24) and [#48](https://github.com/ChainSafe/docker-socket-policy/issues/48). It is outside this model's scope and is tracked with the other routing divergences. - `POST /containers/create` is `deny` in the model, but in reality it routes to container create. - Only the property and the table rows are claims about the code. `reservedExecDeleteDenied` (`DELETE /containers/exec`) is decided in all three implementations by the exec check, not by the reserved set. Its language tests would therefore not catch `exec` being dropped from the reserved set. + Only the properties and the table rows are claims about the code. `reservedExecDeleteDenied` (`DELETE /containers/exec`) is decided in all three implementations by the exec check, not by the reserved set. Its language tests would therefore not catch `exec` being dropped from the reserved set. - **HEAD is outside the model.** `METHODS` is `GET`, `POST`, `DELETE`. The real lifecycle branches agree on those three methods only. For `HEAD /containers/x`, Go and TypeScript fall through to the GET/HEAD passthrough and allow it. Rust denies it in the lifecycle branch. - **Listener fault bias.** `step` crashes an instance on 1 in 10 draws instead of half of all steps, so random runs actually interleave live instances. Every crash stays reachable from every phase, so the reachable state space is unchanged. diff --git a/spec/router.qnt b/spec/router.qnt index 9a5da86..efad563 100644 --- a/spec/router.qnt +++ b/spec/router.qnt @@ -1,24 +1,64 @@ // ─── Module: router_model ─────────────────────────────────────────────── // -// How the router extracts a container name from a request path, and how -// the container-lifecycle branch routes on it (#48). Mirrors +// How the router extracts a container name from a request path, how the +// container-lifecycle branch routes on it (#48), and the percent-encoded +// path deny that runs first in the router (#53). Mirrors // `extract_container_name` and the lifecycle branch of `Router::route` in -// rs/src/proxy.rs. This is not the full router: checks that run before the -// lifecycle branch (exec, build, commit, POST /containers/create) are left -// out. Go (`extractContainerName`) and TypeScript (`extractContainerName`) -// have the same lifecycle branch for GET, POST and DELETE. HEAD differs: -// Go and TypeScript allow it through the passthrough, Rust denies it. HEAD -// is outside METHODS. +// rs/src/proxy.rs. This is not the full router: the percent check is +// modeled, but the API-version strip and the checks that run between the +// percent check and the lifecycle branch (exec, build, commit, +// POST /containers/create) are left out. Go (`extractContainerName`) and +// TypeScript (`extractContainerName`) have the same lifecycle branch for +// GET, POST and DELETE. HEAD differs: Go and TypeScript allow it through +// the passthrough, Rust denies it. HEAD is outside METHODS. // -// A path is a list of segments: `DELETE /containers/` is -// ["containers", ""], `POST /containers//start` is ["containers", "", "start"]. +// A path is a list of raw (still percent-encoded) segments, with the query +// string removed: `DELETE /containers/` is ["containers", ""], +// `POST /containers//start` is ["containers", "", "start"], and +// `DELETE /containers/%2F` is ["containers", "%2F"]. +// +// The daemon percent-decodes the path before it routes. The model cannot +// inspect characters, so decoding is a fixed table (DAEMON_DECODING) from +// encoded tokens to the segments the daemon sees: `/containers/%2F` reaches +// the daemon as `/containers//`, i.e. ["containers", "", ""]. // // ALLOW_EMPTY_NAME = true models Rust before #48, where an empty second // segment counted as a container name. false models Go, TypeScript, and // Rust after the fix. +// +// ALLOW_PERCENT = true models Rust and TypeScript before #53, which routed +// on the raw path and let encoded segments through. false models all three +// after the fix: any `%` in the path is denied, for every method. module router_model { const ALLOW_EMPTY_NAME: bool + const ALLOW_PERCENT: bool + + // ─── Daemon decoding ──────────────────────────────────────────────── + + // Every encoded token in the universe, and the segments the daemon reads + // it as. A segment is encoded iff it is a key; any other segment decodes + // to itself. + pure val DAEMON_DECODING: str -> List[str] = Map( + "%2F" -> ["", ""], + "%2f" -> ["", ""], + "%6A%73%6F%6E" -> ["json"], + "beacon%2Fstart" -> ["beacon", "start"], + ) + + pure def isEncoded(seg: str): bool = + DAEMON_DECODING.keys().contains(seg) + + pure def hasEncodedSeg(segs: List[str]): bool = + segs.indices().exists(i => isEncoded(segs[i])) + + pure def decodeSeg(seg: str): List[str] = + if (isEncoded(seg)) DAEMON_DECODING.get(seg) else [seg] + + pure def decodePath(segs: List[str]): List[str] = + segs.foldl([], (acc, s) => acc.concat(decodeSeg(s))) + + // ─── Router ───────────────────────────────────────────────────────── pure val RESERVED = Set("create", "json", "exec") pure val KNOWN_SERVICES = Set("beacon") @@ -45,8 +85,10 @@ module router_model { pure def routeByName(name: str): str = if (KNOWN_SERVICES.contains(name)) "allowKnown" else "allowUnknown" + // The percent check (#53) comes first, before the GET passthrough. pure def route(method: str, segs: List[str]): str = - if (hasContainerName(segs)) { + if (not(ALLOW_PERCENT) and hasEncodedSeg(segs)) "deny" + else if (hasContainerName(segs)) { val name = containerNameOf(segs) if (method == "POST" and BY_NAME_ACTIONS.contains(lastSeg(segs))) routeByName(name) else if (method == "POST" and DENIED_ACTIONS.contains(lastSeg(segs))) "deny" @@ -59,7 +101,10 @@ module router_model { // ─── Path universe ────────────────────────────────────────────────── - pure val WORDS = Set("containers", "", "json", "create", "exec", "mycontainer", "beacon", "start") + pure val WORDS = Set( + "containers", "", "json", "create", "exec", "mycontainer", "beacon", "start", + "%2F", "%2f", "%6A%73%6F%6E", "beacon%2Fstart", + ) pure val METHODS = Set("GET", "POST", "DELETE") pure val PATHS: Set[List[str]] = @@ -67,7 +112,7 @@ module router_model { .union(tuples(WORDS, WORDS).map(t => [t._1, t._2])) .union(tuples(WORDS, WORDS, WORDS).map(t => [t._1, t._2, t._3])) - // ─── Property ─────────────────────────────────────────────────────── + // ─── Properties ───────────────────────────────────────────────────── // A lifecycle allow only ever targets a real, non-reserved name. pure val lifecycleOnlyTargetsRealNames: bool = @@ -79,10 +124,18 @@ module router_model { } )) + // Any request the router does not deny reads the same to the daemon: + // decoding leaves its path unchanged (#53). Ranges over GET too. + pure val routerSeesWhatDaemonSees: bool = + METHODS.forall(m => PATHS.forall(segs => + route(m, segs) != "deny" implies decodePath(segs) == segs + )) + // ─── Tests: one run per table row ─────────────────────────────────── // - // Rows whose outcome does not depend on ALLOW_EMPTY_NAME run on both - // instances. The two rows that do, and soundTest, live in `router`. + // Rows whose outcome depends on neither ALLOW_EMPTY_NAME nor + // ALLOW_PERCENT run on every instance. The rows that do, and soundTest, + // live in `router`. run emptyNameGetAllowedTest = assert(route("GET", ["containers", ""]) == "allowRead") @@ -105,10 +158,10 @@ module router_model { // ─── Instances ────────────────────────────────────────────────────────── -// The extraction rule of Go, TypeScript, and Rust after #48: an empty -// segment is not a name. +// Go, TypeScript, and Rust after #48 and #53: an empty segment is not a +// name, and a percent-encoded path is denied. module router { - import router_model(ALLOW_EMPTY_NAME = false).* + import router_model(ALLOW_EMPTY_NAME = false, ALLOW_PERCENT = false).* run emptyNameDeleteDeniedTest = assert(route("DELETE", ["containers", ""]) == "deny") @@ -116,14 +169,33 @@ module router { run emptyNameStartDeniedTest = assert(route("POST", ["containers", "", "start"]) == "deny") + run percentSlashDeleteDeniedTest = + assert(route("DELETE", ["containers", "%2F"]) == "deny") + + run percentLowerSlashDeleteDeniedTest = + assert(route("DELETE", ["containers", "%2f"]) == "deny") + + run percentReservedDeleteDeniedTest = + assert(route("DELETE", ["containers", "%6A%73%6F%6E"]) == "deny") + + run percentSubpathStartDeniedTest = + assert(route("POST", ["containers", "beacon%2Fstart"]) == "deny") + + run percentGetDeniedTest = + assert(route("GET", ["containers", "%2F"]) == "deny") + run soundTest = - assert(lifecycleOnlyTargetsRealNames) + assert(and { + lifecycleOnlyTargetsRealNames, + routerSeesWhatDaemonSees, + }) } // Rust before #48: `/containers/` and `/containers//start` reach -// route_by_name("") and are allowed as an unknown container. +// route_by_name("") and are allowed as an unknown container. The percent +// check is on, so that this instance isolates #48. module router_pre48 { - import router_model(ALLOW_EMPTY_NAME = true).* + import router_model(ALLOW_EMPTY_NAME = true, ALLOW_PERCENT = false).* run pre48UnsoundTest = assert(and { @@ -132,3 +204,17 @@ module router_pre48 { route("POST", ["containers", "", "start"]) == "allowUnknown", }) } + +// Rust and TypeScript before #53: they routed on the raw path, so an +// encoded name such as `%2F` counted as an unknown container and the +// request was forwarded. The daemon decodes it and reads a different path. +module router_pre53 { + import router_model(ALLOW_EMPTY_NAME = false, ALLOW_PERCENT = true).* + + run pre53UnsoundTest = + assert(and { + not(routerSeesWhatDaemonSees), + route("DELETE", ["containers", "%2F"]) == "allowUnknown", + route("GET", ["containers", "%2F"]) == "allowRead", + }) +} From 179253150af78693ed2efdb5a4b96cae7637b365 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 15:16:04 -0400 Subject: [PATCH 2/8] spec: add a discriminating percent POST row (#53) --- spec/README.md | 10 ++++++---- spec/router.qnt | 26 ++++++++++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/spec/README.md b/spec/README.md index f3d37f1..13fef2b 100644 --- a/spec/README.md +++ b/spec/README.md @@ -89,7 +89,7 @@ A path is a list of raw, still percent-encoded segments with the query string re - `lifecycleOnlyTargetsRealNames` holds when each lifecycle allow (`allowKnown` or `allowUnknown`) targets a non-empty, non-reserved name. - `routerSeesWhatDaemonSees` holds when every request the router does not deny reads the same to the daemon: decoding leaves its path unchanged. The daemon's decoding is the table `DAEMON_DECODING`: `%2F` and `%2f` decode to `["", ""]` (a `/` splits the segment), `%6A%73%6F%6E` to `["json"]`, and `beacon%2Fstart` to `["beacon", "start"]`. -`soundTest` asserts both on `router`. +On `router`, `soundTest` asserts `lifecycleOnlyTargetsRealNames` and `percentSoundTest` asserts `routerSeesWhatDaemonSees`. | Row (`run Test`) | Request | Outcome | Instances | |-----|---------|---------|-----------| @@ -109,10 +109,11 @@ Rows for the percent-encoded path deny ([#53](https://github.com/ChainSafe/docke | `percentSlashDeleteDenied` | `DELETE /containers/%2F` | deny | `DELETE /containers//` | | `percentLowerSlashDeleteDenied` | `DELETE /containers/%2f` | deny | `DELETE /containers//` | | `percentReservedDeleteDenied` | `DELETE /containers/%6A%73%6F%6E` | deny | `DELETE /containers/json` | -| `percentSubpathStartDenied` | `POST /containers/beacon%2Fstart` | deny | `POST /containers/beacon/start` | +| `percentNameStartDenied` | `POST /containers/%2F/start` | deny | `POST /containers///start` | +| `percentSubpathStartDenied` (pin) | `POST /containers/beacon%2Fstart` | deny | `POST /containers/beacon/start` | | `percentGetDenied` | `GET /containers/%2F` | deny | `GET /containers//` | -Each implementation's router tests use the same row names in comments (added with the #48 and #53 fixes). `router_pre48` runs `pre48UnsoundTest`, which asserts that `lifecycleOnlyTargetsRealNames` fails and that both `emptyName*Denied` requests are allowed as an unknown container ([#48](https://github.com/ChainSafe/docker-socket-policy/issues/48)). `router_pre53` runs `pre53UnsoundTest`, which asserts that `routerSeesWhatDaemonSees` fails, that `DELETE /containers/%2F` is allowed as an unknown container, and that `GET /containers/%2F` is allowed through the passthrough. +Each implementation's router tests use the same row names in comments (added with the #48 and #53 fixes). `router_pre48` runs `pre48UnsoundTest`, which asserts that `lifecycleOnlyTargetsRealNames` fails and that both `emptyName*Denied` requests are allowed as an unknown container ([#48](https://github.com/ChainSafe/docker-socket-policy/issues/48)). `router_pre53` runs `pre53UnsoundTest`, which asserts that `routerSeesWhatDaemonSees` fails, that `DELETE /containers/%2F` and `POST /containers/%2F/start` are allowed as an unknown container, and that `GET /containers/%2F` is allowed through the passthrough. ### Modeling Notes @@ -124,10 +125,11 @@ Two invariants are structurally tautological within the Quint model — they can - **`listener.qnt` checks the design, not the code.** Nothing in the model is derived from the Go, Rust or TypeScript sources. Conformance rests on each implementation's unit and integration tests, which carry the same names as the Quint `run`s (`groupDefaultPresent`, `pathStaleReplaced`, …) so every design-table row can be traced across all four. `raceWithoutLockTest` (in `listener_unlocked`) is the formal record of the TypeScript gap: Node has no `flock`, so two TypeScript instances starting together can orphan one another's socket ([#46](https://github.com/ChainSafe/docker-socket-policy/issues/46)). - **`lifecycleOnlyTargetsRealNames` is close to a tautology.** `route` only allows by name when `hasContainerName` holds, and `hasContainerName` is nearly the property itself. It is not vacuous: on `router_pre48`, where an empty segment counts as a name, the property fails, and `pre48UnsoundTest` asserts that. Most of the evidence comes from the table rows, whose names the language router tests reuse (added with the #48 fix). -- **`routerSeesWhatDaemonSees` is also close to a tautology on `router`.** The percent check denies exactly the paths with a segment that is a key of `DAEMON_DECODING`, and those are exactly the paths that decoding changes. It is not vacuous: on `router_pre53` the property fails, and `pre53UnsoundTest` asserts that. Setting `router`'s `ALLOW_PERCENT` to `true` makes `soundTest` and four of the five `percent*` rows fail. `percentSubpathStartDenied` still passes then, because the lifecycle branch denies `POST` when the last segment (`beacon%2Fstart`) is not an action. That row pins the outcome but does not discriminate the percent check. `lifecycleOnlyTargetsRealNames` holds on `router_pre53` too: `%2F` is a non-empty, non-reserved segment. That is why #53 needs a property that compares the router's reading with the daemon's. +- **`routerSeesWhatDaemonSees` is also close to a tautology on `router`.** The percent check denies exactly the paths with a segment that is a key of `DAEMON_DECODING`, and those are exactly the paths that decoding changes. It is not vacuous: on `router_pre53` the property fails, and `pre53UnsoundTest` asserts that. Setting `router`'s `ALLOW_PERCENT` to `true` makes `percentSoundTest` and five of the six `percent*` rows fail. `percentSubpathStartDenied` still passes then, because the lifecycle branch denies `POST` when the last segment (`beacon%2Fstart`) is not an action. That row is a pin: routing on the raw path already denies it, and only Go's decode-first handler before #53 would have allowed it, since the daemon reads `POST /containers/beacon/start`. `percentNameStartDenied` (`POST /containers/%2F/start`) is the discriminating `POST` row. `lifecycleOnlyTargetsRealNames` holds on `router_pre53` too: `%2F` is a non-empty, non-reserved segment. That is why #53 needs a property that compares the router's reading with the daemon's. - **Decoding is a fixed table.** Quint cannot inspect the characters of a string, so the model cannot find a `%` or decode one. `DAEMON_DECODING` lists the encoded tokens in the path universe and the segments the daemon reads them as; a segment is encoded iff it is a key. The implementations check for any `%` in the path. The table covers the shapes that matter: an encoded `/` (upper and lower case) that splits a segment, a fully encoded reserved word, and an encoded `/` inside a name that adds a lifecycle action. - **The query string is outside the model.** Paths are segment lists with the query string already removed, so the model says nothing about `%` in the query. The implementations never inspect the query string for this rule; encoded queries such as `?filters=%7B…%7D` are routine and must still be forwarded. That is pinned by the language handler tests and the integration tests, not by the model. - **GET is covered.** The percent check runs before the GET passthrough, and `routerSeesWhatDaemonSees` ranges over `GET` as well as `POST` and `DELETE`. On `router_pre53` the passthrough lets `GET /containers/%2F` through, and the property fails for it too. +- **No instance models Go before #53.** Go decoded before routing, which is `route(m, decodePath(segs))`. With this table that agrees with the daemon by construction, so `routerSeesWhatDaemonSees` cannot express Go's risk: decoding that differs from the daemon's, such as encoded version segments ([#57](https://github.com/ChainSafe/docker-socket-policy/issues/57)). Go's change is covered by the language handler tests. - **`router.qnt` is not the full router.** `route` models the percent check, which runs first in all three routers, and the container-lifecycle branch. It leaves out the API-version strip and the checks that the routers run between those two, such as the exec, build and commit denials and `POST /containers/create`. For paths that those checks catch, the model's outcome can differ from what an implementation does: - `DELETE /containers/mycontainer/exec` is `allowUnknown` in the model. Rust and TypeScript deny it with their exec checks. Go's exec check matches `exec` only in the name position, so Go sends this path down the lifecycle branch and allows it. This divergence between the languages belongs to the same family as [#24](https://github.com/ChainSafe/docker-socket-policy/issues/24) and [#48](https://github.com/ChainSafe/docker-socket-policy/issues/48). It is outside this model's scope and is tracked with the other routing divergences. - `POST /containers/create` is `deny` in the model, but in reality it routes to container create. diff --git a/spec/router.qnt b/spec/router.qnt index efad563..9299f5e 100644 --- a/spec/router.qnt +++ b/spec/router.qnt @@ -29,6 +29,12 @@ // ALLOW_PERCENT = true models Rust and TypeScript before #53, which routed // on the raw path and let encoded segments through. false models all three // after the fix: any `%` in the path is denied, for every method. +// +// No instance models Go before #53. Go decoded before routing, which is +// route(m, decodePath(segs)); with this table that agrees with the daemon +// by construction, so the property cannot express Go's risk: decoding that +// differs from the daemon's, such as encoded version segments (#57). Go's +// change is covered by the language handler tests. module router_model { const ALLOW_EMPTY_NAME: bool @@ -134,8 +140,8 @@ module router_model { // ─── Tests: one run per table row ─────────────────────────────────── // // Rows whose outcome depends on neither ALLOW_EMPTY_NAME nor - // ALLOW_PERCENT run on every instance. The rows that do, and soundTest, - // live in `router`. + // ALLOW_PERCENT run on every instance. The rows that do, soundTest and + // percentSoundTest live in `router`. run emptyNameGetAllowedTest = assert(route("GET", ["containers", ""]) == "allowRead") @@ -178,6 +184,13 @@ module router { run percentReservedDeleteDeniedTest = assert(route("DELETE", ["containers", "%6A%73%6F%6E"]) == "deny") + run percentNameStartDeniedTest = + assert(route("POST", ["containers", "%2F", "start"]) == "deny") + + // A pin, not a discriminator: routing on the raw path already denies it, + // since `beacon%2Fstart` is not an action. Only Go's decode-first handler + // before #53 would have allowed it, as the daemon reads + // `POST /containers/beacon/start`. run percentSubpathStartDeniedTest = assert(route("POST", ["containers", "beacon%2Fstart"]) == "deny") @@ -185,10 +198,10 @@ module router { assert(route("GET", ["containers", "%2F"]) == "deny") run soundTest = - assert(and { - lifecycleOnlyTargetsRealNames, - routerSeesWhatDaemonSees, - }) + assert(lifecycleOnlyTargetsRealNames) + + run percentSoundTest = + assert(routerSeesWhatDaemonSees) } // Rust before #48: `/containers/` and `/containers//start` reach @@ -215,6 +228,7 @@ module router_pre53 { assert(and { not(routerSeesWhatDaemonSees), route("DELETE", ["containers", "%2F"]) == "allowUnknown", + route("POST", ["containers", "%2F", "start"]) == "allowUnknown", route("GET", ["containers", "%2F"]) == "allowRead", }) } From fc17c2efc85d2cbdb6b8aa8593d0f8e0dacbc4e8 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 15:40:14 -0400 Subject: [PATCH 3/8] test: pin the percent-encoded path deny (#53) --- deploy/test.sh | 14 +++++++ go/internal/proxy/handler_test.go | 69 +++++++++++++++++++++++++++++++ go/internal/proxy/router_test.go | 53 ++++++++++++++++++++++++ rs/src/handler.rs | 69 +++++++++++++++++++++++++++++++ rs/src/proxy.rs | 36 ++++++++++++++++ ts/src/handler.test.ts | 34 +++++++++++++++ ts/src/proxy.test.ts | 33 +++++++++++++++ 7 files changed, 308 insertions(+) diff --git a/deploy/test.sh b/deploy/test.sh index 2cf0dea..81a74bb 100755 --- a/deploy/test.sh +++ b/deploy/test.sh @@ -353,6 +353,20 @@ else FAIL=$((FAIL+1)) fi +# #53: percent-encoded paths. The daemon decodes the path before it routes, +# so the proxy and the daemon could read the same request differently; any % +# in the path is denied. curl sends the path as written (it does not decode +# %XX, and there are no dot segments to squash). The query string is never +# inspected: an encoded filter, as `docker ps --filter` sends, still passes. +S=$(delete_status "$PROXY/containers/no-such%20x") +check "DELETE /containers/no-such%20x -> 403 (percent-encoded path)" "403" "$S" + +S=$(delete_status "$PROXY/containers/%2F") +check "DELETE /containers/%2F -> 403 (percent-encoded path)" "403" "$S" + +S=$(get_status "$PROXY/v1.45/containers/json?filters=%7B%22status%22%3A%5B%22running%22%5D%7D") +check "GET /v1.45/containers/json?filters= -> 200 (query not inspected)" "200" "$S" + # ─── Summary ────────────────────────────────────────── echo "" diff --git a/go/internal/proxy/handler_test.go b/go/internal/proxy/handler_test.go index 4172437..1f7c17d 100644 --- a/go/internal/proxy/handler_test.go +++ b/go/internal/proxy/handler_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/ChainSafe/docker-socket-policy/go/internal/audit" @@ -84,6 +85,74 @@ func TestHandler_DeniesRoute(t *testing.T) { } } +// newPercentRequest builds a request from a raw target and checks that the +// escaped path really carries the % (#53). +func newPercentRequest(t *testing.T, method, target string) *http.Request { + t.Helper() + req := httptest.NewRequest(method, target, nil) + if !strings.Contains(req.URL.EscapedPath(), "%") { + t.Fatalf("precondition: EscapedPath() = %q has no %%", req.URL.EscapedPath()) + } + return req +} + +// #53: Go decodes the name to "foo bar" before routing; the daemon would act +// on that container. +func TestHandler_DeniesPercentEncodedName(t *testing.T) { + rec := &recorderTransport{} + h := newTestHandler(t, nil, rec) + + req := newPercentRequest(t, "DELETE", "/containers/foo%20bar") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } + if rec.lastRequest != nil { + t.Fatal("expected no forward for a percent-encoded path") + } +} + +// #53: the daemon reads %2F as a request about "/". +func TestHandler_DeniesPercentEncodedSlash(t *testing.T) { + rec := &recorderTransport{} + h := newTestHandler(t, nil, rec) + + req := newPercentRequest(t, "DELETE", "/containers/%2F") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } + if rec.lastRequest != nil { + t.Fatal("expected no forward for a percent-encoded path") + } +} + +// #53: the rule looks at the path only; an encoded query string is routine +// (docker ps --filter) and must reach the daemon unchanged. +func TestHandler_ForwardsPercentEncodedQuery(t *testing.T) { + rec := &recorderTransport{} + h := newTestHandler(t, nil, rec) + + const query = "filters=%7B%22status%22%3A%5B%22running%22%5D%7D" + req := httptest.NewRequest("GET", "/containers/json?"+query, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if rec.lastRequest == nil { + t.Fatal("expected request to be forwarded") + } + if got := rec.lastRequest.URL.RawQuery; got != query { + t.Fatalf("forwarded query = %q, want %q", got, query) + } +} + func TestHandler_CreateContainerValid(t *testing.T) { rec := &recorderTransport{} h := newTestHandler(t, map[string]string{ diff --git a/go/internal/proxy/router_test.go b/go/internal/proxy/router_test.go index ee02066..78c85f1 100644 --- a/go/internal/proxy/router_test.go +++ b/go/internal/proxy/router_test.go @@ -467,6 +467,59 @@ allowed_image_prefixes: } } +// TestRoutePercentEncodedPaths is the cross-language parity guard for #53. +// +// The daemon percent-decodes the path before it routes, so any % in the path +// lets the proxy and the daemon read the same request differently. A path +// containing % is denied for every method. Paths are given raw, as the handler +// passes r.URL.EscapedPath(). Rows mirror the percent* runs in spec/router.qnt. +func TestRoutePercentEncodedPaths(t *testing.T) { + m := newTestManager(t, map[string]string{ + "beacon.yaml": ` +service_name: beacon +allowed_image_prefixes: + - chainsafe/lodestar +`, + }) + r := NewRouter(m) + + tests := []struct { + method string + path string + want Action + }{ + // percentSlashDeleteDeniedTest (#53) + {"DELETE", "/containers/%2F", ActionDeny}, + // percentLowerSlashDeleteDeniedTest (#53) + {"DELETE", "/containers/%2f", ActionDeny}, + // percentReservedDeleteDeniedTest (#53): %6A%73%6F%6E decodes to json. + {"DELETE", "/containers/%6A%73%6F%6E", ActionDeny}, + // percentSubpathStartDeniedTest (#53): a pin; raw routing already denies it. + {"POST", "/containers/beacon%2Fstart", ActionDeny}, + // percentNameStartDeniedTest (#53) + {"POST", "/containers/%2F/start", ActionDeny}, + // percentGetDeniedTest (#53) + {"GET", "/containers/%2F", ActionDeny}, + // #53: the check runs on the versioned path too. + {"DELETE", "/v1.45/containers/foo%25", ActionDeny}, + // #53: an encoded version prefix is not stripped. + {"DELETE", "/v%31/containers/foo", ActionDeny}, + // #53: network names that need escaping are denied, GET included. + {"GET", "/networks/a%20b", ActionDeny}, + // #53 control: a plain name is still routed as a container. + {"DELETE", "/containers/foo", ActionAllow}, + } + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + got := r.Route(tt.method, tt.path, nil) + if got.Action != tt.want { + t.Fatalf("Route(%s, %s) = %v, want %v (deny msg: %q)", + tt.method, tt.path, got.Action, tt.want, got.DenyMsg) + } + }) + } +} + func TestExtractContainerNameSkipsEmptySegment(t *testing.T) { for _, path := range []string{"/containers/", "/containers//start"} { if got := extractContainerName(path); got != "" { diff --git a/rs/src/handler.rs b/rs/src/handler.rs index ca5ea25..01adca3 100644 --- a/rs/src/handler.rs +++ b/rs/src/handler.rs @@ -366,4 +366,73 @@ mod tests { let resp = handler.handle(req).await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); } + + struct UriRecordingTransport { + captured_uri: Arc>>, + } + + #[async_trait] + impl Transport for UriRecordingTransport { + async fn forward( + &self, + req: Request>, + ) -> Result>, TransportError> { + *self.captured_uri.lock().unwrap() = Some(req.uri().clone()); + Ok(Response::builder() + .status(StatusCode::OK) + .body(Full::new(Bytes::new())) + .unwrap()) + } + } + + fn make_uri_recording_handler() -> (Handler, Arc>>) { + let manager = Manager::from_map(std::collections::HashMap::new()); + let router = Arc::new(Router::new(manager)); + let chain = Chain::new(false); + let audit = AuditLogger::new("/dev/null").unwrap(); + let captured_uri = Arc::new(std::sync::Mutex::new(None)); + let transport = UriRecordingTransport { captured_uri: captured_uri.clone() }; + (Handler::new(router, chain, audit, Box::new(transport)), captured_uri) + } + + /// #53: Rust routed on the raw name and forwarded it; the daemon decodes + /// it to "foo bar" and acts on that container. + #[tokio::test] + async fn test_handler_denies_percent_encoded_name() { + let (handler, captured_uri) = make_uri_recording_handler(); + let req = Request::delete("http://localhost/containers/foo%20bar") + .body(Full::new(Bytes::new())) + .unwrap(); + let resp = handler.handle(req).await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!(captured_uri.lock().unwrap().is_none(), "expected no forward"); + } + + /// #53: the daemon reads %2F as a request about "/". + #[tokio::test] + async fn test_handler_denies_percent_encoded_slash() { + let (handler, captured_uri) = make_uri_recording_handler(); + let req = Request::delete("http://localhost/containers/%2F") + .body(Full::new(Bytes::new())) + .unwrap(); + let resp = handler.handle(req).await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!(captured_uri.lock().unwrap().is_none(), "expected no forward"); + } + + /// #53: the rule looks at the path only; an encoded query string is + /// routine (docker ps --filter) and must reach the daemon unchanged. + #[tokio::test] + async fn test_handler_forwards_percent_encoded_query() { + let (handler, captured_uri) = make_uri_recording_handler(); + let query = "filters=%7B%22status%22%3A%5B%22running%22%5D%7D"; + let req = Request::get(format!("http://localhost/containers/json?{}", query)) + .body(Full::new(Bytes::new())) + .unwrap(); + let resp = handler.handle(req).await; + assert_eq!(resp.status(), StatusCode::OK); + let captured = captured_uri.lock().unwrap(); + let uri = captured.as_ref().expect("expected request to be forwarded"); + assert_eq!(uri.query(), Some(query)); + } } diff --git a/rs/src/proxy.rs b/rs/src/proxy.rs index 44763bc..6873415 100644 --- a/rs/src/proxy.rs +++ b/rs/src/proxy.rs @@ -578,6 +578,42 @@ mod tests { } } + /// Cross-language parity guard for #53. The daemon percent-decodes the + /// path before it routes, so any % in the path lets the proxy and the + /// daemon read the same request differently. A path containing % is + /// denied for every method. Paths are given raw, as the handler routes + /// them. Rows mirror the percent* runs in spec/router.qnt. + #[test] + fn test_route_percent_encoded_paths() { + let router = Router::new(make_manager(vec!["alpine"])); + let cases = [ + // percentSlashDeleteDeniedTest (#53) + ("DELETE", "/containers/%2F", Action::Deny), + // percentLowerSlashDeleteDeniedTest (#53) + ("DELETE", "/containers/%2f", Action::Deny), + // percentReservedDeleteDeniedTest (#53): %6A%73%6F%6E decodes to json. + ("DELETE", "/containers/%6A%73%6F%6E", Action::Deny), + // percentSubpathStartDeniedTest (#53): a pin; raw routing already denies it. + ("POST", "/containers/beacon%2Fstart", Action::Deny), + // percentNameStartDeniedTest (#53) + ("POST", "/containers/%2F/start", Action::Deny), + // percentGetDeniedTest (#53) + ("GET", "/containers/%2F", Action::Deny), + // #53: the check runs on the versioned path too. + ("DELETE", "/v1.45/containers/foo%25", Action::Deny), + // #53: an encoded version prefix is not stripped. + ("DELETE", "/v%31/containers/foo", Action::Deny), + // #53: network names that need escaping are denied, GET included. + ("GET", "/networks/a%20b", Action::Deny), + // #53 control: a plain name is still routed as a container. + ("DELETE", "/containers/foo", Action::Allow), + ]; + for (method, path, want) in cases { + let got = router.route(method, path, None); + assert_eq!(got.action, want, "route({} {})", method, path); + } + } + #[test] fn test_extract_container_name_skips_empty_segment() { for path in ["/containers/", "/containers//start"] { diff --git a/ts/src/handler.test.ts b/ts/src/handler.test.ts index 3e898f0..30b0cdc 100644 --- a/ts/src/handler.test.ts +++ b/ts/src/handler.test.ts @@ -155,4 +155,38 @@ describe("Handler", () => { const hostConfig = forwarded["HostConfig"] as Record; assert.equal(hostConfig["NetworkMode"], "host"); }); + + // #53: TS routed on the raw name and forwarded it; the daemon decodes it to + // "foo bar" and acts on that container. + it("denies a percent-encoded container name with 403", async () => { + const dir = makeEnv(defaultConfig); + const { handler, transport } = newHandler(dir); + const { res, recorder } = makeResponse(); + await handler.handle(makeRequest("DELETE", "/containers/foo%20bar"), res); + assert.equal(recorder.statusCode, 403); + assert.equal(transport.lastRequest, undefined); + }); + + // #53: the daemon reads %2F as a request about "/". + it("denies a percent-encoded slash with 403", async () => { + const dir = makeEnv(defaultConfig); + const { handler, transport } = newHandler(dir); + const { res, recorder } = makeResponse(); + await handler.handle(makeRequest("DELETE", "/containers/%2F"), res); + assert.equal(recorder.statusCode, 403); + assert.equal(transport.lastRequest, undefined); + }); + + // #53: the rule looks at the path only; an encoded query string is routine + // (docker ps --filter) and must reach the daemon unchanged. + it("forwards a percent-encoded query string unchanged", async () => { + const dir = makeEnv(defaultConfig); + const { handler, transport } = newHandler(dir); + const { res, recorder } = makeResponse(); + const url = "/containers/json?filters=%7B%22status%22%3A%5B%22running%22%5D%7D"; + await handler.handle(makeRequest("GET", url), res); + assert.notEqual(recorder.statusCode, 403); + assert.ok(transport.lastRequest, "expected request to be forwarded"); + assert.equal(transport.lastRequest.url, url); + }); }); \ No newline at end of file diff --git a/ts/src/proxy.test.ts b/ts/src/proxy.test.ts index f176bc4..3eaa822 100644 --- a/ts/src/proxy.test.ts +++ b/ts/src/proxy.test.ts @@ -270,4 +270,37 @@ describe("Router", () => { assert.equal(r.action, want, `route(${method} ${path})`); } }); + + // Cross-language parity guard for #53. The daemon percent-decodes the path + // before it routes, so any % in the path lets the proxy and the daemon read + // the same request differently. A path containing % is denied for every + // method. Rows mirror the percent* runs in spec/router.qnt. + it("denies percent-encoded paths", () => { + const cases: [string, string, Action][] = [ + // percentSlashDeleteDeniedTest (#53) + ["DELETE", "/containers/%2F", Action.Deny], + // percentLowerSlashDeleteDeniedTest (#53) + ["DELETE", "/containers/%2f", Action.Deny], + // percentReservedDeleteDeniedTest (#53): %6A%73%6F%6E decodes to json. + ["DELETE", "/containers/%6A%73%6F%6E", Action.Deny], + // percentSubpathStartDeniedTest (#53): a pin; raw routing already denies it. + ["POST", "/containers/beacon%2Fstart", Action.Deny], + // percentNameStartDeniedTest (#53) + ["POST", "/containers/%2F/start", Action.Deny], + // percentGetDeniedTest (#53) + ["GET", "/containers/%2F", Action.Deny], + // #53: the check runs on the versioned path too. + ["DELETE", "/v1.45/containers/foo%25", Action.Deny], + // #53: an encoded version prefix is not stripped. + ["DELETE", "/v%31/containers/foo", Action.Deny], + // #53: network names that need escaping are denied, GET included. + ["GET", "/networks/a%20b", Action.Deny], + // #53 control: a plain name is still routed as a container. + ["DELETE", "/containers/foo", Action.Allow], + ]; + for (const [method, path, want] of cases) { + const r = router.route(method, path); + assert.equal(r.action, want, `route(${method} ${path})`); + } + }); }); From 32aacf6a2aef8255f53f122bb77060c3ab3bfa77 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 15:44:47 -0400 Subject: [PATCH 4/8] test: assert the #53 deny reason; add query and HEAD rows (#53) --- deploy/test.sh | 2 ++ go/internal/proxy/handler_test.go | 6 ++++++ go/internal/proxy/router_test.go | 10 ++++++++++ rs/src/handler.rs | 6 ++++++ rs/src/proxy.rs | 15 +++++++++++++++ ts/src/handler.test.ts | 2 ++ ts/src/proxy.test.ts | 16 ++++++++++++++++ 7 files changed, 57 insertions(+) diff --git a/deploy/test.sh b/deploy/test.sh index 81a74bb..9fbffdf 100755 --- a/deploy/test.sh +++ b/deploy/test.sh @@ -364,6 +364,8 @@ check "DELETE /containers/no-such%20x -> 403 (percent-encoded path)" "403" "$S" S=$(delete_status "$PROXY/containers/%2F") check "DELETE /containers/%2F -> 403 (percent-encoded path)" "403" "$S" +# The handler tests prove the query reaches the daemon unchanged; this check +# proves an encoded query is not denied. S=$(get_status "$PROXY/v1.45/containers/json?filters=%7B%22status%22%3A%5B%22running%22%5D%7D") check "GET /v1.45/containers/json?filters= -> 200 (query not inspected)" "200" "$S" diff --git a/go/internal/proxy/handler_test.go b/go/internal/proxy/handler_test.go index 1f7c17d..f463853 100644 --- a/go/internal/proxy/handler_test.go +++ b/go/internal/proxy/handler_test.go @@ -109,6 +109,9 @@ func TestHandler_DeniesPercentEncodedName(t *testing.T) { if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", w.Code) } + if !strings.Contains(w.Body.String(), "percent-encoded") { + t.Fatalf("expected a percent-encoded deny reason, got %q", w.Body.String()) + } if rec.lastRequest != nil { t.Fatal("expected no forward for a percent-encoded path") } @@ -126,6 +129,9 @@ func TestHandler_DeniesPercentEncodedSlash(t *testing.T) { if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", w.Code) } + if !strings.Contains(w.Body.String(), "percent-encoded") { + t.Fatalf("expected a percent-encoded deny reason, got %q", w.Body.String()) + } if rec.lastRequest != nil { t.Fatal("expected no forward for a percent-encoded path") } diff --git a/go/internal/proxy/router_test.go b/go/internal/proxy/router_test.go index 78c85f1..089d1fd 100644 --- a/go/internal/proxy/router_test.go +++ b/go/internal/proxy/router_test.go @@ -3,6 +3,7 @@ package proxy import ( "os" "path/filepath" + "strings" "testing" "github.com/ChainSafe/docker-socket-policy/go/internal/policy" @@ -495,11 +496,16 @@ allowed_image_prefixes: // percentReservedDeleteDeniedTest (#53): %6A%73%6F%6E decodes to json. {"DELETE", "/containers/%6A%73%6F%6E", ActionDeny}, // percentSubpathStartDeniedTest (#53): a pin; raw routing already denies it. + // It discriminates only in Go's handler before #53, which decoded first; + // the foo%20bar handler test covers that. {"POST", "/containers/beacon%2Fstart", ActionDeny}, // percentNameStartDeniedTest (#53) {"POST", "/containers/%2F/start", ActionDeny}, // percentGetDeniedTest (#53) {"GET", "/containers/%2F", ActionDeny}, + // #53, language-only: HEAD is outside the model's METHODS, but the rule + // covers every method. + {"HEAD", "/containers/%2F", ActionDeny}, // #53: the check runs on the versioned path too. {"DELETE", "/v1.45/containers/foo%25", ActionDeny}, // #53: an encoded version prefix is not stripped. @@ -516,6 +522,10 @@ allowed_image_prefixes: t.Fatalf("Route(%s, %s) = %v, want %v (deny msg: %q)", tt.method, tt.path, got.Action, tt.want, got.DenyMsg) } + if tt.want == ActionDeny && !strings.Contains(got.DenyMsg, "percent-encoded") { + t.Fatalf("Route(%s, %s) deny msg = %q, want it to contain %q", + tt.method, tt.path, got.DenyMsg, "percent-encoded") + } }) } } diff --git a/rs/src/handler.rs b/rs/src/handler.rs index 01adca3..094ca37 100644 --- a/rs/src/handler.rs +++ b/rs/src/handler.rs @@ -405,6 +405,9 @@ mod tests { .unwrap(); let resp = handler.handle(req).await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8_lossy(&body); + assert!(body.contains("percent-encoded"), "deny reason = {:?}", body); assert!(captured_uri.lock().unwrap().is_none(), "expected no forward"); } @@ -417,6 +420,9 @@ mod tests { .unwrap(); let resp = handler.handle(req).await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8_lossy(&body); + assert!(body.contains("percent-encoded"), "deny reason = {:?}", body); assert!(captured_uri.lock().unwrap().is_none(), "expected no forward"); } diff --git a/rs/src/proxy.rs b/rs/src/proxy.rs index 6873415..fa94784 100644 --- a/rs/src/proxy.rs +++ b/rs/src/proxy.rs @@ -594,11 +594,16 @@ mod tests { // percentReservedDeleteDeniedTest (#53): %6A%73%6F%6E decodes to json. ("DELETE", "/containers/%6A%73%6F%6E", Action::Deny), // percentSubpathStartDeniedTest (#53): a pin; raw routing already denies it. + // It discriminates only in Go's handler before #53, which decoded first; + // the foo%20bar handler test covers that. ("POST", "/containers/beacon%2Fstart", Action::Deny), // percentNameStartDeniedTest (#53) ("POST", "/containers/%2F/start", Action::Deny), // percentGetDeniedTest (#53) ("GET", "/containers/%2F", Action::Deny), + // #53, language-only: HEAD is outside the model's METHODS, but the rule + // covers every method. + ("HEAD", "/containers/%2F", Action::Deny), // #53: the check runs on the versioned path too. ("DELETE", "/v1.45/containers/foo%25", Action::Deny), // #53: an encoded version prefix is not stripped. @@ -611,6 +616,16 @@ mod tests { for (method, path, want) in cases { let got = router.route(method, path, None); assert_eq!(got.action, want, "route({} {})", method, path); + if want == Action::Deny { + let msg = got.deny_msg.unwrap_or_default(); + assert!( + msg.contains("percent-encoded"), + "route({} {}) deny msg = {:?}, want it to contain \"percent-encoded\"", + method, + path, + msg + ); + } } } diff --git a/ts/src/handler.test.ts b/ts/src/handler.test.ts index 30b0cdc..d703a6e 100644 --- a/ts/src/handler.test.ts +++ b/ts/src/handler.test.ts @@ -164,6 +164,7 @@ describe("Handler", () => { const { res, recorder } = makeResponse(); await handler.handle(makeRequest("DELETE", "/containers/foo%20bar"), res); assert.equal(recorder.statusCode, 403); + assert.ok(recorder.body.includes("percent-encoded"), `deny reason = ${JSON.stringify(recorder.body)}`); assert.equal(transport.lastRequest, undefined); }); @@ -174,6 +175,7 @@ describe("Handler", () => { const { res, recorder } = makeResponse(); await handler.handle(makeRequest("DELETE", "/containers/%2F"), res); assert.equal(recorder.statusCode, 403); + assert.ok(recorder.body.includes("percent-encoded"), `deny reason = ${JSON.stringify(recorder.body)}`); assert.equal(transport.lastRequest, undefined); }); diff --git a/ts/src/proxy.test.ts b/ts/src/proxy.test.ts index 3eaa822..ed06dfd 100644 --- a/ts/src/proxy.test.ts +++ b/ts/src/proxy.test.ts @@ -284,11 +284,16 @@ describe("Router", () => { // percentReservedDeleteDeniedTest (#53): %6A%73%6F%6E decodes to json. ["DELETE", "/containers/%6A%73%6F%6E", Action.Deny], // percentSubpathStartDeniedTest (#53): a pin; raw routing already denies it. + // It discriminates only in Go's handler before #53, which decoded first; + // the foo%20bar handler test covers that. ["POST", "/containers/beacon%2Fstart", Action.Deny], // percentNameStartDeniedTest (#53) ["POST", "/containers/%2F/start", Action.Deny], // percentGetDeniedTest (#53) ["GET", "/containers/%2F", Action.Deny], + // #53, language-only: HEAD is outside the model's METHODS, but the rule + // covers every method. + ["HEAD", "/containers/%2F", Action.Deny], // #53: the check runs on the versioned path too. ["DELETE", "/v1.45/containers/foo%25", Action.Deny], // #53: an encoded version prefix is not stripped. @@ -297,10 +302,21 @@ describe("Router", () => { ["GET", "/networks/a%20b", Action.Deny], // #53 control: a plain name is still routed as a container. ["DELETE", "/containers/foo", Action.Allow], + // #53, TS-only: only the TS router receives the query string (Go and Rust + // route URL.EscapedPath() / uri.path()). A % in the query is not checked. + ["GET", "/containers/json?filters=%7B%7D", Action.Allow], + // Non-GET, so the GET passthrough cannot hide a check placed before the split. + ["DELETE", "/containers/foo?force=%31", Action.Allow], ]; for (const [method, path, want] of cases) { const r = router.route(method, path); assert.equal(r.action, want, `route(${method} ${path})`); + if (want === Action.Deny) { + assert.ok( + r.denyMsg?.includes("percent-encoded"), + `route(${method} ${path}) deny msg = ${JSON.stringify(r.denyMsg)}`, + ); + } } }); }); From 67e542d41362e1b2a0258acf90adcdbafd53a9bd Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 15:58:28 -0400 Subject: [PATCH 5/8] fix!: deny percent-encoded request paths (#53) 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. --- README.md | 2 ++ go/internal/proxy/handler.go | 12 +++++++----- go/internal/proxy/router.go | 5 +++++ rs/src/proxy.rs | 5 +++++ ts/src/proxy.ts | 2 ++ 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0c9de58..67476f2 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ docker pull attacker/malware:latest # denied: image not in allowlist | GET/HEAD | Any | Allowed (read-only) | | Other | Other | **DENIED** | +Any request whose path contains a percent-encoded byte (`%`) is denied with 403 for every method, GET and HEAD included, because the daemon decodes the path before routing. The query string is not inspected, so filters such as `docker ps --filter …` still work. A consequence is that networks whose names contain a space or `%` cannot be inspected or removed through the proxy. + ## Configuration ### CLI Flags diff --git a/go/internal/proxy/handler.go b/go/internal/proxy/handler.go index e864a0e..bd585f4 100644 --- a/go/internal/proxy/handler.go +++ b/go/internal/proxy/handler.go @@ -50,11 +50,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - route := h.router.Route(r.Method, r.URL.Path, bodyJSON) + // Route on the raw path: r.URL.Path is already decoded and would hide %-escapes (#53). + path := r.URL.EscapedPath() + route := h.router.Route(r.Method, path, bodyJSON) extra := map[string]interface{}{ "method": r.Method, - "path": r.URL.Path, + "path": path, } if route.Service != "" { extra["service"] = route.Service @@ -64,7 +66,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if route.Action == ActionDeny { - slog.Warn("denied", "method", r.Method, "path", r.URL.Path, "reason", route.DenyMsg) + slog.Warn("denied", "method", r.Method, "path", path, "reason", route.DenyMsg) h.auditLog.Deny(r.Method, r.RequestURI, route.DenyMsg, extra) http.Error(w, route.DenyMsg, http.StatusForbidden) return @@ -73,7 +75,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if route.Action == ActionCreateContainer && route.Policy != nil && bodyJSON != nil { result := h.chain.Execute(r, route.Policy, bodyJSON) if !result.Allowed { - slog.Warn("denied by middleware", "method", r.Method, "path", r.URL.Path, "reason", result.Reason) + slog.Warn("denied by middleware", "method", r.Method, "path", path, "reason", result.Reason) h.auditLog.Deny(r.Method, r.RequestURI, result.Reason, extra) http.Error(w, result.Reason, http.StatusForbidden) return @@ -89,7 +91,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { r.Body = io.NopCloser(bytes.NewReader(body)) } - slog.Info("allowed", "method", r.Method, "path", r.URL.Path) + slog.Info("allowed", "method", r.Method, "path", path) h.auditLog.Allow(r.Method, r.RequestURI, "request allowed", extra) h.transport.ServeHTTP(w, r) diff --git a/go/internal/proxy/router.go b/go/internal/proxy/router.go index 2c7471b..7be33a7 100644 --- a/go/internal/proxy/router.go +++ b/go/internal/proxy/router.go @@ -34,6 +34,11 @@ func NewRouter(manager *policy.Manager) *Router { } func (r *Router) Route(method, path string, body map[string]interface{}) *RouteResult { + // The daemon decodes the path before routing, so deny any escape (#53). + if strings.Contains(path, "%") { + return &RouteResult{Action: ActionDeny, DenyMsg: "percent-encoded path not allowed"} + } + path = stripAPIVersion(path) if path == "/_ping" || path == "/version" || path == "/info" || strings.HasPrefix(path, "/events") { diff --git a/rs/src/proxy.rs b/rs/src/proxy.rs index fa94784..c4fa623 100644 --- a/rs/src/proxy.rs +++ b/rs/src/proxy.rs @@ -34,6 +34,11 @@ impl Router { path: &str, body: Option<&HashMap>, ) -> RouteResult { + // The daemon decodes the path before routing, so deny any escape (#53). + if path.contains('%') { + return deny("percent-encoded path not allowed"); + } + let path = strip_api_version(path); // Read-only endpoints diff --git a/ts/src/proxy.ts b/ts/src/proxy.ts index 70ff61f..743049f 100644 --- a/ts/src/proxy.ts +++ b/ts/src/proxy.ts @@ -23,6 +23,8 @@ export class Router { route(method: string, path: string, body?: Record): RouteResult { const qmIdx = path.indexOf("?"); const cleanPath = qmIdx !== -1 ? path.slice(0, qmIdx) : path; + // The daemon decodes the path before routing, so deny any escape (#53). + if (cleanPath.includes("%")) return { action: Action.Deny, denyMsg: "percent-encoded path not allowed" }; path = stripAPIVersion(cleanPath); // Read-only endpoints From d7cba75ba207821d792f3ea057df44bacf4d3124 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 15:58:39 -0400 Subject: [PATCH 6/8] docs: update test coverage counts (#53) --- AGENTS.md | 10 +++++----- README.md | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 27b8ae1..2191fe9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,17 +37,17 @@ deploy/ — Docker Compose + integration tests - Zero external deps where possible (Go: yaml.v3, Rust: tokio/hyper/serde/clap, TS: yaml) ## Test Coverage -- Go: 103 unit tests (main/listener: 24, policy: 10, middleware: 29, proxy: 36, audit: 4) -- Rust: 140 unit tests (main/listener: 23, policy: 15, middleware: 50, proxy: 42, handler: 4, audit: 4, transport: 2) -- TypeScript: 157 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 41, proxy: 29, policy: 10, handler: 6, shutdown: 5, transport: 5, audit: 4) -- Integration, per implementation: 36 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose) +- Go: 107 unit tests (main/listener: 24, policy: 10, middleware: 29, proxy: 40, audit: 4) +- Rust: 144 unit tests (main/listener: 23, policy: 15, middleware: 50, proxy: 43, handler: 7, audit: 4, transport: 2) +- TypeScript: 161 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 41, proxy: 30, policy: 10, handler: 9, shutdown: 5, transport: 5, audit: 4) +- Integration, per implementation: 39 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose) - Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests (instances `listener_locked`, `listener_unlocked`) and the `spec/router.qnt` `run` tests (instances `router`, `router_pre48`, `router_pre53`) ## Test Conventions - Go: stdlib `testing` package, `go test ./...` - Rust: `#[cfg(test)]` inline modules, `cargo test` - TypeScript: `node:test` framework, `npm run build && node --test dist/*.test.js` -- Integration: `make test-integration` (36 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose +- Integration: `make test-integration` (39 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose ## Contribution Workflow diff --git a/README.md b/README.md index 67476f2..56e3d8d 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,9 @@ All three implementations expose the same API surface, share the same [Quint spe | Language | Directory | Tests | Stack | |----------|-----------|-------|-------| -| Go | [go/](go/) | 103 unit + 36 integration | stdlib net/http + yaml.v3 | -| Rust | [rs/](rs/) | 140 unit | tokio, hyper, serde, clap | -| TypeScript | [ts/](ts/) | 157 unit (1 skipped) | Node 22 ESM, built-in http | +| Go | [go/](go/) | 107 unit + 39 integration | stdlib net/http + yaml.v3 | +| Rust | [rs/](rs/) | 144 unit | tokio, hyper, serde, clap | +| TypeScript | [ts/](ts/) | 161 unit (1 skipped) | Node 22 ESM, built-in http | ### Build All From ab4fd4e9ec3fcef369f9f03c58da40a0e09858f5 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 16:09:42 -0400 Subject: [PATCH 7/8] docs: correct the #53 README note on networks and Go re-encoding (#53) --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 56e3d8d..36a6873 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ docker pull attacker/malware:latest # denied: image not in allowlist | HTTP Method | Path | Action | |-------------|------|--------| +| Any | Path containing `%` | **DENIED** (see below) | | POST | `/containers/create` | Validated by middleware chain | | POST | `/containers/{name}/start\|stop\|restart\|kill\|wait\|pause\|unpause` | Allowed on known containers | | DELETE | `/containers/{name}` | Allowed on known containers | @@ -213,10 +214,10 @@ docker pull attacker/malware:latest # denied: image not in allowlist | POST | `/auth` | **DENIED** | | POST | `/build` | **DENIED** | | POST | `/commit` | **DENIED** | -| GET/HEAD | Any | Allowed (read-only) | +| GET/HEAD | Any path without `%` | Allowed (read-only) | | Other | Other | **DENIED** | -Any request whose path contains a percent-encoded byte (`%`) is denied with 403 for every method, GET and HEAD included, because the daemon decodes the path before routing. The query string is not inspected, so filters such as `docker ps --filter …` still work. A consequence is that networks whose names contain a space or `%` cannot be inspected or removed through the proxy. +Any request whose path contains a percent-encoded byte (`%`) is denied with 403 for every method, GET and HEAD included, because the daemon decodes the path before routing. The query string is not inspected, so filters such as `docker ps --filter …` still work. The Go implementation also denies paths that contain raw characters it must re-encode, such as non-ASCII bytes or `{`; the Docker CLI never sends these. A consequence is that networks whose names contain a space or `%` cannot be inspected through the proxy (other network operations are denied regardless). ## Configuration From 413ac9a768cc63ce6c88bd74ac76beb21e9f18d8 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 7 Oct 2026 16:26:16 -0400 Subject: [PATCH 8/8] docs: tighten the #53 network-name wording and a stale test comment (#53) --- README.md | 2 +- go/internal/proxy/handler_test.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 36a6873..3546af2 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ docker pull attacker/malware:latest # denied: image not in allowlist | GET/HEAD | Any path without `%` | Allowed (read-only) | | Other | Other | **DENIED** | -Any request whose path contains a percent-encoded byte (`%`) is denied with 403 for every method, GET and HEAD included, because the daemon decodes the path before routing. The query string is not inspected, so filters such as `docker ps --filter …` still work. The Go implementation also denies paths that contain raw characters it must re-encode, such as non-ASCII bytes or `{`; the Docker CLI never sends these. A consequence is that networks whose names contain a space or `%` cannot be inspected through the proxy (other network operations are denied regardless). +Any request whose path contains a percent-encoded byte (`%`) is denied with 403 for every method, GET and HEAD included, because the daemon decodes the path before routing. The query string is not inspected, so filters such as `docker ps --filter …` still work. The Go implementation also denies paths that contain raw characters it must re-encode, such as non-ASCII bytes or `{`; the Docker CLI never sends these. A consequence is that networks whose names need percent-encoding (for example a space or `%`) cannot be inspected by name through the proxy; inspecting them by ID still works, and other network operations are denied regardless. ## Configuration diff --git a/go/internal/proxy/handler_test.go b/go/internal/proxy/handler_test.go index f463853..7b33b39 100644 --- a/go/internal/proxy/handler_test.go +++ b/go/internal/proxy/handler_test.go @@ -96,8 +96,9 @@ func newPercentRequest(t *testing.T, method, target string) *http.Request { return req } -// #53: Go decodes the name to "foo bar" before routing; the daemon would act -// on that container. +// #53: before the fix the Go handler routed on the decoded r.URL.Path +// ("foo bar"), so a router-only fix would never see the '%' and would let +// this through. The handler must route on the escaped path. func TestHandler_DeniesPercentEncodedName(t *testing.T) { rec := &recorderTransport{} h := newTestHandler(t, nil, rec)