From efe8467b146340a5aff80ef86428ea7697b9e4dd Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:38:44 +0800 Subject: [PATCH] fix: require finite Docker stats samples --- .../dockermap-daemon/src/docker_collector.rs | 2 +- crates/dockermap-daemon/src/main.rs | 9 +++- crates/dockermap-docker-gateway/src/lib.rs | 48 +++++++++---------- .../architecture/DOCKER_AUTHORITY_BOUNDARY.md | 10 ++-- docs/deployment/DOCKER.md | 2 +- docs/security/THREAT_MODEL.md | 2 +- tests/e2e/dockermapHarness.ts | 2 +- 7 files changed, 42 insertions(+), 33 deletions(-) diff --git a/crates/dockermap-daemon/src/docker_collector.rs b/crates/dockermap-daemon/src/docker_collector.rs index 74616a36..78074ddf 100644 --- a/crates/dockermap-daemon/src/docker_collector.rs +++ b/crates/dockermap-daemon/src/docker_collector.rs @@ -141,7 +141,7 @@ impl DockerCollector { } let options = StatsOptionsBuilder::new() .stream(false) - .one_shot(false) + .one_shot(true) .build(); let mut stream = self.client.stats(container_id, Some(options)); stream.next().await.transpose().map_err(|_| ())?.ok_or(()) diff --git a/crates/dockermap-daemon/src/main.rs b/crates/dockermap-daemon/src/main.rs index c0f915cc..76d2b4ad 100644 --- a/crates/dockermap-daemon/src/main.rs +++ b/crates/dockermap-daemon/src/main.rs @@ -398,7 +398,7 @@ mod tests { let trace = tokio::spawn(async move { let mut requests = Vec::new(); - for _ in 0..5 { + for _ in 0..6 { let (mut stream, _) = listener .accept() .await @@ -427,6 +427,8 @@ mod tests { r#"{"Volumes":[],"Warnings":null}"# } else if target.contains("/containers/api/logs") { "" + } else if target.contains("/containers/api/stats") { + "{}" } else { panic!("unexpected Bollard target: {target}"); }; @@ -478,6 +480,10 @@ mod tests { ) .await .expect("bounded historical log read should succeed"); + collector + .collect_one_shot_stats("api") + .await + .expect("finite stats read should succeed"); let requests = trace.await.expect("wire trace should finish"); assert_eq!(requests, vec![ @@ -486,6 +492,7 @@ mod tests { "GET /volumes? HTTP/1.1", "GET /containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0×tamps=true&tail=4096 HTTP/1.1", "GET /containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=1706000124×tamps=true&tail=4096 HTTP/1.1", + "GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1", ], "Bollard wire contract changed; update the gateway ADR and policy review before permitting a new request shape"); } diff --git a/crates/dockermap-docker-gateway/src/lib.rs b/crates/dockermap-docker-gateway/src/lib.rs index 3f4b3835..999fa974 100644 --- a/crates/dockermap-docker-gateway/src/lib.rs +++ b/crates/dockermap-docker-gateway/src/lib.rs @@ -26,7 +26,7 @@ use tokio::net::{UnixListener, UnixStream}; pub const LOG_TAIL: &str = "4096"; /// Exact bounded one-shot request emitted by Bollard 0.19.4 for a container /// stats sample. Streaming stats remain outside the gateway authority. -pub const STATS_QUERY: &str = "stream=false&one-shot=false"; +pub const STATS_QUERY: &str = "stream=false&one-shot=true"; /// Event replay and live-tail starts may inspect at most this recent window. pub const EVENT_MAX_LOOKBACK_SECONDS: u64 = 300; @@ -563,29 +563,29 @@ mod tests { #[test] fn only_measured_unfiltered_requests_pass() { let policy = Policy::new(None); - for target in ["/containers/json?all=true&size=false", "/networks?", "/volumes?", "/containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0×tamps=true&tail=4096", "/containers/api/stats?stream=false&one-shot=false"] { assert!(policy.allow(&req(target)).is_ok(), "{target}"); } + for target in ["/containers/json?all=true&size=false", "/networks?", "/volumes?", "/containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0×tamps=true&tail=4096", "/containers/api/stats?stream=false&one-shot=true"] { assert!(policy.allow(&req(target)).is_ok(), "{target}"); } for target in ["/containers/json?all=true", "/events", "/v1.44/containers/json?all=true&size=false", "/containers/api/json", "/containers/api/logs?follow=true&stdout=true&stderr=true&since=0&until=0×tamps=true&tail=4096", "/containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0×tamps=true&tail=4097"] { assert!(policy.allow(&req(target)).is_err(), "{target}"); } } #[test] fn stats_allow_only_the_measured_finite_request() { let policy = Policy::new(None); - let valid = "/containers/api/stats?stream=false&one-shot=false"; + let valid = "/containers/api/stats?stream=false&one-shot=true"; assert_eq!(policy.allow(&req(valid)), Ok(valid.into())); for target in [ - "/containers/stats?stream=false&one-shot=false", - "/containers//stats?stream=false&one-shot=false", + "/containers/stats?stream=false&one-shot=true", + "/containers//stats?stream=false&one-shot=true", "/containers/api/stats", "/containers/api/stats?", - "/containers/api/stats?stream=true&one-shot=false", - "/containers/api/stats?stream=false&one-shot=true", - "/containers/api/stats?one-shot=false&stream=false", + "/containers/api/stats?stream=true&one-shot=true", + "/containers/api/stats?stream=false&one-shot=false", + "/containers/api/stats?one-shot=true&stream=false", "/containers/api/stats?stream=false", - "/containers/api/stats?stream=false&one-shot=false&one-shot=false", - "/containers/api/stats?stream=false&one-shot=false&x=1", - "/containers/%61pi/stats?stream=false&one-shot=false", - "/containers/api%2fstats?stream=false&one-shot=false", - "/v1.44/containers/api/stats?stream=false&one-shot=false", + "/containers/api/stats?stream=false&one-shot=true&one-shot=true", + "/containers/api/stats?stream=false&one-shot=true&x=1", + "/containers/%61pi/stats?stream=false&one-shot=true", + "/containers/api%2fstats?stream=false&one-shot=true", + "/v1.44/containers/api/stats?stream=false&one-shot=true", ] { assert!(policy.allow(&req(target)).is_err(), "{target}"); } @@ -602,7 +602,7 @@ mod tests { fn stats_fail_closed_when_a_gateway_label_scope_is_configured() { let policy = Policy::new(Some("com.dockermap.fixture=trace-123".into())); assert_eq!( - policy.allow(&req("/containers/api/stats?stream=false&one-shot=false")), + policy.allow(&req("/containers/api/stats?stream=false&one-shot=true")), Err(Deny::Query) ); } @@ -1047,7 +1047,7 @@ mod tests { .unwrap(); let options = StatsOptionsBuilder::new() .stream(false) - .one_shot(false) + .one_shot(true) .build(); let mut stream = Box::pin(docker.stats("api", Some(options))); let received = tokio::time::timeout(Duration::from_secs(2), stream.next()) @@ -1062,7 +1062,7 @@ mod tests { let mut lines = head.lines(); assert_eq!( lines.next(), - Some("GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1") + Some("GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1") ); let headers = lines.collect::>(); assert!(headers @@ -1112,7 +1112,7 @@ mod tests { } let response = request( &gateway_socket, - "GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\n\r\n", + "GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\n\r\n", ) .await; assert!(!response.starts_with("HTTP/1.1 200"), "{response}"); @@ -1177,13 +1177,13 @@ mod tests { "GET /containers/json?all=true&size=%GG HTTP/1.1\r\nHost: docker\r\n\r\n", "GET /containers/api/logs?follow=true&stdout=true&stderr=true&since=0&until=0×tamps=true&tail=4096 HTTP/1.1\r\nHost: docker\r\n\r\n", "GET /containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0×tamps=true&tail=4096&tail=4096 HTTP/1.1\r\nHost: docker\r\n\r\n", - "GET /containers/api/stats?stream=true&one-shot=false HTTP/1.1\r\nHost: docker\r\n\r\n", - "GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\n\r\n", - "GET /containers/api/stats?one-shot=false&stream=false HTTP/1.1\r\nHost: docker\r\n\r\n", - "GET /containers/api/stats?stream=false&one-shot=false&x=1 HTTP/1.1\r\nHost: docker\r\n\r\n", - "GET /containers/api%2fstats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\n\r\n", - "GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\nContent-Length: 1\r\n\r\nx", - "GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + "GET /containers/api/stats?stream=true&one-shot=true HTTP/1.1\r\nHost: docker\r\n\r\n", + "GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\n\r\n", + "GET /containers/api/stats?one-shot=true&stream=false HTTP/1.1\r\nHost: docker\r\n\r\n", + "GET /containers/api/stats?stream=false&one-shot=true&x=1 HTTP/1.1\r\nHost: docker\r\n\r\n", + "GET /containers/api%2fstats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\n\r\n", + "GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\nContent-Length: 1\r\n\r\nx", + "GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", "GET /networks? HTTP/1.1\r\nHost: docker\r\nContent-Length: 0\r\n\r\n", "GET /networks? HTTP/1.1\r\nHost: docker\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", "GET /networks? HTTP/1.1\r\nHost: docker\r\nUpgrade: h2c\r\n\r\n", diff --git a/docs/architecture/DOCKER_AUTHORITY_BOUNDARY.md b/docs/architecture/DOCKER_AUTHORITY_BOUNDARY.md index 4290f6e2..2ec6dcd1 100644 --- a/docs/architecture/DOCKER_AUTHORITY_BOUNDARY.md +++ b/docs/architecture/DOCKER_AUTHORITY_BOUNDARY.md @@ -155,13 +155,15 @@ regression measures Bollard 0.19.4 against the real filtered Unix gateway and an isolated raw-Docker Unix stub. The one approved origin-form target is: ```text -/containers//stats?stream=false&one-shot=false +/containers//stats?stream=false&one-shot=true ``` The test proves the exact query order, empty request body, normal HTTP/1.1 -framing, and verbatim forwarding. The policy rejects every other stats form: -the streaming form, one-shot variation, reordered/duplicate/unknown or missing -query keys, root/versioned/encoded paths, request bodies, and upgrade framing. +framing, and verbatim forwarding. `one-shot=true` asks Docker for one finite +sample; the gateway never permits a live stats stream. The policy rejects every +other stats form: `stream=true`, `one-shot=false`, reordered/duplicate/unknown +or missing query keys, root/versioned/encoded paths, request bodies, and upgrade +framing. It provides no general Docker read or path wildcard, but an unfiltered gateway does permit that single finite stats shape for any valid container name or ID segment. It does not itself implement a stats collector or publish Docker's diff --git a/docs/deployment/DOCKER.md b/docs/deployment/DOCKER.md index 1949bedf..69161a22 100644 --- a/docs/deployment/DOCKER.md +++ b/docs/deployment/DOCKER.md @@ -46,7 +46,7 @@ port on the interface of your choice, e.g. `-p 3233:3233`. - Gateway only: `/var/run/docker.sock` read-only. The gateway independently permits only reviewed inventory, bounded non-following logs, bounded Docker events, and the exact finite per-container stats request - `stream=false&one-shot=false` on an unfiltered profile. It denies all stats + `stream=false&one-shot=true` on an unfiltered profile. It denies all stats requests when `DOCKERMAP_DOCKER_LABEL_FILTER` is set because that Docker endpoint cannot express the inventory label scope. - Collector only: `/opt/dockermap/project` read-only plus the filtered gateway diff --git a/docs/security/THREAT_MODEL.md b/docs/security/THREAT_MODEL.md index bd1f8e85..ec79aac6 100644 --- a/docs/security/THREAT_MODEL.md +++ b/docs/security/THREAT_MODEL.md @@ -77,7 +77,7 @@ Protections: - The gateway is default-deny and permits only the measured container/network/ volume inventory calls, fixed bounded non-following logs, bounded Docker events, and—only when no Docker label scope is configured—the exact finite - per-container stats request `stream=false&one-shot=false`. It rejects + per-container stats request `stream=false&one-shot=true`. It rejects mutations, inspect/archive/top/exec, streaming or malformed stats, every stats request under a configured label scope, images/builds, ambiguous targets, unknown queries, bodies, and upgrades before opening Docker. diff --git a/tests/e2e/dockermapHarness.ts b/tests/e2e/dockermapHarness.ts index 7c1a7e9e..98821a15 100644 --- a/tests/e2e/dockermapHarness.ts +++ b/tests/e2e/dockermapHarness.ts @@ -326,7 +326,7 @@ export async function startLiveDockerStack(options: { } return requestFixedGatewayStatus( gatewaySocket, - `/containers/${containerId}/stats?stream=false&one-shot=false`, + `/containers/${containerId}/stats?stream=false&one-shot=true`, ); }, stop: async () => {