From fe37775d4b729462a502250c246b815d7929ba8c Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Sat, 10 Oct 2026 22:00:54 -0400 Subject: [PATCH 1/2] test: tighten listener and route-table assertions (#55) --- go/main_test.go | 12 +++---- rs/src/main.rs | 4 +++ rs/src/proxy.rs | 79 +++++++++++++++++++++++++++++++------------ ts/src/flags.ts | 16 +++++++-- ts/src/listen.test.ts | 8 +++-- ts/src/proxy.test.ts | 41 +++++++++++++++------- 6 files changed, 113 insertions(+), 47 deletions(-) diff --git a/go/main_test.go b/go/main_test.go index d848b1b..d1de6ca 100644 --- a/go/main_test.go +++ b/go/main_test.go @@ -410,8 +410,8 @@ func TestOpenListenerConcurrent(t *testing.T) { winner = &r continue } - if !strings.Contains(r.err.Error(), "is in use by another") { - t.Fatalf("iteration %d: loser error = %q, want an in-use error", i, r.err) + if want := path + " is in use by another instance (lock " + path + ".lock held)"; r.err.Error() != want { + t.Fatalf("iteration %d: loser error = %q, want %q", i, r.err, want) } } if winner == nil { @@ -474,6 +474,7 @@ func TestLockReleasedOnSIGKILL(t *testing.T) { if err := cmd.Start(); err != nil { t.Fatal(err) } + t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) ready := make(chan error, 1) go func() { @@ -489,13 +490,9 @@ func TestLockReleasedOnSIGKILL(t *testing.T) { select { case err := <-ready: if err != nil { - cmd.Process.Kill() - cmd.Wait() t.Fatal(err) } case <-time.After(10 * time.Second): - cmd.Process.Kill() - cmd.Wait() t.Fatal("child did not report ready within 10s") } @@ -856,6 +853,9 @@ func TestUnixListenerChownEPERMNamesGroup(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root can chown to any group") } + if os.Getegid() == 0 { + t.Skip("process's own group is gid 0, so chown to it succeeds") + } if groups, err := os.Getgroups(); err == nil { for _, g := range groups { if g == 0 { diff --git a/rs/src/main.rs b/rs/src/main.rs index 03c3c47..bca469c 100644 --- a/rs/src/main.rs +++ b/rs/src/main.rs @@ -1262,6 +1262,10 @@ mod tests { eprintln!("skipping: root can chown to any group"); return; } + if unsafe { libc::getegid() } == 0 { + eprintln!("skipping: process's own group is gid 0, so chown to it succeeds"); + return; + } // SAFETY: a zero-length query returns the group count; the second // call fills a buffer of exactly that size. let n = unsafe { libc::getgroups(0, std::ptr::null_mut()) }; diff --git a/rs/src/proxy.rs b/rs/src/proxy.rs index 24dbc6c..701e307 100644 --- a/rs/src/proxy.rs +++ b/rs/src/proxy.rs @@ -501,13 +501,22 @@ mod tests { // reservedInSubpathAllowedTest ("GET", "/containers/mycontainer/json", Action::Allow, None), ]; + let mut mismatches = Vec::new(); 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); + if got.action != want || (want_msg.is_some() && 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] @@ -539,10 +548,21 @@ mod tests { // emptyNameGetAllowedTest ("GET", "/containers/", Action::Allow), ]; + let mut mismatches = Vec::new(); for (method, path, want) in cases { let got = router.route(method, path, None); - assert_eq!(got.action, want, "route({} {})", method, path); + if got.action != want { + mismatches.push(format!( + "{} {}: got {:?}/{}, want {:?}", + method, + path, + got.action, + got.deny_msg.as_deref().unwrap_or("-"), + want + )); + } } + assert!(mismatches.is_empty(), "mismatches:\n{}", mismatches.join("\n")); } /// Cross-language parity guard for #52 and #57. The Docker CLI prefixes @@ -594,10 +614,21 @@ mod tests { // #57 sanity row, cannot fail: GET /version is allowed as a read-only request. ("GET", "/version", None, Action::Allow), ]; + let mut mismatches = Vec::new(); for (method, path, body, want) in cases { let got = router.route(method, path, body); - assert_eq!(got.action, want, "route({} {})", method, path); + if got.action != want { + mismatches.push(format!( + "{} {}: got {:?}/{}, want {:?}", + method, + path, + got.action, + got.deny_msg.as_deref().unwrap_or("-"), + want + )); + } } + assert!(mismatches.is_empty(), "mismatches:\n{}", mismatches.join("\n")); } /// Cross-language parity guard for #53. The daemon percent-decodes the @@ -634,20 +665,19 @@ mod tests { // #53 control: a plain name is still routed as a container. ("DELETE", "/containers/foo", Action::Allow), ]; + let mut mismatches = Vec::new(); 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 - ); + let msg = got.deny_msg.as_deref().unwrap_or("-"); + if got.action != want || (want == Action::Deny && !msg.contains("percent-encoded")) { + let want_msg = if want == Action::Deny { "*percent-encoded*" } else { "-" }; + mismatches.push(format!( + "{} {}: got {:?}/{}, want {:?}/{}", + method, path, got.action, msg, want, want_msg + )); } } + assert!(mismatches.is_empty(), "mismatches:\n{}", mismatches.join("\n")); } /// Cross-language parity guard for #49. Exec and create are matched on @@ -701,19 +731,24 @@ mod tests { // A top-level name starting with exec is not the exec namespace (#49, language-only). ("GET", "/executor", None, Action::Allow, None), ]; + let mut mismatches = Vec::new(); 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); + if got.action != want || (want_msg.is_some() && 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")); } /// Cross-language parity guard for #55. A path with an empty interior diff --git a/ts/src/flags.ts b/ts/src/flags.ts index 428e54f..6598dc7 100644 --- a/ts/src/flags.ts +++ b/ts/src/flags.ts @@ -18,8 +18,14 @@ export function hasFlag(args: string[], name: string): boolean { } // The flags the proxy accepts, for validateFlags. -export const VALUE_FLAGS = ["--listen-socket", "--docker-host", "--config-dir", "--log-file", "--listen-socket-group"]; -export const BOOL_FLAGS = ["--readonly"]; +export const VALUE_FLAGS = Object.freeze([ + "--listen-socket", + "--docker-host", + "--config-dir", + "--log-file", + "--listen-socket-group", +] as const); +export const BOOL_FLAGS = Object.freeze(["--readonly"] as const); // Rejects anything not recognised. Go's flag package and Rust's clap both exit // non-zero on an unrecognised argument; without this the hand-rolled parser @@ -30,7 +36,11 @@ export const BOOL_FLAGS = ["--readonly"]; // `valueFlags` take an argument (in either `--name value` or `--name=value` // form); `boolFlags` do not, and must not swallow the following argument. // Returns an error message, or null when every argument is recognised. -export function validateFlags(args: string[], valueFlags: string[], boolFlags: string[]): string | null { +export function validateFlags( + args: string[], + valueFlags: readonly string[], + boolFlags: readonly string[], +): string | null { const known = [...valueFlags, ...boolFlags]; for (let i = 0; i < args.length; i++) { const arg = args[i]; diff --git a/ts/src/listen.test.ts b/ts/src/listen.test.ts index 5d03184..230f8fd 100644 --- a/ts/src/listen.test.ts +++ b/ts/src/listen.test.ts @@ -229,9 +229,11 @@ describe("listenOnSocket chown EPERM", () => { const skip = process.geteuid?.() === 0 ? "root can chown to any group" - : process.getgroups?.().includes(0) - ? "process is a member of gid 0, so chown to it succeeds" - : false; + : process.getegid?.() === 0 + ? "process's own group is gid 0, so chown to it succeeds" + : process.getgroups?.().includes(0) + ? "process is a member of gid 0, so chown to it succeeds" + : false; it("names the group", { skip }, async () => { const { path, cleanup } = tempSocket(); diff --git a/ts/src/proxy.test.ts b/ts/src/proxy.test.ts index c11c7c5..d4f2ceb 100644 --- a/ts/src/proxy.test.ts +++ b/ts/src/proxy.test.ts @@ -197,13 +197,16 @@ describe("Router", () => { // reservedInSubpathAllowedTest ["GET", "/containers/mycontainer/json", Action.Allow], ]; + const mismatches: string[] = []; for (const [method, path, want, wantMsg] of cases) { const r = router.route(method, path); - assert.equal(r.action, want, `route(${method} ${path})`); - if (wantMsg !== undefined) { - assert.equal(r.denyMsg, wantMsg, `route(${method} ${path})`); + if (r.action !== want || (wantMsg !== undefined && r.denyMsg !== wantMsg)) { + mismatches.push( + `${method} ${path}: got ${Action[r.action]}/${r.denyMsg ?? "-"}, want ${Action[want]}/${wantMsg ?? "-"}`, + ); } } + assert.deepEqual(mismatches, []); }); // Cross-language parity guard for #48. An empty segment in the name position @@ -219,10 +222,14 @@ describe("Router", () => { // emptyNameGetAllowedTest ["GET", "/containers/", Action.Allow], ]; + const mismatches: string[] = []; for (const [method, path, want] of cases) { const r = router.route(method, path); - assert.equal(r.action, want, `route(${method} ${path})`); + if (r.action !== want) { + mismatches.push(`${method} ${path}: got ${Action[r.action]}/${r.denyMsg ?? "-"}, want ${Action[want]}`); + } } + assert.deepEqual(mismatches, []); }); // Cross-language parity guard for #52 and #57. The Docker CLI prefixes every @@ -265,10 +272,14 @@ describe("Router", () => { // #57 sanity row, cannot fail: GET /version is allowed as a read-only request. ["GET", "/version", undefined, Action.Allow], ]; + const mismatches: string[] = []; for (const [method, path, body, want] of cases) { const r = router.route(method, path, body); - assert.equal(r.action, want, `route(${method} ${path})`); + if (r.action !== want) { + mismatches.push(`${method} ${path}: got ${Action[r.action]}/${r.denyMsg ?? "-"}, want ${Action[want]}`); + } } + assert.deepEqual(mismatches, []); }); // Cross-language parity guard for #53. The daemon percent-decodes the path @@ -307,16 +318,17 @@ describe("Router", () => { // Non-GET, so the GET passthrough cannot hide a check placed before the split. ["DELETE", "/containers/foo?force=%31", Action.Allow], ]; + const mismatches: string[] = []; 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)}`, + if (r.action !== want || (want === Action.Deny && !r.denyMsg?.includes("percent-encoded"))) { + const wantMsg = want === Action.Deny ? "*percent-encoded*" : "-"; + mismatches.push( + `${method} ${path}: got ${Action[r.action]}/${r.denyMsg ?? "-"}, want ${Action[want]}/${wantMsg}`, ); } } + assert.deepEqual(mismatches, []); }); // Cross-language parity guard for #49. Exec and create are matched on whole @@ -359,15 +371,18 @@ describe("Router", () => { // A top-level name starting with exec is not the exec namespace (#49, language-only). ["GET", "/executor", undefined, Action.Allow, undefined], ]; + const mismatches: string[] = []; for (const [method, path, body, want, wantMsg] of cases) { const r = router.route(method, path, body); - assert.equal(r.action, want, `route(${method} ${path}) deny msg = ${JSON.stringify(r.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 (wantMsg !== undefined) { - assert.equal(r.denyMsg, wantMsg, `route(${method} ${path})`); + if (r.action !== want || (wantMsg !== undefined && r.denyMsg !== wantMsg)) { + mismatches.push( + `${method} ${path}: got ${Action[r.action]}/${r.denyMsg ?? "-"}, want ${Action[want]}/${wantMsg ?? "-"}`, + ); } } + assert.deepEqual(mismatches, []); }); // Cross-language parity guard for #55. A path with an empty interior segment From 0db7a11fb7def46b58291a266a63dce9a7ef13f4 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Sat, 10 Oct 2026 22:05:57 -0400 Subject: [PATCH 2/2] test: assert proxy deny messages in the integration suite (#55) --- deploy/test.sh | 108 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 72 insertions(+), 36 deletions(-) diff --git a/deploy/test.sh b/deploy/test.sh index a5e3eae..9b4d0de 100755 --- a/deploy/test.sh +++ b/deploy/test.sh @@ -1,6 +1,7 @@ #!/bin/sh # Integration tests for docker-socket-policy. -# Sends HTTP requests directly to the proxy's Docker API and checks status codes. +# Sends HTTP requests directly to the proxy's Docker API and checks status codes, +# and the deny message on every expected proxy 403. # The proxy's ReadonlyGate, RegistryGate, MountSourceGate, EnvFileGate, # CmdGate, ExecGate, and ContainerConfigMutator are all exercised. @@ -54,22 +55,30 @@ echo "" # curl still prints %{http_code} when it exits non-zero, so the fallback only # applies when it produced nothing at all. Overwriting unconditionally would # discard a real status on a mid-response error and report it as 000. +# The response body goes to $BODY for check_denied. It is truncated first, so a +# request that gets no body cannot pass on the previous request's body. +BODY=$(mktemp) +trap 'rm -f "$BODY"' EXIT get_status() { - out=$(curl -s -o /dev/null -w '%{http_code}' $TIMEOUT --unix-socket "$PROXY_SOCK" "$1" 2>/dev/null || true) + : >"$BODY" + out=$(curl -s -o "$BODY" -w '%{http_code}' $TIMEOUT --unix-socket "$PROXY_SOCK" "$1" 2>/dev/null || true) echo "${out:-000}" } post_json() { - out=$(curl -s -o /dev/null -w '%{http_code}' $TIMEOUT --unix-socket "$PROXY_SOCK" \ + : >"$BODY" + out=$(curl -s -o "$BODY" -w '%{http_code}' $TIMEOUT --unix-socket "$PROXY_SOCK" \ -X POST -H "Content-Type: application/json" -d "$1" "$2" 2>/dev/null || true) echo "${out:-000}" } post_empty() { - out=$(curl -s -o /dev/null -w '%{http_code}' $TIMEOUT --unix-socket "$PROXY_SOCK" \ + : >"$BODY" + out=$(curl -s -o "$BODY" -w '%{http_code}' $TIMEOUT --unix-socket "$PROXY_SOCK" \ -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" \ + : >"$BODY" + out=$(curl -s -o "$BODY" -w '%{http_code}' $TIMEOUT --unix-socket "$PROXY_SOCK" \ -X DELETE "$1" 2>/dev/null || true) echo "${out:-000}" } @@ -87,6 +96,28 @@ check() { fi } +# check_denied DESC ACTUAL_STATUS BODY_FILE WANT_SUBSTRING [WANT_SUBSTRING...] +# Passes only on a 403 whose body contains one of the WANT_SUBSTRINGs, so a +# 403 from the daemon (or a different proxy deny) cannot pass for the one the +# check is about. The proxy's body is its plain-text deny message. +check_denied() { + desc="$1" + actual="$2" + body_file="$3" + shift 3 + if [ "$actual" = "403" ]; then + for want in "$@"; do + if grep -qF -- "$want" "$body_file"; then + echo " PASS: $desc" + PASS=$((PASS+1)) + return 0 + fi + done + fi + echo " FAIL: $desc (expected 403 with \"$*\", got $actual: $(head -c 200 "$body_file"))" + FAIL=$((FAIL+1)) +} + echo "" echo "============================================" echo " docker-socket-policy integration tests" @@ -151,19 +182,19 @@ echo "" echo "--- Denied endpoints ---" S=$(post_empty "$PROXY/containers/test/exec") -check "POST /containers/*/exec -> 403" "403" "$S" +check_denied "POST /containers/*/exec -> 403" "$S" "$BODY" 'exec is not allowed' S=$(post_empty "$PROXY/exec/abc123/start") -check "POST /exec/*/start -> 403" "403" "$S" +check_denied "POST /exec/*/start -> 403" "$S" "$BODY" 'exec is not allowed' S=$(post_empty "$PROXY/build") -check "POST /build -> 403" "403" "$S" +check_denied "POST /build -> 403" "$S" "$BODY" 'build is not allowed' S=$(post_empty "$PROXY/commit") -check "POST /commit -> 403" "403" "$S" +check_denied "POST /commit -> 403" "$S" "$BODY" 'commit is not allowed' S=$(post_empty "$PROXY/auth") -check "POST /auth -> 403" "403" "$S" +check_denied "POST /auth -> 403" "$S" "$BODY" 'auth endpoint is not allowed' # ─── Container create — image validation ─────────────────── @@ -182,7 +213,7 @@ else fi S=$(post_json '{"Image":"alpine:latest","Cmd":["sh"]}' "$PROXY/containers/create") -check "create with unlisted image -> 403" "403" "$S" +check_denied "create with unlisted image -> 403" "$S" "$BODY" 'image alpine:latest not allowed by any policy' # Another allowed image prefix S=$(post_json '{"Image":"ethpandaops/lodestar:latest","Cmd":["--rcConfig","/data/config.yml"]}' "$PROXY/containers/create") @@ -195,7 +226,7 @@ else fi S=$(post_json '{"Cmd":["--rcConfig","/data/config.yml"]}' "$PROXY/containers/create") -check "create with no image -> 403" "403" "$S" +check_denied "create with no image -> 403" "$S" "$BODY" 'image field is required' # ─── Container create — privileged mode ────────────────── @@ -228,7 +259,7 @@ else fi S=$(post_json '{"Image":"chainsafe/lodestar:beacon","HostConfig":{"Binds":["/tmp:/tmp"]},"Cmd":["--rcConfig","/data/config.yml"]}' "$PROXY/containers/create") -check "create with unlisted volume -> 403" "403" "$S" +check_denied "create with unlisted volume -> 403" "$S" "$BODY" 'volume mount "/tmp" is not in the whitelist' # ─── Inline env vars (env_file is set in policy) ───────── @@ -236,7 +267,7 @@ echo "" echo "--- Inline environment variables ---" S=$(post_json '{"Image":"chainsafe/lodestar:beacon","Env":["VALIDATOR_KEY=secret"],"Cmd":["--rcConfig","/data/config.yml"]}' "$PROXY/containers/create") -check "create with inline env -> 403" "403" "$S" +check_denied "create with inline env -> 403" "$S" "$BODY" 'inline environment variables are not allowed for service' # ─── CLI flag validation ──────────────────────────────── @@ -245,7 +276,7 @@ echo "--- CLI flag validation ---" # --privileged is in denied_flags -> proxy denies S=$(post_json '{"Image":"chainsafe/lodestar:beacon","Cmd":["--privileged","--rcConfig","/data/config.yml"]}' "$PROXY/containers/create") -check "create with --privileged flag -> 403" "403" "$S" +check_denied "create with --privileged flag -> 403" "$S" "$BODY" 'flag "--privileged" is denied for service' # Allowed flags, request passes through to Docker S=$(post_json '{"Image":"chainsafe/lodestar:beacon","Cmd":["--rcConfig","/data/config.yml","--logLevel","info"]}' "$PROXY/containers/create") @@ -280,12 +311,12 @@ fi # Unlisted image pull -> proxy denies S=$(post_json '{"fromImage":"alpine:latest"}' "$PROXY/images/create") -check "pull unlisted image -> 403" "403" "$S" +check_denied "pull unlisted image -> 403" "$S" "$BODY" 'image alpine:latest not allowed by any policy' # Real container lifecycle with busybox (image that exists) # Step 1: Pull busybox through proxy (should be denied — not in policy) S=$(post_json '{"fromImage":"busybox:latest"}' "$PROXY/images/create") -check "pull busybox (unlisted) -> 403" "403" "$S" +check_denied "pull busybox (unlisted) -> 403" "$S" "$BODY" 'image busybox:latest not allowed by any policy' # ─── Endpoint completeness ───────────────────────────── @@ -293,23 +324,23 @@ echo "" echo "--- Unrecognized endpoints ---" S=$(post_empty "$PROXY/networks/create") -check "POST /networks/create -> 403" "403" "$S" +check_denied "POST /networks/create -> 403" "$S" "$BODY" 'endpoint POST /networks/create is not allowed' S=$(post_empty "$PROXY/volumes/create") -check "POST /volumes/create -> 403" "403" "$S" +check_denied "POST /volumes/create -> 403" "$S" "$BODY" 'endpoint POST /volumes/create is not allowed' S=$(post_empty "$PROXY/containers/test") -check "PATCH /containers/test -> 403" "403" "$S" +check_denied "POST /containers/test -> 403 (no action segment)" "$S" "$BODY" 'endpoint POST /containers/test is not allowed' # 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" +check_denied "DELETE /containers/json -> 403 (reserved, not a container)" "$S" "$BODY" 'endpoint DELETE /containers/json is not allowed' S=$(delete_status "$PROXY/containers/create") -check "DELETE /containers/create -> 403 (reserved, not a container)" "403" "$S" +check_denied "DELETE /containers/create -> 403 (reserved, not a container)" "$S" "$BODY" 'endpoint DELETE /containers/create is not allowed' # Listing must still work: it reaches the GET/HEAD passthrough instead. S=$(get_status "$PROXY/containers/json") @@ -320,10 +351,10 @@ check "GET /containers/json -> 200 (still the list endpoint)" "200" "$S" # path, where an unknown container is allowed through, so Rust forwarded these # while Go and TypeScript denied them. S=$(delete_status "$PROXY/containers/") -check "DELETE /containers/ -> 403 (empty name, not a container)" "403" "$S" +check_denied "DELETE /containers/ -> 403 (empty name, not a container)" "$S" "$BODY" 'endpoint DELETE /containers/ is not allowed' S=$(post_empty "$PROXY/containers//start") -check "POST /containers//start -> 403 (empty name, not a container)" "403" "$S" +check_denied "POST /containers//start -> 403 (empty name, not a container)" "$S" "$BODY" 'empty path segment not allowed' # Dotted API-version prefix (#52). The Docker CLI versions every request # (/v1.45/containers/...). The proxy must strip the prefix and route the rest @@ -334,12 +365,12 @@ S=$(post_empty "$PROXY/v1.45/containers/no-such-container/start") check "POST /v1.45/containers/*/start -> 404 (daemon answered, not proxy 403)" "404" "$S" S=$(delete_status "$PROXY/v1.45/containers/json") -check "DELETE /v1.45/containers/json -> 403 (reserved survives version strip)" "403" "$S" +check_denied "DELETE /v1.45/containers/json -> 403 (reserved survives version strip)" "$S" "$BODY" 'endpoint DELETE /containers/json is not allowed' # #57: /volumes/ is not an API-version prefix. The daemon would run a volume # removal; the proxy must not classify it as a container delete. S=$(delete_status "$PROXY/volumes/containers/no-such-container") -check "DELETE /volumes/containers/no-such-container -> 403 (not a version prefix)" "403" "$S" +check_denied "DELETE /volumes/containers/no-such-container -> 403 (not a version prefix)" "$S" "$BODY" 'endpoint DELETE /volumes/containers/no-such-container is not allowed' # #52: versioned create, the `docker run` path. Mirrors the unversioned # allowed-image create above: the daemon answers 201, or 404 when the image is @@ -359,10 +390,10 @@ fi # %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" +check_denied "DELETE /containers/no-such%20x -> 403 (percent-encoded path)" "$S" "$BODY" 'percent-encoded path not allowed' S=$(delete_status "$PROXY/containers/%2F") -check "DELETE /containers/%2F -> 403 (percent-encoded path)" "403" "$S" +check_denied "DELETE /containers/%2F -> 403 (percent-encoded path)" "$S" "$BODY" 'percent-encoded path not allowed' # The handler tests prove the query reaches the daemon unchanged; this check # proves an encoded query is not denied. @@ -375,34 +406,39 @@ check "GET /v1.45/containers/json?filters= -> 200 (query not inspected) # 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" +check_denied "DELETE /containers/*/exec -> 403 (exec subpath, any method)" "$S" "$BODY" 'exec is not allowed' 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" +check_denied "POST /containers/create/extra -> 403 (not the create endpoint)" "$S" "$BODY" 'endpoint POST /containers/create/extra is not allowed' 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" +check_denied "GET /exec/*/json -> 403 (exec inspect denied)" "$S" "$BODY" 'exec is not allowed' # #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" \ +: >"$BODY" +S=$(curl -s -o "$BODY" -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" +check_denied "GET //exec/*/json -> 403 (empty segment)" "$S" "$BODY" 'empty path segment not allowed' # #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" \ +# real one). Go and Rust route on the parsed path, /exec/..., and deny it as +# exec; TypeScript routes the raw target and denies its "//" as an empty +# segment. Either proxy deny passes; a daemon 403 does not. +: >"$BODY" +S=$(curl -s -o "$BODY" -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" +check_denied "GET http://docker/exec/*/json -> 403 (absolute-form target)" "$S" "$BODY" \ + 'exec is not allowed' 'empty path segment not allowed' # ─── Summary ──────────────────────────────────────────