Skip to content
Merged
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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)
- Go: 108 unit tests (main/listener: 24, policy: 10, middleware: 29, proxy: 41, audit: 4)
- 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`)

## 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` (39 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose
- Integration: `make test-integration` (43 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose

## Contribution Workflow

Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ All three implementations expose the same API surface, share the same [Quint spe

| Language | Directory | Tests | Stack |
|----------|-----------|-------|-------|
| 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 |
| Go | [go/](go/) | 108 unit + 43 integration | stdlib net/http + yaml.v3 |
| Rust | [rs/](rs/) | 145 unit | tokio, hyper, serde, clap |
| TypeScript | [ts/](ts/) | 162 unit (1 skipped) | Node 22 ESM, built-in http |

### Build All

Expand Down Expand Up @@ -208,17 +208,20 @@ docker pull attacker/malware:latest # denied: image not in allowlist
| 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 |
| POST | `/containers/{name}/exec` | **DENIED** |
| Any | `/containers/…/exec` (an `exec` segment anywhere under `/containers`) | **DENIED** |
| Any | `/exec/*` | **DENIED** |
| POST | `/containers/{name}/rename\|update` | **DENIED** |
| POST | `/images/create` | Validated by registry gate |
| POST | `/auth` | **DENIED** |
| POST | `/build` | **DENIED** |
| POST | `/commit` | **DENIED** |
| GET/HEAD | Any path without `%` | Allowed (read-only) |
| GET/HEAD | Any other 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 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.

## Configuration

### CLI Flags
Expand Down
17 changes: 17 additions & 0 deletions deploy/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,23 @@ 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=<encoded> -> 200 (query not inspected)" "200" "$S"

# #49: exec and create are matched on whole path segments. Exec is denied for
# every method under /containers/<name>/exec and /exec/, GET included (exec
# inspect leaks command lines). A name that only starts with exec reaches the
# daemon (404, no such container — not a proxy 403). A create path with extra
# segments is not the create endpoint, even with an allowed image.
S=$(delete_status "$PROXY/containers/no-such/exec")
check "DELETE /containers/*/exec -> 403 (exec subpath, any method)" "403" "$S"

S=$(post_json '{"Image":"chainsafe/lodestar:beacon","Cmd":["--rcConfig","/data/config.yml"]}' "$PROXY/containers/create/extra")
check "POST /containers/create/extra -> 403 (not the create endpoint)" "403" "$S"

S=$(post_empty "$PROXY/containers/exec-nosuch/start")
check "POST /containers/exec-nosuch/start -> 404 (daemon answered, not proxy 403)" "404" "$S"

S=$(get_status "$PROXY/exec/0000000000000000000000000000000000000000000000000000000000000000/json")
check "GET /exec/*/json -> 403 (exec inspect denied)" "403" "$S"

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

echo ""
Expand Down
18 changes: 8 additions & 10 deletions go/internal/proxy/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package proxy

import (
"fmt"
"slices"
"strings"

"github.com/ChainSafe/docker-socket-policy/go/internal/policy"
Expand Down Expand Up @@ -49,7 +50,7 @@ func (r *Router) Route(method, path string, body map[string]interface{}) *RouteR
return &RouteResult{Action: ActionDeny, DenyMsg: "auth endpoint is not allowed"}
}

if matchEndpoint(path, "containers", "exec") {
if isExecPath(path) {
return &RouteResult{Action: ActionDeny, DenyMsg: "exec is not allowed"}
}

Expand All @@ -61,7 +62,7 @@ func (r *Router) Route(method, path string, body map[string]interface{}) *RouteR
return &RouteResult{Action: ActionDeny, DenyMsg: "commit is not allowed"}
}

if matchEndpoint(path, "containers", "create") && method == "POST" {
if path == "/containers/create" && method == "POST" {
return r.routeCreate(body)
}

Expand Down Expand Up @@ -94,7 +95,7 @@ func (r *Router) Route(method, path string, body map[string]interface{}) *RouteR
}
}

if matchEndpoint(path, "images", "create") && method == "POST" {
if path == "/images/create" && method == "POST" {
return r.routeImagePull(body)
}

Expand Down Expand Up @@ -192,13 +193,10 @@ func scanDigits(s string, i int) int {
return i
}

func matchEndpoint(path, resource, endpoint string) bool {
path = strings.TrimPrefix(path, "/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 2 {
return false
}
return parts[0] == resource && parts[1] == endpoint
// isExecPath matches exec on whole segments, so a name like exec-runner is not exec (#49).
func isExecPath(path string) bool {
segs := strings.Split(strings.TrimPrefix(path, "/"), "/")
return segs[0] == "exec" || (segs[0] == "containers" && slices.Contains(segs[1:], "exec"))
}

// reservedContainerSegments are Docker endpoints that sit where a container
Expand Down
98 changes: 88 additions & 10 deletions go/internal/proxy/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,26 +316,27 @@ allowed_image_prefixes:
r := NewRouter(m)

tests := []struct {
method string
path string
want Action
method string
path string
want Action
wantMsg string
}{
// Reserved: must not be mistaken for a container to remove.
// reservedJsonDeleteDeniedTest
{"DELETE", "/containers/json", ActionDeny},
{"DELETE", "/containers/json", ActionDeny, ""},
// reservedCreateDeleteDeniedTest
{"DELETE", "/containers/create", ActionDeny},
{"DELETE", "/containers/create", ActionDeny, ""},
// reservedExecDeleteDeniedTest: denied by the exec check, before the lifecycle branch.
{"DELETE", "/containers/exec", ActionDeny},
{"DELETE", "/containers/exec", ActionDeny, "exec is not allowed"},
// Listing and inspecting stay allowed via the GET/HEAD passthrough.
{"GET", "/containers/json", ActionAllow},
{"GET", "/containers/json", ActionAllow, ""},
// A real container name is still routed as a container.
// realNameDeleteAllowedTest
{"DELETE", "/containers/mycontainer", ActionAllow},
{"GET", "/containers/mycontainer", ActionAllow},
{"DELETE", "/containers/mycontainer", ActionAllow, ""},
{"GET", "/containers/mycontainer", ActionAllow, ""},
// The reserved word as a *sub*-resource is a normal inspect.
// reservedInSubpathAllowedTest
{"GET", "/containers/mycontainer/json", ActionAllow},
{"GET", "/containers/mycontainer/json", ActionAllow, ""},
}
for _, tt := range tests {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
Expand All @@ -344,6 +345,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.wantMsg != "" && got.DenyMsg != tt.wantMsg {
t.Fatalf("Route(%s, %s) deny msg = %q, want %q",
tt.method, tt.path, got.DenyMsg, tt.wantMsg)
}
})
}
}
Expand Down Expand Up @@ -530,6 +535,79 @@ allowed_image_prefixes:
}
}

