Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ deploy/ — Docker Compose + integration tests
- Rust: 145 unit tests (main/listener: 23, policy: 15, middleware: 50, proxy: 44, handler: 7, audit: 4, transport: 2)
- TypeScript: 162 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 41, proxy: 31, policy: 10, handler: 9, shutdown: 5, transport: 5, audit: 4)
- Integration, per implementation: 43 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`)
- 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`, `router_pre55`)

## Test Conventions
- Go: stdlib `testing` package, `go test ./...`
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ test-spec:
$(QUINT) test $(ROUTER_SPEC) --main=router
$(QUINT) test $(ROUTER_SPEC) --main=router_pre48
$(QUINT) test $(ROUTER_SPEC) --main=router_pre53
$(QUINT) test $(ROUTER_SPEC) --main=router_pre55

verify-ts:
$(QUINT) run $(SPEC) --max-steps=50 --invariants allInvariants --backend typescript
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ docker pull attacker/malware:latest # denied: image not in allowlist
| HTTP Method | Path | Action |
|-------------|------|--------|
| Any | Path containing `%` | **DENIED** (see below) |
| Any | Path containing `//` | **DENIED** |
| 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 |
Expand All @@ -215,12 +216,12 @@ docker pull attacker/malware:latest # denied: image not in allowlist
| POST | `/auth` | **DENIED** |
| POST | `/build` | **DENIED** |
| POST | `/commit` | **DENIED** |
| GET/HEAD | Any other path without `%` | Allowed (read-only) |
| GET/HEAD | Any other path without `%` or `//` | 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 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.

Exec is matched on whole path segments, so containers named like `exec-runner` work normally, and exec inspect (`GET /exec/{id}/json`) is denied too.
Exec is matched on whole path segments, so containers named like `exec-runner` work normally, and exec inspect (`GET /exec/{id}/json`) is denied too. A path with an empty segment (`//`, as in `//exec/{id}/json`) is denied for every method, so an empty segment cannot hide the exec namespace; a single trailing slash is fine, and `//` in the query string is not inspected. Absolute-form request targets (`GET http://host/…`) never reach exec: Go and Rust route them on the parsed path, so exec is denied and other paths are routed as usual, while the TypeScript proxy sees the raw target and denies every absolute-form request (its `//` after the scheme is an empty segment). The Docker CLI never sends absolute form.

## Configuration

Expand Down
18 changes: 18 additions & 0 deletions deploy/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,24 @@ check "POST /containers/exec-nosuch/start -> 404 (daemon answered, not proxy 403
S=$(get_status "$PROXY/exec/0000000000000000000000000000000000000000000000000000000000000000/json")
check "GET /exec/*/json -> 403 (exec inspect denied)" "403" "$S"

# #55: an empty interior segment is denied for every method. Before #55 the
# empty first segment of //exec/... hid the exec namespace and the GET
# passthrough forwarded it. --path-as-is stops curl from collapsing the //.
S=$(curl -s -o /dev/null -w '%{http_code}' $TIMEOUT --path-as-is --unix-socket "$PROXY_SOCK" \
"$PROXY//exec/0000000000000000000000000000000000000000000000000000000000000000/json" 2>/dev/null || true)
S="${S:-000}"
check "GET //exec/*/json -> 403 (empty segment)" "403" "$S"

# #55: an absolute-form request target. Before #55 the TypeScript router saw
# the raw target, whose first segment is "http:", and forwarded exec inspect;
# the daemon answered 404 for this unknown ID (200 with the command line for a
# real one). Go and Rust route on the parsed path.
S=$(curl -s -o /dev/null -w '%{http_code}' $TIMEOUT --unix-socket "$PROXY_SOCK" \
--request-target "http://docker/exec/0000000000000000000000000000000000000000000000000000000000000000/json" \
"$PROXY/" 2>/dev/null || true)
S="${S:-000}"
check "GET http://docker/exec/*/json -> 403 (absolute-form target)" "403" "$S"

# ─── Summary ──────────────────────────────────────────

