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
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: 97 unit tests (main/listener: 23, policy: 10, middleware: 29, proxy: 31, audit: 4)
- Rust: 135 unit tests (main/listener: 23, policy: 15, middleware: 50, proxy: 37, handler: 4, audit: 4, transport: 2)
- TypeScript: 154 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 41, proxy: 26, policy: 10, handler: 6, shutdown: 5, transport: 5, audit: 4)
- Integration, per implementation: 27 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose)
- Go: 99 unit tests (main/listener: 23, policy: 10, middleware: 29, proxy: 33, audit: 4)
- Rust: 137 unit tests (main/listener: 23, policy: 15, middleware: 50, proxy: 39, handler: 4, audit: 4, transport: 2)
- TypeScript: 155 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 41, proxy: 27, policy: 10, handler: 6, shutdown: 5, transport: 5, audit: 4)
- Integration, per implementation: 30 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

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

## Contribution Workflow

Expand Down
19 changes: 19 additions & 0 deletions deploy/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ post_empty() {
-X POST -H "Content-Type: application/json" -d "" "$1" 2>/dev/null || true)
echo "${out:-000}"
}
delete_status() {
out=$(curl -s -o /dev/null -w '%{http_code}' $TIMEOUT --unix-socket "$PROXY_SOCK" \
-X DELETE "$1" 2>/dev/null || true)
echo "${out:-000}"
}