// TestRouteExecAndExactEndpoints is the cross-language parity guard for #49.
//
// Exec and create are matched on whole path segments. Exec is denied for every
// method when the first segment is exec, or when the first segment is
// containers and a later segment is exactly exec; a name that only contains
// exec routes normally. POST /containers/create and POST /images/create match
// only with exactly two segments. Rows mirror the exec* and create* runs in
// spec/router.qnt.
func TestRouteExecAndExactEndpoints(t *testing.T) {
m := newTestManager(t, map[string]string{
"beacon.yaml": `
service_name: beacon
allowed_image_prefixes:
- chainsafe/lodestar
`,
})
r := NewRouter(m)
createBody := map[string]interface{}{"Image": "chainsafe/lodestar:next"}
pullBody := map[string]interface{}{"fromImage": "chainsafe/lodestar:next"}

tests := []struct {
method string
path string
body map[string]interface{}
want Action
wantMsg string
}{
// execSubpathDeleteDeniedTest (#49)
{"DELETE", "/containers/mycontainer/exec", nil, ActionDeny, "exec is not allowed"},
// execSubpathPostDeniedTest (#49)
{"POST", "/containers/mycontainer/exec", nil, ActionDeny, "exec is not allowed"},
// execPrefixNameStartAllowedTest (#49): an unknown container.
{"POST", "/containers/exec-runner/start", nil, ActionAllow, ""},
// execPrefixNameDeleteAllowedTest (#49): an unknown container.
{"DELETE", "/containers/exec-runner", nil, ActionAllow, ""},
// execPrefixNameGetAllowedTest (#49)
{"GET", "/containers/exec-runner/json", nil, ActionAllow, ""},
// execNamespaceGetDeniedTest (#49): exec inspect leaks command lines.
{"GET", "/exec/abc/json", nil, ActionDeny, "exec is not allowed"},
// execNamespacePostDeniedTest (#49)
{"POST", "/exec/abc/start", nil, ActionDeny, "exec is not allowed"},
// createSubpathDeniedTest (#49): an allowed image, so only the path decides.
{"POST", "/containers/create/extra", createBody, ActionDeny, ""},
// Exec is matched under containers or exec only (#49, language-only).
{"GET", "/images/exec", nil, ActionAllow, ""},
// An allowed image, so only the path decides (#49, language-only).
{"POST", "/images/create/extra", pullBody, ActionDeny, ""},
// A name ending in exec is a plain name (#49, language-only).
{"GET", "/containers/myexec/json", nil, ActionAllow, ""},
// A name starting with exec is a plain name (#49, language-only).
{"DELETE", "/containers/executor", nil, ActionAllow, ""},
// The reserved name, decided by the exec check (#49, language-only).
{"GET", "/containers/exec/json", nil, ActionDeny, "exec is not allowed"},
// A top-level name starting with exec is not the exec namespace (#49, language-only).
{"GET", "/executor", nil, ActionAllow, ""},
}
for _, tt := range tests {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
got := r.Route(tt.method, tt.path, tt.body)
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)
}
// Exact match: the default deny for POST /containers/x/exec ends in
// "exec is not allowed" too, so a substring check passes vacuously.
if tt.wantMsg != "" && got.DenyMsg != tt.wantMsg {
t.Fatalf("Route(%s, %s) deny msg = %q, want %q",
tt.method, tt.path, got.DenyMsg, tt.wantMsg)
}
})
}
}