echo ""
Expand Down
5 changes: 5 additions & 0 deletions go/internal/proxy/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ func (r *Router) Route(method, path string, body map[string]interface{}) *RouteR
return &RouteResult{Action: ActionDeny, DenyMsg: "percent-encoded path not allowed"}
}

// An empty interior segment can hide a denied prefix such as /exec (#55).
if strings.Contains(path, "//") {
return &RouteResult{Action: ActionDeny, DenyMsg: "empty path segment not allowed"}
}

path = stripAPIVersion(path)

if path == "/_ping" || path == "/version" || path == "/info" || strings.HasPrefix(path, "/events") {
Expand Down
67 changes: 65 additions & 2 deletions go/internal/proxy/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,7 @@ allowed_image_prefixes:
{"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.
// #53, language-only: the rule covers every method, HEAD included.
{"HEAD", "/containers/%2F", ActionDeny},
// #53: the check runs on the versioned path too.
{"DELETE", "/v1.45/containers/foo%25", ActionDeny},
Expand Down Expand Up @@ -608,6 +607,70 @@ allowed_image_prefixes:
}
}

// TestRouteEmptySegmentsAndHead is the cross-language parity guard for #55.
//
// A path with an empty interior segment (// anywhere in the path) is denied
// for every method, after the percent check and before the API-version strip;
// a single trailing slash is not interior. HEAD on a named container is routed
// like GET. Rows mirror the emptyLeadingExecGetDenied, emptyInteriorGetDenied
// and headNamedContainerAllowed runs in spec/router.qnt.
func TestRouteEmptySegmentsAndHead(t *testing.T) {
m := newTestManager(t, map[string]string{
"beacon.yaml": `
service_name: beacon
allowed_image_prefixes:
- chainsafe/lodestar
`,
})
r := NewRouter(m)
empty := "empty path segment not allowed"
percent := "percent-encoded path not allowed"
exec := "exec is not allowed"

tests := []struct {
method string
path string
want Action
wantMsg string
}{
// emptyLeadingExecGetDeniedTest (#55): the leading empty segment hid the exec namespace.
{"GET", "//exec/abc/json", ActionDeny, empty},
// #55: the empty segment after the version prefix hides the exec namespace too.
{"GET", "/v1.45//exec/abc/json", ActionDeny, empty},
// #55: the check runs before the version strip.
{"GET", "//v1.45/exec/abc/json", ActionDeny, empty},
// emptyInteriorGetDeniedTest (#55)
{"GET", "/containers//json", ActionDeny, empty},
// #55: emptyNameStartDeniedTest (#48) is now decided by the empty segment check.
{"POST", "/containers//start", ActionDeny, empty},
// #55: an empty name followed by a trailing slash.
{"DELETE", "/containers//", ActionDeny, empty},
// #55: the read-only endpoints are not exempt.
{"GET", "//_ping", ActionDeny, empty},
// #55: the percent check runs first.
{"GET", "//containers/%2F", ActionDeny, percent},
// #55 control: a single trailing slash is not an empty interior segment.
{"GET", "/containers/", ActionAllow, ""},
// #55 control: the root path.
{"GET", "/", ActionAllow, ""},
// headNamedContainerAllowedTest (#55)
{"HEAD", "/containers/mycontainer", ActionAllow, ""},
// #55: HEAD on a container subpath.
{"HEAD", "/containers/mycontainer/json", ActionAllow, ""},
// #55: the exec check still denies HEAD.
{"HEAD", "/containers/mycontainer/exec", ActionDeny, exec},
}
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 || got.DenyMsg != tt.wantMsg {
t.Fatalf("Route(%s, %s) = %v/%q, want %v/%q",
tt.method, tt.path, got.Action, got.DenyMsg, tt.want, tt.wantMsg)
}
})
}
}