check() {
desc="$1"
Expand Down Expand Up @@ -296,6 +301,20 @@ check "POST /volumes/create -> 403" "403" "$S"
S=$(post_empty "$PROXY/containers/test")
check "PATCH /containers/test -> 403" "403" "$S"

# Reserved path segments (#24). /containers/json is the list endpoint, not a
# container called "json". Treating it as a container name sent the request
# down the lifecycle path, where an unknown container is allowed through, so Go
# allowed this while Rust and TypeScript denied it.
S=$(delete_status "$PROXY/containers/json")
check "DELETE /containers/json -> 403 (reserved, not a container)" "403" "$S"

S=$(delete_status "$PROXY/containers/create")
check "DELETE /containers/create -> 403 (reserved, not a container)" "403" "$S"

# Listing must still work: it reaches the GET/HEAD passthrough instead.
S=$(get_status "$PROXY/containers/json")
check "GET /containers/json -> 200 (still the list endpoint)" "200" "$S"

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

echo ""
Expand Down
14 changes: 13 additions & 1 deletion go/internal/proxy/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,22 @@ func matchEndpoint(path, resource, endpoint string) bool {
return parts[0] == resource && parts[1] == endpoint
}

// reservedContainerSegments are Docker endpoints that sit where a container
// name would: /containers/json lists, /containers/create creates. Treating one
// as a container name routes the request down the lifecycle path, where an
// unknown container is allowed through — so DELETE /containers/json would be
// allowed rather than denied. Rust (rs/src/proxy.rs) and TypeScript
// (ts/src/proxy.ts) exclude the same set.
var reservedContainerSegments = map[string]bool{
"create": true,
"json": true,
"exec": true,
}

func extractContainerName(path string) string {
path = strings.TrimPrefix(path, "/")
parts := strings.Split(path, "/")
if len(parts) >= 2 && parts[0] == "containers" {
if len(parts) >= 2 && parts[0] == "containers" && !reservedContainerSegments[parts[1]] {
return parts[1]
}
return ""
Expand Down
62 changes: 62 additions & 0 deletions go/internal/proxy/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,65 @@ func TestRouterDenyPostOnReadOnly(t *testing.T) {
t.Fatalf("expected ActionDeny for POST on non-lifecycle path, got %v", result.Action)
}
}

// TestRouterReservedPathSegments is the cross-language parity guard for #24.
//
// /containers/<x> is ambiguous: <x> is usually a container name, but Docker
// also has reserved endpoints at that position (/containers/json to list,
// /containers/create to create). Treating a reserved word as a container name
// sends the request down the lifecycle path, where an unknown container is
// allowed through — so DELETE /containers/json was allowed in Go while Rust
// and TypeScript denied it.
//
// GET stays allowed either way: it reaches the GET/HEAD passthrough instead.
func TestRouterReservedPathSegments(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
}{
// Reserved: must not be mistaken for a container to remove.
{"DELETE", "/containers/json", ActionDeny},
{"DELETE", "/containers/create", ActionDeny},
// Listing and inspecting stay allowed via the GET/HEAD passthrough.
{"GET", "/containers/json", ActionAllow},
// A real container name is still routed as a container.
{"DELETE", "/containers/mycontainer", ActionAllow},
{"GET", "/containers/mycontainer", ActionAllow},
// The reserved word as a *sub*-resource is a normal inspect.
{"GET", "/containers/mycontainer/json", 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 TestExtractContainerNameSkipsReservedSegments(t *testing.T) {
for _, reserved := range []string{"create", "json", "exec"} {
if got := extractContainerName("/containers/" + reserved); got != "" {
t.Errorf("extractContainerName(/containers/%s) = %q, want \"\"", reserved, got)
}
}
if got := extractContainerName("/containers/mycontainer"); got != "mycontainer" {
t.Errorf("extractContainerName(/containers/mycontainer) = %q, want \"mycontainer\"", got)
}
// Reserved words are only reserved in the name position.
if got := extractContainerName("/containers/mycontainer/json"); got != "mycontainer" {
t.Errorf("extractContainerName(/containers/mycontainer/json) = %q, want \"mycontainer\"", got)
}
}
39 changes: 39 additions & 0 deletions rs/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,45 @@ mod tests {
assert_eq!(result.action, Action::Allow);
}

/// Cross-language parity guard for #24. /containers/<x> is ambiguous: <x>
/// is usually a container name, but Docker also has reserved endpoints at
/// that position. Treating one as a container name routes the request down
/// the lifecycle path, where an unknown container is allowed through — Go
/// allowed DELETE /containers/json for exactly that reason.
#[test]
fn test_route_reserved_path_segments() {
let router = Router::new(make_manager(vec!["alpine"]));
let cases = [
// Reserved: must not be mistaken for a container to remove.
("DELETE", "/containers/json", Action::Deny),
("DELETE", "/containers/create", Action::Deny),
// Listing stays allowed, via the GET/HEAD passthrough.
("GET", "/containers/json", Action::Allow),
// A real container name is still routed as a container.
("DELETE", "/containers/mycontainer", Action::Allow),
("GET", "/containers/mycontainer", Action::Allow),
// Reserved words are only reserved in the name position.
("GET", "/containers/mycontainer/json", 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_reserved_segments() {
for reserved in ["create", "json", "exec"] {
let path = format!("/containers/{}", reserved);
assert_eq!(extract_container_name(&path), None, "{} should be reserved", path);
}
assert_eq!(extract_container_name("/containers/mycontainer"), Some("mycontainer"));
assert_eq!(
extract_container_name("/containers/mycontainer/json"),
Some("mycontainer")
);
}

#[test]
fn test_route_image_pull() {
let router = Router::new(make_manager(vec!["alpine"]));
Expand Down
24 changes: 24 additions & 0 deletions ts/src/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,28 @@ describe("Router", () => {
assert.equal(r.action, Action.Allow);
assert.equal(r.service, "nginx-svc");
});

// Cross-language parity guard for #24. /containers/<x> is ambiguous: <x> is
// usually a container name, but Docker also has reserved endpoints at that
// position. Treating one as a container name routes the request down the
// lifecycle path, where an unknown container is allowed through — Go allowed
// DELETE /containers/json for exactly that reason.
it("does not treat reserved path segments as container names", () => {
const cases: [string, string, Action][] = [
// Reserved: must not be mistaken for a container to remove.
["DELETE", "/containers/json", Action.Deny],
["DELETE", "/containers/create", Action.Deny],
// Listing stays allowed, via the GET/HEAD passthrough.
["GET", "/containers/json", Action.Allow],
// A real container name is still routed as a container.
["DELETE", "/containers/mycontainer", Action.Allow],
["GET", "/containers/mycontainer", Action.Allow],
// Reserved words are only reserved in the name position.
["GET", "/containers/mycontainer/json", Action.Allow],
];
for (const [method, path, want] of cases) {
const r = router.route(method, path);
assert.equal(r.action, want, `route(${method} ${path})`);
}
});
});
Loading