func TestExtractContainerNameSkipsEmptySegment(t *testing.T) {
for _, path := range []string{"/containers/", "/containers//start"} {
if got := extractContainerName(path); got != "" {
Expand Down
87 changes: 78 additions & 9 deletions rs/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ impl Router {
if path.starts_with("/auth") {
return deny("auth endpoint is not allowed");
}
if path == "/containers/exec" || path.starts_with("/containers/") && path.contains("/exec") {
if is_exec_path(path) {
return deny("exec is not allowed");
}
if path.starts_with("/build") {
Expand Down Expand Up @@ -232,6 +232,16 @@ fn strip_api_version(path: &str) -> &str {
path
}

// Exec is matched on whole segments, so a name like exec-runner is not exec (#49).
fn is_exec_path(path: &str) -> bool {
let mut segs = path.strip_prefix('/').unwrap_or(path).split('/');
match segs.next() {
Some("exec") => true,
Some("containers") => segs.any(|s| s == "exec"),
_ => false,
}
}

fn extract_container_name(path: &str) -> Option<&str> {
let path = path.strip_prefix('/').unwrap_or(path);
let parts: Vec<&str> = path.split('/').collect();
Expand Down Expand Up @@ -477,24 +487,27 @@ mod tests {
let cases = [
// Reserved: must not be mistaken for a container to remove.
// reservedJsonDeleteDeniedTest
("DELETE", "/containers/json", Action::Deny),
("DELETE", "/containers/json", Action::Deny, None),
// reservedCreateDeleteDeniedTest
("DELETE", "/containers/create", Action::Deny),
("DELETE", "/containers/create", Action::Deny, None),
// reservedExecDeleteDeniedTest: denied by the exec check, before the lifecycle branch.
("DELETE", "/containers/exec", Action::Deny),
("DELETE", "/containers/exec", Action::Deny, Some("exec is not allowed")),
// Listing stays allowed, via the GET/HEAD passthrough.
("GET", "/containers/json", Action::Allow),
("GET", "/containers/json", Action::Allow, None),
// A real container name is still routed as a container.
// realNameDeleteAllowedTest
("DELETE", "/containers/mycontainer", Action::Allow),
("GET", "/containers/mycontainer", Action::Allow),
("DELETE", "/containers/mycontainer", Action::Allow, None),
("GET", "/containers/mycontainer", Action::Allow, None),
// Reserved words are only reserved in the name position.
// reservedInSubpathAllowedTest
("GET", "/containers/mycontainer/json", Action::Allow),
("GET", "/containers/mycontainer/json", Action::Allow, None),
];
for (method, path, want) in cases {
for (method, path, want, want_msg) in cases {
let got = router.route(method, path, None);
assert_eq!(got.action, want, "route({} {})", method, path);
if let Some(want_msg) = want_msg {
assert_eq!(got.deny_msg.as_deref(), Some(want_msg), "route({} {})", method, path);
}
}
}

Expand Down Expand Up @@ -634,6 +647,62 @@ mod tests {
}
}

/// Cross-language parity guard for #49. Exec and create are matched on
/// whole path segments. Exec is denied for every method when the first
/// segment is exec, or when the first segment is containers and a later
/// segment is exactly exec; a name that only contains exec routes
/// normally. POST /containers/create and POST /images/create match only
/// with exactly two segments. Rows mirror the exec* and create* runs in
/// spec/router.qnt.
#[test]
fn test_route_exec_and_exact_endpoints() {
let router = Router::new(make_manager(vec!["alpine"]));
let create_body: HashMap<String, serde_json::Value> =
serde_json::from_value(serde_json::json!({"Image": "alpine:latest"})).unwrap();
let pull_body: HashMap<String, serde_json::Value> =
serde_json::from_value(serde_json::json!({"fromImage": "alpine:latest"})).unwrap();
let exec = Some("exec is not allowed");
let cases = [
// execSubpathDeleteDeniedTest (#49)
("DELETE", "/containers/mycontainer/exec", None, Action::Deny, exec),
// execSubpathPostDeniedTest (#49)
("POST", "/containers/mycontainer/exec", None, Action::Deny, exec),
// execPrefixNameStartAllowedTest (#49): an unknown container.
("POST", "/containers/exec-runner/start", None, Action::Allow, None),
// execPrefixNameDeleteAllowedTest (#49): an unknown container.
("DELETE", "/containers/exec-runner", None, Action::Allow, None),
// execPrefixNameGetAllowedTest (#49)
("GET", "/containers/exec-runner/json", None, Action::Allow, None),
// execNamespaceGetDeniedTest (#49): exec inspect leaks command lines.
("GET", "/exec/abc/json", None, Action::Deny, exec),
// execNamespacePostDeniedTest (#49)
("POST", "/exec/abc/start", None, Action::Deny, exec),
// createSubpathDeniedTest (#49): an allowed image, so only the path decides.
("POST", "/containers/create/extra", Some(&create_body), Action::Deny, None),
// Exec is matched under containers or exec only (#49, language-only).
("GET", "/images/exec", None, Action::Allow, None),
// An allowed image, so only the path decides (#49, language-only).
("POST", "/images/create/extra", Some(&pull_body), Action::Deny, None),
// A name ending in exec is a plain name (#49, language-only).
("GET", "/containers/myexec/json", None, Action::Allow, None),
// A name starting with exec is a plain name (#49, language-only).
("DELETE", "/containers/executor", None, Action::Allow, None),
// The reserved name, decided by the exec check (#49, language-only).
("GET", "/containers/exec/json", None, Action::Deny, exec),
// A top-level name starting with exec is not the exec namespace (#49, language-only).
("GET", "/executor", None, Action::Allow, None),
];
for (method, path, body, want, want_msg) in cases {
let got = router.route(method, path, body);
assert_eq!(got.action, want, "route({} {}) deny msg = {:?}", method, path, got.deny_msg);
// Exact match: the default deny for POST /containers/x/exec ends in
// "exec is not allowed" too, so a substring check passes vacuously.
if let Some(want_msg) = want_msg {
assert_eq!(got.deny_msg.as_deref(), Some(want_msg), "route({} {})", method, path);
}
}
}

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