test(docker,flows): cover the listing cap paths, bound the container-list path count

The truncation slice and the demux stdout byte-cap had no docker-layer tests —
only the handler's Truncated wiring was exercised through the fake, so a
mis-slice or a dropped cap would have gone unnoticed. Extract find-output
parsing into a pure parseFindEntries and take the byte cap as a demuxExecStdout
parameter, then unit-test both boundaries (at cap / cap+1 / over-limit stream).

Also bound how many paths one container-files request may list
(maxContainerListPaths), so the per-path entry cap can't be multiplied by an
attacker-chosen path count into a large fan-out or response body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-12 23:22:33 +07:00
co-authored by Claude Opus 4.8
parent 18234f05a3
commit 7cd22ccbc9
4 changed files with 119 additions and 21 deletions
+23 -21
View File
@@ -640,7 +640,7 @@ func (dc *dockerClient) ListContainerDir(
if err != nil {
return ContainerDirListing{}, fmt.Errorf("failed to attach list exec for '%s': %w", dirPath, err)
}
output, readErr := demuxExecStdout(resp.Reader)
output, readErr := demuxExecStdout(resp.Reader, maxListStdoutBytes)
resp.Close()
if readErr != nil {
return ContainerDirListing{}, fmt.Errorf("failed to read list output for '%s': %w", dirPath, readErr)
@@ -654,22 +654,7 @@ func (dc *dockerClient) ListContainerDir(
return ContainerDirListing{}, fmt.Errorf("list command failed for '%s' with exit code %d: %s", dirPath, inspect.ExitCode, string(output))
}
// find -print0 emits `<absolute-path>\0<absolute-path>\0…`.
entryPaths := make([]string, 0)
for _, tok := range strings.Split(string(output), "\x00") {
if tok == "" {
continue
}
entryPaths = append(entryPaths, tok)
}
// Over the cap, list the first maxListEntries and flag the truncation rather
// than failing the whole listing — the caller reports the partiality.
truncated := false
if len(entryPaths) > maxListEntries {
entryPaths = entryPaths[:maxListEntries]
truncated = true
}
entryPaths, truncated := parseFindEntries(output)
stats, failures := statContainerEntries(ctx, entryPaths, containerListWorkers, func(ctx context.Context, entryPath string) (container.PathStat, error) {
return dc.ContainerStatPath(ctx, containerID, entryPath)
@@ -695,10 +680,27 @@ func (dc *dockerClient) ListContainerDir(
return listing, nil
}
// parseFindEntries splits `find -print0` output (NUL-delimited absolute paths),
// dropping empties, and caps the result at maxListEntries — returning truncated=true
// so the caller reports the partiality instead of failing or silently dropping.
func parseFindEntries(output []byte) (entries []string, truncated bool) {
for _, tok := range strings.Split(string(output), "\x00") {
if tok == "" {
continue
}
entries = append(entries, tok)
}
if len(entries) > maxListEntries {
return entries[:maxListEntries], true
}
return entries, false
}
// demuxExecStdout reads a non-TTY Docker exec stream — stdout and stderr
// interleaved as frames with an 8-byte header (stream id + big-endian size) —
// and returns only the stdout bytes.
func demuxExecStdout(r io.Reader) ([]byte, error) {
// and returns only the stdout bytes, erroring if stdout exceeds maxStdout so a
// compromised sandbox can't stream unbounded output into memory.
func demuxExecStdout(r io.Reader, maxStdout int) ([]byte, error) {
var stdout bytes.Buffer
header := make([]byte, 8)
for {
@@ -719,8 +721,8 @@ func demuxExecStdout(r io.Reader) ([]byte, error) {
if _, err := io.CopyN(sink, r, size); err != nil {
return nil, err
}
if stdout.Len() > maxListStdoutBytes {
return nil, fmt.Errorf("listing output exceeded %d bytes", maxListStdoutBytes)
if stdout.Len() > maxStdout {
return nil, fmt.Errorf("listing output exceeded %d bytes", maxStdout)
}
}
return stdout.Bytes(), nil
+55
View File
@@ -0,0 +1,55 @@
package docker
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
func listingFrame(streamID byte, payload string) []byte {
h := make([]byte, 8)
h[0] = streamID
binary.BigEndian.PutUint32(h[4:8], uint32(len(payload)))
return append(h, []byte(payload)...)
}
func TestParseFindEntries(t *testing.T) {
entries, truncated := parseFindEntries([]byte("a\x00b\x00\x00c\x00"))
if truncated {
t.Error("small input must not truncate")
}
if len(entries) != 3 || entries[0] != "a" || entries[1] != "b" || entries[2] != "c" {
t.Fatalf("split/skip-empty wrong: %q", entries)
}
// exactly at the cap → not truncated
e, tr := parseFindEntries([]byte(strings.Repeat("x\x00", maxListEntries)))
if tr || len(e) != maxListEntries {
t.Fatalf("at cap: truncated=%v len=%d (want false, %d)", tr, len(e), maxListEntries)
}
// cap+1 → truncated, capped to maxListEntries
e, tr = parseFindEntries([]byte(strings.Repeat("x\x00", maxListEntries+1)))
if !tr || len(e) != maxListEntries {
t.Fatalf("over cap: truncated=%v len=%d (want true, %d)", tr, len(e), maxListEntries)
}
}
func TestDemuxExecStdout_StdoutOnly_AndByteCap(t *testing.T) {
// stdout is returned; stderr (id 2) is discarded
in := append(listingFrame(1, "hello"), listingFrame(2, "diagnostic")...)
out, err := demuxExecStdout(bytes.NewReader(in), 1<<20)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if string(out) != "hello" {
t.Fatalf("want stdout only, got %q", out)
}
// stdout exceeding the cap → error before materializing the full buffer
_, err = demuxExecStdout(bytes.NewReader(listingFrame(1, strings.Repeat("x", 50))), 10)
if err == nil || !strings.Contains(err.Error(), "listing output exceeded") {
t.Fatalf("want cap error, got %v", err)
}
}