func TestExtractContainerNameSkipsEmptySegment(t *testing.T) {
for _, path := range []string{"/containers/", "/containers//start"} {
if got := extractContainerName(path); got != "" {
Expand Down
68 changes: 65 additions & 3 deletions rs/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ impl Router {
return deny("percent-encoded path not allowed");
}

// An empty interior segment can hide a denied prefix such as /exec (#55).
if path.contains("//") {
return deny("empty path segment not allowed");
}

let path = strip_api_version(path);

// Read-only endpoints
Expand Down Expand Up @@ -81,7 +86,7 @@ impl Router {
_ if path.ends_with("/rename") && method == "POST" => deny("rename is not allowed"),
_ if path.ends_with("/update") && method == "POST" => deny("update is not allowed"),
_ if method == "DELETE" => self.route_by_name(name),
_ if method == "GET" => allow(),
_ if method == "GET" || method == "HEAD" => allow(),
_ => deny(&format!("endpoint {} {} is not allowed", method, path)),
};
}
Expand Down Expand Up @@ -618,8 +623,7 @@ mod tests {
("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.
// #53, language-only: the rule covers every method, HEAD included.
("HEAD", "/containers/%2F", Action::Deny),
// #53: the check runs on the versioned path too.
("DELETE", "/v1.45/containers/foo%25", Action::Deny),
Expand Down Expand Up @@ -712,6 +716,64 @@ mod tests {
}
}

/// Cross-language parity guard for #55. A path with an empty interior
/// segment (// anywhere in the path) is denied for every method, after the
/// percent check and before the API-version strip; a single trailing slash
/// is not interior. HEAD on a named container is routed like GET. Rows
/// mirror the emptyLeadingExecGetDenied, emptyInteriorGetDenied and
/// headNamedContainerAllowed runs in spec/router.qnt.
#[test]
fn test_route_empty_segments_and_head() {
let router = Router::new(make_manager(vec!["alpine"]));
let empty = Some("empty path segment not allowed");
let percent = Some("percent-encoded path not allowed");
let exec = Some("exec is not allowed");
let cases = [
// emptyLeadingExecGetDeniedTest (#55): the leading empty segment hid the exec namespace.
("GET", "//exec/abc/json", Action::Deny, empty),
// #55: the empty segment after the version prefix hides the exec namespace too.
("GET", "/v1.45//exec/abc/json", Action::Deny, empty),
// #55: the check runs before the version strip.
("GET", "//v1.45/exec/abc/json", Action::Deny, empty),
// emptyInteriorGetDeniedTest (#55)
("GET", "/containers//json", Action::Deny, empty),
// #55: emptyNameStartDeniedTest (#48) is now decided by the empty segment check.
("POST", "/containers//start", Action::Deny, empty),
// #55: an empty name followed by a trailing slash.
("DELETE", "/containers//", Action::Deny, empty),
// #55: the read-only endpoints are not exempt.
("GET", "//_ping", Action::Deny, empty),
// #55: the percent check runs first.
("GET", "//containers/%2F", Action::Deny, percent),
// #55 control: a single trailing slash is not an empty interior segment.
("GET", "/containers/", Action::Allow, None),
// #55 control: the root path.
("GET", "/", Action::Allow, None),
// headNamedContainerAllowedTest (#55)
("HEAD", "/containers/mycontainer", Action::Allow, None),
// #55: HEAD on a container subpath.
("HEAD", "/containers/mycontainer/json", Action::Allow, None),
// #55: the exec check still denies HEAD.
("HEAD", "/containers/mycontainer/exec", Action::Deny, exec),
];
let mut mismatches = Vec::new();
for (method, path, want, want_msg) in cases {
let got = router.route(method, path, None);
if got.action != want || got.deny_msg.as_deref() != want_msg {
mismatches.push(format!(
"{} {}: got {:?}/{}, want {:?}/{}",
method,
path,
got.action,
got.deny_msg.as_deref().unwrap_or("-"),
want,
want_msg.unwrap_or("-")
));
}
}
assert!(mismatches.is_empty(), "mismatches:\n{}", mismatches.join("\n"));
}

#[test]
fn test_extract_container_name_skips_empty_segment() {
for path in ["/containers/", "/containers//start"] {
Expand Down
Loading
Loading