From 3cb1e885c476b819a150ebb7bbe32fdad78d36ec Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Thu, 6 Aug 2026 14:07:20 +0800 Subject: [PATCH] Protect host system trees from runtime binds Reject runtime host sources that resolve to the filesystem root or protected kernel system trees. Use platform-specific filesystem and mount topology identity to catch direct, aliased, and nested kernel-control filesystems while preserving explicit ordinary directory binds with nested application objects. Reject unsupported remote Docker endpoints before runtime and recovered-container operations, including admission cleanup paths. Document the narrowed direct-bind security contract, close the corresponding backlog item, and add a security changelog fragment. --- .../+protect-host-system-trees.yaml | 2 + docs/BACKLOG.md | 39 +- docs/CONTROLLED_SESSION_DESIGN.md | 116 +++- internal/dockerdeploy/buildkit_probe.go | 3 + internal/dockerdeploy/compose.go | 22 +- internal/dockerdeploy/compose_test.go | 29 +- internal/dockerdeploy/control_admission.go | 2 +- .../dockerdeploy/control_admission_test.go | 44 ++ internal/dockerdeploy/docker_endpoint.go | 102 +++ internal/dockerdeploy/docker_endpoint_test.go | 130 ++++ internal/dockerdeploy/execution_plan.go | 16 +- internal/dockerdeploy/execution_plan_test.go | 6 +- .../installed_service_container.go | 6 +- internal/dockerdeploy/live_run_admission.go | 2 +- .../dockerdeploy/live_run_admission_test.go | 44 ++ internal/dockerdeploy/platform.go | 3 + internal/dockerdeploy/platform_test.go | 4 +- .../dockerdeploy/provider_build_run_test.go | 2 +- .../runtime_host_filesystem_darwin.go | 32 + .../runtime_host_filesystem_linux.go | 621 ++++++++++++++++++ .../runtime_host_filesystem_linux_test.go | 481 ++++++++++++++ .../runtime_host_filesystem_other.go | 11 + .../dockerdeploy/runtime_host_preflight.go | 73 ++ .../runtime_host_preflight_test.go | 70 ++ .../runtime_host_preflight_unix_test.go | 27 + 25 files changed, 1827 insertions(+), 60 deletions(-) create mode 100644 .changes/unreleased/+protect-host-system-trees.yaml create mode 100644 internal/dockerdeploy/docker_endpoint.go create mode 100644 internal/dockerdeploy/docker_endpoint_test.go create mode 100644 internal/dockerdeploy/runtime_host_filesystem_darwin.go create mode 100644 internal/dockerdeploy/runtime_host_filesystem_linux.go create mode 100644 internal/dockerdeploy/runtime_host_filesystem_linux_test.go create mode 100644 internal/dockerdeploy/runtime_host_filesystem_other.go create mode 100644 internal/dockerdeploy/runtime_host_preflight_unix_test.go diff --git a/.changes/unreleased/+protect-host-system-trees.yaml b/.changes/unreleased/+protect-host-system-trees.yaml new file mode 100644 index 00000000..41cb769f --- /dev/null +++ b/.changes/unreleased/+protect-host-system-trees.yaml @@ -0,0 +1,2 @@ +kind: Security +body: Reject ordinary application-runtime binds to the host filesystem root, `/proc`, `/dev`, and `/sys`, including symlink and protected-filesystem aliases; reject unsupported remote Docker endpoints; and replace generic root-runtime warnings with precise diagnostics for prohibited authority. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 61c5cbf5..2d1f6bae 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -31,12 +31,14 @@ This file is the day-to-day queue for design and implementation gaps. ## Now -- [ ] `P1` Define confinement for special files nested inside host directory - binds. A launch-time recursive scan for sockets and device nodes adds - unbounded source-tree latency and provides only a point-in-time result. - Choose a durable mechanism or explicitly narrow the security contract, - then add focused cross-platform tests without weakening ordinary - read-only project mounts. +- [ ] `P1` Implement the initial coarse application-network policy. Preserve + independent public and local policy intent with both denied by default, + apply it consistently to workloads, commands, shells, and lifecycle + commands, and use only proven isolation and endpoint primitives from the + active runtime backend. Permit exact declared inbound endpoints without + granting general local access. Fail closed when a backend cannot realize + a requested combination, and do not represent this slice as destination-, + domain-, or packet-level filtering. ## Pre-release @@ -183,6 +185,17 @@ This file is the day-to-day queue for design and implementation gaps. ## Post-v1 +- [ ] `P2` Design explicit remote Docker support. + Replace today's rejected ambient `DOCKER_HOST` and remote-context behavior + with an intentional distributed-runtime contract. Define input snapshot + upload, output-file and output-dir extraction with safe local publication, + image export or remote placement, port forwarding, remote identity and + permission semantics, authenticated transport, lifecycle ownership, + interruption recovery, deterministic cleanup, and a defensible way to + establish or replace host-path namespace equivalence. Keep local Docker + Engine and Docker Desktop behavior distinct from generic remote daemons, + containerized Reploy, and Unix-socket proxies. + - [ ] `P2` Consider a Reploy host configuration surface. Keep a standing inventory of concrete operator- or host-owned settings that do not belong in blueprints, staging overrides, or installation @@ -192,6 +205,20 @@ This file is the day-to-day queue for design and implementation gaps. fixed host-owned limits for controlled-session endpoint streams and connection-open rates. +- [ ] `P2` Design and implement a Reploy userland L3 policy gateway. Keep this + separate from the initial public/local kill switches and controlled + sessions. Define a capability-free application network namespace, a + one-shot route initializer, an isolated data path whose only peer is the + gateway, private gateway control, root-resistant route invariants, + IPv4/IPv6 and DNS policy, destination and port grants, auditing, resource + limits, failure behavior, reconciliation, and Docker/Podman plus Desktop + integration. Treat native engine primitives as fast paths rather than + exposing backend network modes as product policy. Replace or mediate the + initial controlled-session host-loopback endpoint publication so only the + lease-owned Host Reploy operation can reach the recorded application; + include multi-user-host tests proving unrelated local processes cannot + bypass the session endpoint grant. + - [ ] `P2` Evaluate and prioritize the Dingo development-environment gaps. Use `docs/DINGO_GAPS.md` as the needs and evidence record for portable checkout binding, development execution, shell initialization, pinned diff --git a/docs/CONTROLLED_SESSION_DESIGN.md b/docs/CONTROLLED_SESSION_DESIGN.md index 0bc3c555..dd8bcfca 100644 --- a/docs/CONTROLLED_SESSION_DESIGN.md +++ b/docs/CONTROLLED_SESSION_DESIGN.md @@ -76,12 +76,13 @@ containers use the host account selected by materializes the local account through Linux account databases; other target-OS backends may realize the same contract differently. -If the effective runtime user is root, Reploy emits a precise warning that the -application can interfere with more of its container. Root does not implicitly -grant capabilities, host input or shared-state mounts, network access, -privileged mode, or daemon access. Root-safe `--output-file` and `--output-dir` -are separate global runtime contracts and remain rejected until their focused -pre-release review and implementation are complete. +Root remains an explicit runtime identity, but selecting it does not produce a +generic runtime warning. Reploy instead rejects prohibited combinations with +precise diagnostics. Root does not implicitly grant capabilities, host input +or shared-state mounts, network access, privileged mode, or daemon access. +Root-safe `--output-file` and `--output-dir` are separate global runtime +contracts and remain rejected until their focused pre-release review and +implementation are complete. ## Context @@ -635,13 +636,13 @@ user-scope Reploy was invoked as root, or because a system-scope installation explicitly selected root. It is never inherited merely from the base image's configured `USER`. -A root runtime identity must emit a warning equivalent to: - -> The application will run as root inside its container. Root can bypass -> application-level file permissions. Host input and shared-state mounts are -> prohibited. Explicit root output contracts require their separately reviewed -> safeguards. Network access and Linux capabilities remain restricted unless -> separately granted. +A root runtime identity does not emit a generic warning. With the global +sandbox enforced, its additional authority is limited to container-scoped +root-owned image content, declared persistent storage, and processes using the +same identity. Prohibited combinations fail with diagnostics that identify the +specific rejected authority. If a future capability grants root broader +authority, that capability's explicit opt-in surface must disclose the added +risk rather than making ordinary root execution noisy. A root runtime identity does not imply: @@ -670,6 +671,60 @@ approved project directory. A client cannot turn it into an arbitrary host-path selector through the session protocol. Original project source is never exposed through a writable bind. +An explicit host directory bind grants access to every unmasked entry below +that directory. Read-only mode prevents ordinary file mutation, but does not +neutralize Unix sockets, device nodes, FIFOs, or nested mount points. Reploy +does not recursively scan a live source tree: such a scan has unbounded launch +cost and provides only a race-prone point-in-time observation. A caller that +requires stronger isolation must use no host bind or a future filtered-copy +workspace. This is a deliberate narrowing of the direct-bind security +contract, not a claim that active host objects have been confined. + +Direct host binds also trust the selected host pathname namespace to remain +stable until Docker establishes the mount. Reploy does not defend against a +separate host-side actor retargeting the source path during launch or using +Docker daemon access to alter the container. Such actors already hold authority +outside the controller/workload isolation boundary. The launched workload +cannot create this race itself because Docker establishes its mounts before +starting the workload process. + +Generic remote Docker daemons are unsupported. Reploy requires a local Unix +socket or Windows named-pipe endpoint, including the local endpoint presented +by Docker Desktop. This prevents local validation and output contracts from +silently applying to paths, ports, images, identities, and lifecycle state on +another machine. A future remote-Docker design requires explicit input upload, +output extraction and local publication, image placement, port forwarding, +authentication, cleanup, and recovery semantics rather than inherited Docker +context behavior. + +On native Linux, the operator must run Reploy directly in the host namespace +served by the local Docker Engine. Running Reploy inside a container with a +host socket mounted, or placing a local Unix-socket proxy in front of another +daemon, is unsupported because the Docker API cannot prove that Reploy and the +daemon resolve host paths in the same mount namespace. Unix-socket and +named-pipe classification rejects ordinary remote Docker configuration; it is +not a security attestation for an operator-controlled socket. Docker Desktop +is the intentional exception because its native client integration supplies the +supported host-path sharing and port-forwarding contract. + +Ordinary host binds reject the host filesystem root and canonical sources at +or below `/proc`, `/dev`, or `/sys`, including symlink aliases. On Linux, +Reploy also rejects bind mounts that expose the same filesystem root and checks +filesystem identity so procfs, sysfs, cgroup hierarchies, device filesystems, +and kernel control or observation filesystems remain prohibited when exposed +through another path. Linux proc magic links are rejected before canonical +resolution, including when procfs is reached through a symlink alias, so they +cannot resolve differently for Reploy and Docker. Where Linux reports mount +identity, Reploy rejects aliases of every mount rooted below `/proc`, `/dev`, +or `/sys` while preserving unrelated mounts of the same filesystem type. On +Linux kernels without no-magic-link path resolution, direct paths remain +available but symlinked host sources fail closed. On macOS, native devfs and +procfs sources are likewise rejected. +Containers keep Docker's container-scoped `/proc` and restricted `/dev`; those +are not host binds. Hardware or host-observation access, if later justified by +a compelling use case, requires a separately designed explicit capability +rather than an ordinary mount. + Root inside any Reploy application container may not receive host input or shared-state binds, including read-only binds. Read-only prevents modification but does not make exposed content confidential from container root. Reploy @@ -786,6 +841,13 @@ denial includes host gateways, Docker peers outside the granted operation, loopback redirection, private and link-local address ranges, IPv6 local ranges, and infrastructure metadata endpoints. +The initial implementation preserves this coarse public/local policy intent +and exact declared endpoint grants, using only backend isolation and endpoint +primitives whose behavior Reploy can verify. A backend that cannot realize a +requested combination fails closed. This slice does not introduce a custom +packet gateway and must not claim destination-, port-, domain-, DNS-, or +packet-level enforcement beyond what the selected primitive actually proves. + A controller may receive an explicit session-local grant to a declared workload endpoint. That grant is not treated as general local-network access. @@ -1077,9 +1139,13 @@ remain separate prerequisite slices. Root host authority is now enforced at runtime: host sources are classified as input, shared state, or explicit output; UID 0 is rejected for all three before container creation; and root output options are rejected before host-path preparation. Docker-managed -volumes and tmpfs remain available to root. Durable confinement of special -files nested inside a non-root directory bind remains unresolved and must not -be represented as solved by an expensive launch-time snapshot alone. +volumes and tmpfs remain available to root. Ordinary binds also reject +canonical host root, `/proc`, +`/dev`, and `/sys` sources plus equivalent protected filesystem mounts detected +through native filesystem identity. Explicit non-root directory binds +intentionally grant access to their remaining unmasked contents, including +nested active objects; this narrowed contract avoids representing a recursive +launch-time scan as durable confinement. ### Slice 2: Controlled-Session Lifecycle Core @@ -1151,11 +1217,16 @@ lease protocol. ### Network Isolation and Audit -Define general public and local kill switches, direct-egress enforcement, proxy -behavior, DNS control, IPv6, metadata protection, and auditability as a -separate Reploy/agent-sandbox design. The one-way, exact endpoint forwarding -used by the initial controlled session is intentionally narrower than that -future surface. +After the coarse public/local kill switches, define a separate Reploy userland +L3 policy gateway for finer network control. Its design should cover a +capability-free application network namespace, one-shot route initialization, +an isolated data path whose only peer is the gateway, private gateway control, +root-resistant route invariants, direct-egress prevention, destination and +port grants, DNS and IPv6 policy, metadata protection, auditing, resource +limits, failure behavior, reconciliation, and portable Docker/Podman +integration. The one-way, exact endpoint forwarding used by the initial +controlled session remains intentionally narrower and does not depend on this +later gateway. ### Disposable Writable Workspaces @@ -1192,7 +1263,8 @@ analysis. Privileged application containers remain outside this design. - Installed system-scope application containers use the configured host service account's numeric identity under the blueprint's container-local account name. -- Root application containers are possible but visibly weaker. +- Root application containers are possible but receive no implicit additional + authority; prohibited combinations fail with precise diagnostics. - Root application containers never receive host input or shared-state binds; local source requires the separately designed disposable-copy capability. Explicit root output-only binds remain unavailable until their separate diff --git a/internal/dockerdeploy/buildkit_probe.go b/internal/dockerdeploy/buildkit_probe.go index 59f664ea..6e587f56 100644 --- a/internal/dockerdeploy/buildkit_probe.go +++ b/internal/dockerdeploy/buildkit_probe.go @@ -83,6 +83,9 @@ func minimumDockerVersion(value string, minimumMajor int, minimumMinor int) bool } func executeDockerOutput(ctx context.Context, args ...string) (string, error) { + if err := requireDefaultLocalDockerEndpointV1(ctx); err != nil { + return "", err + } ctx, end := buildprofile.Start(ctx, dockerProfileOperation(args)) command := exec.CommandContext(ctx, "docker", args...) output, err := command.CombinedOutput() diff --git a/internal/dockerdeploy/compose.go b/internal/dockerdeploy/compose.go index 04ab546d..d04f038e 100644 --- a/internal/dockerdeploy/compose.go +++ b/internal/dockerdeploy/compose.go @@ -41,17 +41,22 @@ const defaultDockerPreflightTimeout = 5 * time.Second var dockerPreflight = checkDockerResponsive func runCommand(spec CommandSpec, options RunOptions) error { + if spec.Name == "docker" { + return runDockerCommand(spec, options) + } + return runCommandWithoutDockerPreflight(spec, options) +} + +func runDockerCommand(spec CommandSpec, options RunOptions) error { ctx := options.Context if ctx == nil { ctx = context.Background() } - if spec.Name == "docker" { - _, end := buildprofile.Start(ctx, "Docker preflight") - err := dockerPreflight(ctx, spec, effectiveDockerPreflightTimeout(options.DockerPreflightTimeout)) - end(err) - if err != nil { - return err - } + _, end := buildprofile.Start(ctx, "Docker preflight") + err := dockerPreflight(ctx, spec, effectiveDockerPreflightTimeout(options.DockerPreflightTimeout)) + end(err) + if err != nil { + return err } return runCommandWithoutDockerPreflight(spec, options) } @@ -120,6 +125,9 @@ func effectiveDockerPreflightTimeout(timeout time.Duration) time.Duration { } func checkDockerResponsive(ctx context.Context, spec CommandSpec, timeout time.Duration) error { + if err := requireLocalDockerEndpointV1(ctx, spec, timeout); err != nil { + return err + } preflightCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() diff --git a/internal/dockerdeploy/compose_test.go b/internal/dockerdeploy/compose_test.go index 1dbbb100..36b22de6 100644 --- a/internal/dockerdeploy/compose_test.go +++ b/internal/dockerdeploy/compose_test.go @@ -136,6 +136,33 @@ func TestRunCommandWithoutDockerPreflightRunsKnownFollowup(t *testing.T) { } } +func TestRunDockerCommandPreflightsAbsoluteExecutable(t *testing.T) { + dir := t.TempDir() + dockerPath := writeFakeCommand( + t, + dir, + "configured-docker", + "#!/bin/sh\nexit 0\n", + "@echo off\r\nexit /b 0\r\n", + ) + preflightCalled := false + restore := stubDockerPreflight(t, func(_ context.Context, spec CommandSpec, _ time.Duration) error { + preflightCalled = true + if spec.Name != dockerPath { + t.Fatalf("preflight executable = %q, want %q", spec.Name, dockerPath) + } + return nil + }) + defer restore() + + if err := runDockerCommand(CommandSpec{Name: dockerPath, Args: []string{"version"}}, RunOptions{}); err != nil { + t.Fatal(err) + } + if !preflightCalled { + t.Fatal("absolute Docker executable bypassed preflight") + } +} + func TestCheckDockerResponsiveUsesServerVersion(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "argv.log") @@ -149,7 +176,7 @@ func TestCheckDockerResponsiveUsesServerVersion(t *testing.T) { err := checkDockerResponsive( context.Background(), - CommandSpec{Name: dockerPath, Env: []string{"DOCKER_ARGV_LOG=" + logPath}}, + CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=unix:///var/run/docker.sock", "DOCKER_CONTEXT=", "DOCKER_ARGV_LOG=" + logPath}}, defaultDockerPreflightTimeout, ) if err != nil { diff --git a/internal/dockerdeploy/control_admission.go b/internal/dockerdeploy/control_admission.go index cfddd13a..24647eca 100644 --- a/internal/dockerdeploy/control_admission.go +++ b/internal/dockerdeploy/control_admission.go @@ -50,7 +50,7 @@ func AwaitControlAdmissionWithNoticeV1( return nil } }, - removeContainer: runCommandWithoutDockerPreflight, + removeContainer: runCommand, }) } diff --git a/internal/dockerdeploy/control_admission_test.go b/internal/dockerdeploy/control_admission_test.go index 135e2e17..5df1ce96 100644 --- a/internal/dockerdeploy/control_admission_test.go +++ b/internal/dockerdeploy/control_admission_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/omry/reploy/internal/deploy" ) @@ -110,6 +111,49 @@ func TestAwaitControlAdmissionV1ReturnsHeldAfterEarlierRunAndKeepsLaterRunBehind } } +func TestAwaitControlAdmissionV1PreflightsRecoveredContainerCleanup(t *testing.T) { + dir := t.TempDir() + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + abandoned := liveRunAdmissionFixtureV1("run-0000000000000001", false) + if _, err := operation.AdmitLiveRunV1(abandoned, false); err != nil { + t.Fatal(err) + } + container := "demo-" + abandoned.ID + if err := operation.RecordLiveRunContainerV1(abandoned.ID, container); err != nil { + t.Fatal(err) + } + marker := controlAdmissionFixtureV1("control-0000000000000001", deploy.ControlOperationInstallV1) + lease, err := operation.AcquireControlLeaseV1(marker.ID) + if err != nil { + t.Fatal(err) + } + preflightCalls := 0 + restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) error { + preflightCalls++ + return errors.New("remote Docker endpoint rejected") + }) + defer restore() + + admitted, err := AwaitControlAdmissionV1(t.Context(), dir, operation, marker, false) + if err != nil { + t.Fatal(err) + } + if preflightCalls != 1 { + t.Fatalf("Docker preflight calls = %d, want 1", preflightCalls) + } + queue, _, err := admitted.ReadLiveRunQueueV1() + if err != nil || len(queue.Runs) != 1 || queue.Runs[0].ID != marker.ID || + len(queue.Cleanup) != 1 || queue.Cleanup[0].Container != container { + t.Fatalf("queue after rejected remote cleanup = %#v, %v", queue, err) + } + if err := CompleteControlAdmissionV1(admitted, marker.ID, lease); err != nil { + t.Fatal(err) + } +} + func TestAwaitControlAdmissionV1ExplainsLifecycleWait(t *testing.T) { dir := t.TempDir() operation, err := deploy.AcquireOperationLock(t.Context(), dir) diff --git a/internal/dockerdeploy/docker_endpoint.go b/internal/dockerdeploy/docker_endpoint.go new file mode 100644 index 00000000..e5900c90 --- /dev/null +++ b/internal/dockerdeploy/docker_endpoint.go @@ -0,0 +1,102 @@ +package dockerdeploy + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const dockerContextHostFormatV1 = "{{.Endpoints.docker.Host}}" + +func requireDefaultLocalDockerEndpointV1(ctx context.Context) error { + return requireLocalDockerEndpointV1(ctx, CommandSpec{Name: "docker"}, defaultDockerPreflightTimeout) +} + +func requireLocalDockerEndpointV1(ctx context.Context, spec CommandSpec, timeout time.Duration) error { + endpoint, source, err := effectiveDockerEndpointV1(ctx, spec, timeout) + if err != nil { + return err + } + if localDockerEndpointV1(endpoint) { + return nil + } + return fmt.Errorf( + "remote Docker endpoint %q selected by %s is not supported; switch to a local Docker Engine or Docker Desktop context", + endpoint, + source, + ) +} + +func effectiveDockerEndpointV1(ctx context.Context, spec CommandSpec, timeout time.Duration) (string, string, error) { + if spec.Name == "" { + spec.Name = "docker" + } + if contextName := commandEnvironmentValueV1(spec, "DOCKER_CONTEXT"); contextName != "" { + endpoint, err := inspectDockerContextEndpointV1(ctx, spec, timeout, contextName) + return endpoint, fmt.Sprintf("Docker context %q", contextName), err + } + if endpoint := commandEnvironmentValueV1(spec, "DOCKER_HOST"); endpoint != "" { + return endpoint, "DOCKER_HOST", nil + } + endpoint, err := inspectDockerContextEndpointV1(ctx, spec, timeout, "") + return endpoint, "the active Docker context", err +} + +func inspectDockerContextEndpointV1(ctx context.Context, spec CommandSpec, timeout time.Duration, contextName string) (string, error) { + probeCtx, cancel := context.WithTimeout(ctx, effectiveDockerPreflightTimeout(timeout)) + defer cancel() + args := []string{"context", "inspect", "--format", dockerContextHostFormatV1} + if contextName != "" { + args = append(args, contextName) + } + command := exec.CommandContext(probeCtx, spec.Name, args...) + command.Dir = spec.Dir + if len(spec.Env) > 0 { + command.Env = append(os.Environ(), spec.Env...) + } + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + if err := command.Run(); err != nil { + if probeCtx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("Docker context inspection did not respond within %s", effectiveDockerPreflightTimeout(timeout)) + } + if text := trimmedCommandOutput(output.String()); text != "" { + return "", fmt.Errorf("inspect Docker endpoint: %w\ncommand output:\n%s", err, text) + } + return "", fmt.Errorf("inspect Docker endpoint: %w", err) + } + endpoint := strings.TrimSpace(output.String()) + if endpoint == "" { + return "", fmt.Errorf("Docker context did not report an endpoint") + } + return endpoint, nil +} + +func commandEnvironmentValueV1(spec CommandSpec, name string) string { + value := os.Getenv(name) + prefix := name + "=" + for _, assignment := range spec.Env { + if strings.HasPrefix(assignment, prefix) { + value = strings.TrimPrefix(assignment, prefix) + } + } + return strings.TrimSpace(value) +} + +func localDockerEndpointV1(endpoint string) bool { + scheme, _, found := strings.Cut(strings.TrimSpace(endpoint), ":") + if !found { + return false + } + switch strings.ToLower(scheme) { + case "unix", "npipe": + return true + default: + return false + } +} diff --git a/internal/dockerdeploy/docker_endpoint_test.go b/internal/dockerdeploy/docker_endpoint_test.go new file mode 100644 index 00000000..781443e7 --- /dev/null +++ b/internal/dockerdeploy/docker_endpoint_test.go @@ -0,0 +1,130 @@ +package dockerdeploy + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLocalDockerEndpointV1(t *testing.T) { + for _, test := range []struct { + endpoint string + want bool + }{ + {endpoint: "unix:///var/run/docker.sock", want: true}, + {endpoint: "unix:///home/user/.docker/desktop/docker.sock", want: true}, + {endpoint: "npipe:////./pipe/docker_engine", want: true}, + {endpoint: "npipe:////./pipe/dockerDesktopLinuxEngine", want: true}, + {endpoint: "ssh://builder.example", want: false}, + {endpoint: "tcp://builder.example:2376", want: false}, + {endpoint: "tcp://127.0.0.1:2375", want: false}, + {endpoint: "https://builder.example", want: false}, + {endpoint: "", want: false}, + } { + if got := localDockerEndpointV1(test.endpoint); got != test.want { + t.Fatalf("local Docker endpoint %q = %t, want %t", test.endpoint, got, test.want) + } + } +} + +func TestRequireLocalDockerEndpointV1RejectsDockerHost(t *testing.T) { + err := requireLocalDockerEndpointV1( + context.Background(), + CommandSpec{Name: "command-must-not-run", Env: []string{"DOCKER_HOST=ssh://builder.example", "DOCKER_CONTEXT="}}, + time.Second, + ) + if err == nil { + t.Fatal("remote DOCKER_HOST was accepted") + } + for _, want := range []string{"ssh://builder.example", "DOCKER_HOST", "not supported"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error missing %q: %v", want, err) + } + } +} + +func TestRequireLocalDockerEndpointV1RejectsRemoteContext(t *testing.T) { + dir := t.TempDir() + dockerPath := writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nprintf 'ssh://builder.example\\n'\n", + "@echo off\r\necho ssh://builder.example\r\n", + ) + err := requireLocalDockerEndpointV1( + context.Background(), + CommandSpec{Name: dockerPath, Env: []string{"DOCKER_CONTEXT=remote", "DOCKER_HOST=tcp://ignored.example:2376"}}, + time.Second, + ) + if err == nil { + t.Fatal("remote Docker context was accepted") + } + if !strings.Contains(err.Error(), `Docker context "remote"`) { + t.Fatalf("error does not identify context: %v", err) + } +} + +func TestEffectiveDockerEndpointV1InspectsActiveContext(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "argv.log") + dockerPath := writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nprintf '%s\\n' \"$*\" > \"$DOCKER_ARGV_LOG\"\nprintf 'unix:///var/run/docker.sock\\n'\n", + "@echo off\r\necho %* > \"%DOCKER_ARGV_LOG%\"\r\necho npipe:////./pipe/docker_engine\r\n", + ) + endpoint, source, err := effectiveDockerEndpointV1( + context.Background(), + CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=", "DOCKER_CONTEXT=", "DOCKER_ARGV_LOG=" + logPath}}, + time.Second, + ) + if err != nil { + t.Fatal(err) + } + if !localDockerEndpointV1(endpoint) || source != "the active Docker context" { + t.Fatalf("endpoint = %q from %q", endpoint, source) + } + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + want := "context inspect --format " + dockerContextHostFormatV1 + if strings.TrimSpace(string(content)) != want { + t.Fatalf("Docker context argv = %q, want %q", strings.TrimSpace(string(content)), want) + } +} + +func TestRequireDefaultLocalDockerEndpointV1RevalidatesActiveContext(t *testing.T) { + dir := t.TempDir() + endpointPath := filepath.Join(dir, "endpoint") + writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\ncat \"$DOCKER_ENDPOINT_FILE\"\n", + "@echo off\r\ntype \"%DOCKER_ENDPOINT_FILE%\"\r\n", + ) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("DOCKER_CONTEXT", "") + t.Setenv("DOCKER_HOST", "") + t.Setenv("DOCKER_ENDPOINT_FILE", endpointPath) + + if err := os.WriteFile(endpointPath, []byte("unix:///var/run/docker.sock\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := requireDefaultLocalDockerEndpointV1(context.Background()); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(endpointPath, []byte("ssh://builder.example\n"), 0o600); err != nil { + t.Fatal(err) + } + err := requireDefaultLocalDockerEndpointV1(context.Background()) + if err == nil || !strings.Contains(err.Error(), "remote Docker endpoint") { + t.Fatalf("changed active context was not rejected: %v", err) + } +} diff --git a/internal/dockerdeploy/execution_plan.go b/internal/dockerdeploy/execution_plan.go index 78d0c696..12eb8b23 100644 --- a/internal/dockerdeploy/execution_plan.go +++ b/internal/dockerdeploy/execution_plan.go @@ -332,16 +332,12 @@ func planRuntimeUser(document blueprint.Document, context DockerPlanContext) (Ru plan.Warnings = append(plan.Warnings, fmt.Sprintf("current-user install overrides the image user with local account %q (UID/GID %d:%d)", plan.LocalUser, context.UID, context.GID), ) - if context.UID == 0 { - plan.Warnings = append(plan.Warnings, rootRuntimeIdentityWarningV1) - } else { + if context.UID != 0 { plan.Warnings = append(plan.Warnings, "the image must tolerate an arbitrary non-root identity and may write persistently only to declared writable paths") } if document.Environment.Install.System.Account.User != "" || document.Environment.Install.System.Account.Group != "" { plan.Warnings = append(plan.Warnings, "environment.install.system.account does not apply to current-user scope") } - } else if context.UID == 0 { - plan.Warnings = append(plan.Warnings, rootRuntimeIdentityWarningV1) } return plan, nil } @@ -352,22 +348,16 @@ func planRuntimeUser(document blueprint.Document, context DockerPlanContext) (Ru if context.SystemUser == "" || context.SystemGroup == "" || context.UID < 0 || context.GID < 0 { return RuntimeUserPlan{}, fmt.Errorf("system Docker plan requires resolved service account and numeric UID/GID") } - plan := RuntimeUserPlan{ + return RuntimeUserPlan{ User: context.SystemUser, Group: context.SystemGroup, UID: context.UID, GID: context.GID, SupplementaryGIDs: supplementaryGIDs, DockerUser: strconv.Itoa(context.UID) + ":" + strconv.Itoa(context.GID), LocalUser: runtimeLocalUserNameV1(document.Environment.Runtime.User, context.UID), - } - if context.UID == 0 { - plan.Warnings = append(plan.Warnings, rootRuntimeIdentityWarningV1) - } - return plan, nil + }, nil } return RuntimeUserPlan{}, fmt.Errorf("cannot resolve Docker runtime user") } -const rootRuntimeIdentityWarningV1 = "the application will run as root inside its container; root can bypass application-level file permissions, while Docker access, Linux capabilities, host filesystem access, and network access remain limited by the effective Reploy sandbox policy" - func runtimeLocalUserNameV1(configured string, uid int) string { if uid == 0 { return "root" diff --git a/internal/dockerdeploy/execution_plan_test.go b/internal/dockerdeploy/execution_plan_test.go index 43d942e9..dd12fc3f 100644 --- a/internal/dockerdeploy/execution_plan_test.go +++ b/internal/dockerdeploy/execution_plan_test.go @@ -168,7 +168,7 @@ func TestPlanRuntimeUserScopePolicy(t *testing.T) { if root.LocalUser != "root" || root.DockerUser != "0:0" { t.Fatalf("root plan = %#v", root) } - if len(root.Warnings) != 1 || !strings.Contains(root.Warnings[0], "run as root inside its container") { + if len(root.Warnings) != 0 { t.Fatalf("root warnings = %#v", root.Warnings) } scope = blueprint.InstallScopeUser @@ -178,8 +178,8 @@ func TestPlanRuntimeUserScopePolicy(t *testing.T) { if err != nil { t.Fatal(err) } - if len(root.Warnings) != 3 || strings.Contains(strings.Join(root.Warnings, "\n"), "non-root identity") || - !strings.Contains(strings.Join(root.Warnings, "\n"), "run as root inside its container") { + if len(root.Warnings) != 2 || strings.Contains(strings.Join(root.Warnings, "\n"), "non-root identity") || + strings.Contains(strings.Join(root.Warnings, "\n"), "run as root inside its container") { t.Fatalf("root current-user warnings = %#v", root.Warnings) } } diff --git a/internal/dockerdeploy/installed_service_container.go b/internal/dockerdeploy/installed_service_container.go index a987d640..f4653d8b 100644 --- a/internal/dockerdeploy/installed_service_container.go +++ b/internal/dockerdeploy/installed_service_container.go @@ -105,12 +105,12 @@ func RunInstalledServiceContainerV1(ctx context.Context, deploymentDir string, a plan.Docker.ContainerName, environment, options, - runCommandWithoutDockerPreflight, + runDockerCommand, ); err != nil { return err } if err := notifyInstalledServiceReadyV1(); err != nil { - return errors.Join(err, cleanupPrivateWorkloadContainerV1(cleanup, RunOptions{Context: context.WithoutCancel(ctx)}, runCommandWithoutDockerPreflight)) + return errors.Join(err, cleanupPrivateWorkloadContainerV1(cleanup, RunOptions{Context: context.WithoutCancel(ctx)}, runDockerCommand)) } if err := operation.Unlock(); err != nil { return err @@ -123,7 +123,7 @@ func RunInstalledServiceContainerV1(ctx context.Context, deploymentDir string, a waitOptions.Stdin = nil waitOptions.Stdout = &status waitOptions.Stderr = options.Stderr - if err := runCommandWithoutDockerPreflight(CommandSpec{ + if err := runDockerCommand(CommandSpec{ Name: dockerPath, Args: []string{"wait", plan.Docker.ContainerName}, }, waitOptions); err != nil { diff --git a/internal/dockerdeploy/live_run_admission.go b/internal/dockerdeploy/live_run_admission.go index 9ac120d3..17621934 100644 --- a/internal/dockerdeploy/live_run_admission.go +++ b/internal/dockerdeploy/live_run_admission.go @@ -52,7 +52,7 @@ func AwaitLiveRunAdmissionWithNoticeV1( return nil } }, - removeContainer: runCommandWithoutDockerPreflight, + removeContainer: runCommand, }) } diff --git a/internal/dockerdeploy/live_run_admission_test.go b/internal/dockerdeploy/live_run_admission_test.go index 9dc200e4..e94fdd7c 100644 --- a/internal/dockerdeploy/live_run_admission_test.go +++ b/internal/dockerdeploy/live_run_admission_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/omry/reploy/internal/deploy" ) @@ -288,3 +289,46 @@ func TestAwaitLiveRunAdmissionV1RecoversAbandonedControlMarker(t *testing.T) { t.Fatal(err) } } + +func TestAwaitLiveRunAdmissionV1PreflightsRecoveredContainerCleanup(t *testing.T) { + dir := t.TempDir() + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + abandoned := liveRunAdmissionFixtureV1("run-0000000000000001", false) + if _, err := operation.AdmitLiveRunV1(abandoned, false); err != nil { + t.Fatal(err) + } + container := "demo-" + abandoned.ID + if err := operation.RecordLiveRunContainerV1(abandoned.ID, container); err != nil { + t.Fatal(err) + } + candidate := liveRunAdmissionFixtureV1("run-0000000000000002", false) + holdLiveRunLeaseV1(t, operation, candidate.ID) + preflightCalls := 0 + restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) error { + preflightCalls++ + return errors.New("remote Docker endpoint rejected") + }) + defer restore() + + admitted, err := AwaitLiveRunAdmissionV1(t.Context(), dir, operation, candidate, false) + if err != nil { + t.Fatal(err) + } + if preflightCalls != 1 { + t.Fatalf("Docker preflight calls = %d, want 1", preflightCalls) + } + queue, _, err := admitted.ReadLiveRunQueueV1() + if err != nil || len(queue.Runs) != 1 || queue.Runs[0].ID != candidate.ID || + len(queue.Cleanup) != 1 || queue.Cleanup[0].Container != container { + t.Fatalf("queue after rejected remote cleanup = %#v, %v", queue, err) + } + if _, removed, err := admitted.RemoveLiveRunV1(candidate.ID); err != nil || !removed { + t.Fatalf("remove admitted run = %t, %v", removed, err) + } + if err := admitted.Unlock(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/dockerdeploy/platform.go b/internal/dockerdeploy/platform.go index 0547210a..8bab6747 100644 --- a/internal/dockerdeploy/platform.go +++ b/internal/dockerdeploy/platform.go @@ -268,6 +268,9 @@ func detectDockerRuntime(ctx context.Context, spec CommandSpec, timeout time.Dur if spec.Name == "" { spec.Name = "docker" } + if err := requireLocalDockerEndpointV1(ctx, spec, timeout); err != nil { + return dockerRuntimeInfo{}, err + } probeCtx, cancel := context.WithTimeout(ctx, effectiveDockerPreflightTimeout(timeout)) defer cancel() diff --git a/internal/dockerdeploy/platform_test.go b/internal/dockerdeploy/platform_test.go index 130ddd17..b8324cda 100644 --- a/internal/dockerdeploy/platform_test.go +++ b/internal/dockerdeploy/platform_test.go @@ -406,7 +406,7 @@ func TestDetectDockerRuntimeDetectsDockerDesktop(t *testing.T) { "#!/bin/sh\nprintf '{\"OperatingSystem\":\"Docker Desktop\",\"ServerVersion\":\"29.5.3\"}\\n'\n", "@echo off\r\necho {\"OperatingSystem\":\"Docker Desktop\",\"ServerVersion\":\"29.5.3\"}\r\n", ) - info, err := detectDockerRuntime(context.Background(), CommandSpec{Name: dockerPath}, time.Second) + info, err := detectDockerRuntime(context.Background(), CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=unix:///var/run/docker.sock", "DOCKER_CONTEXT="}}, time.Second) if err != nil { t.Fatal(err) } @@ -427,7 +427,7 @@ func TestDetectDockerRuntimeDetectsLinuxEngine(t *testing.T) { "#!/bin/sh\nprintf '{\"OperatingSystem\":\"Ubuntu 24.04\",\"ServerVersion\":\"29.5.3\"}\\n'\n", "@echo off\r\necho {\"OperatingSystem\":\"Ubuntu 24.04\",\"ServerVersion\":\"29.5.3\"}\r\n", ) - info, err := detectDockerRuntime(context.Background(), CommandSpec{Name: dockerPath}, time.Second) + info, err := detectDockerRuntime(context.Background(), CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=unix:///var/run/docker.sock", "DOCKER_CONTEXT="}}, time.Second) if err != nil { t.Fatal(err) } diff --git a/internal/dockerdeploy/provider_build_run_test.go b/internal/dockerdeploy/provider_build_run_test.go index 021e577e..e8cee206 100644 --- a/internal/dockerdeploy/provider_build_run_test.go +++ b/internal/dockerdeploy/provider_build_run_test.go @@ -113,7 +113,7 @@ func TestRunLockedProviderBuildV1UsesAndRetainsCallerLock(t *testing.T) { if err != nil { t.Fatal(err) } - want := LockedProviderBuildExecutionResultV1{Reused: true, Warnings: []string{rootRuntimeIdentityWarningV1}} + want := LockedProviderBuildExecutionResultV1{Reused: true} order := []string{} result, err := runLockedProviderBuildV1(t.Context(), LockedProviderBuildRunInputV1{ diff --git a/internal/dockerdeploy/runtime_host_filesystem_darwin.go b/internal/dockerdeploy/runtime_host_filesystem_darwin.go new file mode 100644 index 00000000..97d41620 --- /dev/null +++ b/internal/dockerdeploy/runtime_host_filesystem_darwin.go @@ -0,0 +1,32 @@ +//go:build darwin + +package dockerdeploy + +import "golang.org/x/sys/unix" + +func protectedRuntimeHostPathV1(string) (string, error) { + return "", nil +} + +func protectedRuntimeHostFilesystemV1(path string) (string, error) { + var filesystem unix.Statfs_t + if err := unix.Statfs(path, &filesystem); err != nil { + return "", err + } + kind := darwinFilesystemNameV1(filesystem.Fstypename[:]) + switch kind { + case "devfs", "procfs": + return kind, nil + default: + return "", nil + } +} + +func darwinFilesystemNameV1(value []byte) string { + for index, item := range value { + if item == 0 { + return string(value[:index]) + } + } + return string(value) +} diff --git a/internal/dockerdeploy/runtime_host_filesystem_linux.go b/internal/dockerdeploy/runtime_host_filesystem_linux.go new file mode 100644 index 00000000..5039915c --- /dev/null +++ b/internal/dockerdeploy/runtime_host_filesystem_linux.go @@ -0,0 +1,621 @@ +//go:build linux + +package dockerdeploy + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "golang.org/x/sys/unix" +) + +const ( + legacyDevfsSuperMagicV1 = 0x1373 + fuseCtlSuperMagicV1 = 0x65735543 + mqueueMagicV1 = 0x19800202 +) + +func protectedRuntimeHostPathV1(path string) (string, error) { + return protectedRuntimeHostPathWithResolverV1(path, runtimeHostResolveNoMagicLinksV1) +} + +func runtimeHostResolveNoMagicLinksV1(path string) error { + fd, err := unix.Openat2(unix.AT_FDCWD, path, &unix.OpenHow{ + Flags: uint64(unix.O_PATH | unix.O_CLOEXEC), + Resolve: unix.RESOLVE_NO_MAGICLINKS, + }) + if err != nil { + return err + } + return unix.Close(fd) +} + +func protectedRuntimeHostPathWithResolverV1(path string, resolve func(string) error) (string, error) { + err := resolve(path) + if err == nil { + return "", nil + } + if errors.Is(err, unix.ELOOP) { + return "procfs magic link", nil + } + if errors.Is(err, unix.ENOSYS) { + symlink, err := runtimeHostPathContainsSymlinkV1(path) + if err != nil { + return "", fmt.Errorf("inspect host path without openat2: %w", err) + } + if symlink { + return "", fmt.Errorf("kernel does not support safe validation of symlinked host paths") + } + return "", nil + } + return "", fmt.Errorf("resolve without procfs magic links: %w", err) +} + +func runtimeHostPathContainsSymlinkV1(path string) (bool, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return false, err + } + current := string(filepath.Separator) + for _, component := range strings.Split(strings.TrimPrefix(filepath.Clean(absolute), current), current) { + if component == "" { + continue + } + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink != 0 { + return true, nil + } + } + return false, nil +} + +func protectedRuntimeHostFilesystemV1(path string) (string, error) { + rootFilesystem, err := runtimeHostSharesRootFilesystemV1(path) + if err != nil { + return "", err + } + if rootFilesystem { + return "host filesystem root", nil + } + mountFilesystem, err := runtimeHostMountFilesystemV1(path) + if err != nil { + return "", err + } + if protectedRuntimeHostFilesystemNameV1(mountFilesystem) { + if mountFilesystem == "proc" { + return "procfs", nil + } + return mountFilesystem, nil + } + var filesystem unix.Statfs_t + if err := unix.Statfs(path, &filesystem); err != nil { + return "", err + } + filesystemMagic := uint64(filesystem.Type) + if kind := protectedRuntimeHostFilesystemKindV1(filesystemMagic); kind != "" { + return kind, nil + } + protectedMount, err := runtimeHostSharesProtectedMountV1(path) + if err != nil { + return "", err + } + if protectedMount { + return "protected host submount", nil + } + protectedSubmount, err := runtimeHostContainsProtectedSubmountV1(path) + if err != nil { + return "", err + } + if protectedSubmount { + return "protected nested host submount", nil + } + if filesystemMagic != unix.TMPFS_MAGIC { + return "", nil + } + // devtmpfs deliberately shares tmpfs's superblock implementation. Use + // the exact mount identity to distinguish it without rejecting ordinary + // tmpfs sources. + + kind, err := runtimeHostMountFilesystemV1(path) + if err != nil { + return "", err + } + switch kind { + case "devtmpfs": + return kind, nil + } + devFilesystem, err := runtimeHostSharesDedicatedDevFilesystemV1(path) + if err != nil { + return "", err + } + if devFilesystem { + return "host /dev filesystem", nil + } + return "", nil +} + +func protectedRuntimeHostFilesystemKindV1(filesystemMagic uint64) string { + switch filesystemMagic { + case unix.PROC_SUPER_MAGIC: + return "procfs" + case unix.SYSFS_MAGIC: + return "sysfs" + case unix.DEVPTS_SUPER_MAGIC: + return "devpts" + case legacyDevfsSuperMagicV1: + return "devfs" + case unix.CGROUP_SUPER_MAGIC: + return "cgroup" + case unix.CGROUP2_SUPER_MAGIC: + return "cgroup2" + case unix.DEBUGFS_MAGIC: + return "debugfs" + case unix.TRACEFS_MAGIC: + return "tracefs" + case unix.SECURITYFS_MAGIC: + return "securityfs" + case unix.BPF_FS_MAGIC: + return "bpf" + case unix.BINFMTFS_MAGIC: + return "binfmt_misc" + case unix.EFIVARFS_MAGIC: + return "efivarfs" + case unix.NSFS_MAGIC: + return "nsfs" + case unix.PSTOREFS_MAGIC: + return "pstore" + case unix.SELINUX_MAGIC: + return "selinuxfs" + case fuseCtlSuperMagicV1: + return "fusectl" + case mqueueMagicV1: + return "mqueue" + } + return "" +} + +func runtimeHostSharesDedicatedDevFilesystemV1(path string) (bool, error) { + var candidate unix.Stat_t + if err := unix.Stat(path, &candidate); err != nil { + return false, fmt.Errorf("stat candidate filesystem: %w", err) + } + var dev unix.Stat_t + if err := unix.Stat("/dev", &dev); err != nil { + return false, fmt.Errorf("stat /dev filesystem: %w", err) + } + var root unix.Stat_t + if err := unix.Stat("/", &root); err != nil { + return false, fmt.Errorf("stat root filesystem: %w", err) + } + if dev.Dev == root.Dev { + return false, nil + } + return candidate.Dev == dev.Dev, nil +} + +func runtimeHostSharesProtectedMountV1(path string) (bool, error) { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return false, fmt.Errorf("read /proc/self/mountinfo: %w", err) + } + candidate, err := runtimeHostMountIdentityForPathV1(data, path) + if err != nil { + return false, err + } + for _, protectedTree := range []string{"/proc", "/dev", "/sys"} { + matched, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, candidate, path, protectedTree) + if err != nil || matched { + return matched, err + } + } + return false, nil +} + +func runtimeHostContainsProtectedSubmountV1(path string) (bool, error) { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return false, fmt.Errorf("read /proc/self/mountinfo: %w", err) + } + return runtimeHostMountContainsProtectedSubmountV1(data, path) +} + +func runtimeHostMountContainsProtectedSubmountV1(data []byte, path string) (bool, error) { + cleanPath := filepath.Clean(path) + root, err := runtimeHostMountIdentityByPathV1(data, string(filepath.Separator)) + if err != nil { + return false, err + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + if err != nil { + return false, err + } + mountPoint := filepath.Clean(identity.mountPoint) + if mountPoint == cleanPath || !pathWithinV1(mountPoint, cleanPath) { + continue + } + visible, err := runtimeHostMountIdentityByPathV1(data, mountPoint) + if err != nil { + return false, fmt.Errorf("resolve visible mount at %q: %w", mountPoint, err) + } + if visible.mountID != identity.mountID { + continue + } + if protectedRuntimeHostFilesystemNameV1(identity.filesystem) { + return true, nil + } + exposesRoot, err := runtimeHostMountIdentitiesExposeSameRootV1(identity, root, mountPoint) + if err != nil { + return false, err + } + if exposesRoot { + return true, nil + } + for _, protectedTree := range []string{"/proc", "/dev", "/sys"} { + exposesProtectedTree, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, identity, mountPoint, protectedTree) + if err != nil { + return false, err + } + if exposesProtectedTree { + return true, nil + } + } + } + return false, nil +} + +func protectedRuntimeHostFilesystemNameV1(filesystem string) bool { + switch filesystem { + case "anon_inodefs", "bdev", "binder", "binderfs", "binfmt_misc", "bpf", + "cgroup", "cgroup2", "configfs", "cpuset", "debugfs", "devfs", + "devmem", "devpts", "devtmpfs", "dma_buf", "efivarfs", "fusectl", + "futexfs", "hugetlbfs", "mqueue", "nfsd", "nsfs", "pipefs", "proc", + "pstore", "resctrl", "rootfs", "rpc_pipefs", "secretmem", "securityfs", + "selinuxfs", "smackfs", "sockfs", "sysfs", "tracefs", "usbfs", "xenfs": + return true + default: + return false + } +} + +func runtimeHostMountFilesystemV1(path string) (string, error) { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return "", fmt.Errorf("read /proc/self/mountinfo: %w", err) + } + identity, err := runtimeHostMountIdentityForPathV1(data, path) + if err != nil { + return "", err + } + return identity.filesystem, nil +} + +func runtimeHostSharesRootFilesystemV1(path string) (bool, error) { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return false, fmt.Errorf("read /proc/self/mountinfo: %w", err) + } + candidate, err := runtimeHostMountIdentityForPathV1(data, path) + if err != nil { + return false, err + } + root, err := runtimeHostMountIdentityForPathV1(data, "/") + if err != nil { + return false, err + } + return runtimeHostMountIdentitiesExposeSameRootV1(candidate, root, path) +} + +func runtimeHostMountIDV1(path string) (uint64, bool, error) { + var status unix.Statx_t + err := unix.Statx(unix.AT_FDCWD, path, unix.AT_STATX_SYNC_AS_STAT, unix.STATX_MNT_ID, &status) + if err != nil && !errors.Is(err, unix.ENOSYS) && !errors.Is(err, unix.EINVAL) && !errors.Is(err, unix.EOPNOTSUPP) { + return 0, false, fmt.Errorf("statx mount identity: %w", err) + } + if err != nil || status.Mask&unix.STATX_MNT_ID == 0 { + return 0, false, nil + } + return status.Mnt_id, true, nil +} + +func runtimeHostMountFilesystemByIDV1(data []byte, mountID uint64) (string, bool, error) { + identity, found, err := runtimeHostMountIdentityByIDV1(data, mountID) + return identity.filesystem, found, err +} + +type runtimeHostMountIdentityV1 struct { + mountID uint64 + parentID uint64 + device string + root string + mountPoint string + filesystem string +} + +func runtimeHostMountIdentityForPathV1(data []byte, path string) (runtimeHostMountIdentityV1, error) { + return runtimeHostMountIdentityForPathWithResolverV1(data, path, runtimeHostMountIDV1) +} + +func runtimeHostMountIdentityForPathWithResolverV1( + data []byte, + path string, + resolveMountID func(string) (uint64, bool, error), +) (runtimeHostMountIdentityV1, error) { + mountID, found, err := resolveMountID(path) + if err != nil { + return runtimeHostMountIdentityV1{}, err + } + if found { + identity, found, err := runtimeHostMountIdentityByIDV1(data, mountID) + if err != nil { + return runtimeHostMountIdentityV1{}, err + } + if !found { + return runtimeHostMountIdentityV1{}, fmt.Errorf("mount ID %d is absent from /proc/self/mountinfo", mountID) + } + return identity, nil + } + return runtimeHostMountIdentityByPathV1(data, path) +} + +func runtimeHostMountIdentityByPathV1(data []byte, path string) (runtimeHostMountIdentityV1, error) { + cleanPath := filepath.Clean(path) + byMountPoint := make(map[string][]runtimeHostMountIdentityV1) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + if err != nil { + return runtimeHostMountIdentityV1{}, err + } + if !pathWithinV1(cleanPath, identity.mountPoint) { + continue + } + mountPoint := filepath.Clean(identity.mountPoint) + byMountPoint[mountPoint] = append(byMountPoint[mountPoint], identity) + } + rootMounts := byMountPoint[string(filepath.Separator)] + visible, err := runtimeHostTopmostMountAtLocationV1(rootMounts) + if err != nil { + return runtimeHostMountIdentityV1{}, fmt.Errorf("resolve visible root mount: %w", err) + } + if visible.mountPoint == "" { + return runtimeHostMountIdentityV1{}, fmt.Errorf("host path %q is absent from /proc/self/mountinfo", path) + } + + mountPoints := make([]string, 0, len(byMountPoint)) + for mountPoint := range byMountPoint { + if mountPoint != string(filepath.Separator) { + mountPoints = append(mountPoints, mountPoint) + } + } + sort.Slice(mountPoints, func(left int, right int) bool { + return len(mountPoints[left]) < len(mountPoints[right]) + }) + for _, mountPoint := range mountPoints { + candidate, found, err := runtimeHostVisibleMountAtLocationV1(byMountPoint[mountPoint], visible.mountID) + if err != nil { + return runtimeHostMountIdentityV1{}, fmt.Errorf("resolve visible mount at %q: %w", mountPoint, err) + } + if found { + visible = candidate + } + } + return visible, nil +} + +func runtimeHostTopmostMountAtLocationV1(mounts []runtimeHostMountIdentityV1) (runtimeHostMountIdentityV1, error) { + parents := make(map[uint64]struct{}, len(mounts)) + for _, mount := range mounts { + if mount.parentID != mount.mountID { + parents[mount.parentID] = struct{}{} + } + } + var top runtimeHostMountIdentityV1 + found := false + for _, mount := range mounts { + if _, hidden := parents[mount.mountID]; hidden { + continue + } + if found { + return runtimeHostMountIdentityV1{}, fmt.Errorf("mount topology has multiple topmost entries") + } + top = mount + found = true + } + return top, nil +} + +func runtimeHostVisibleMountAtLocationV1( + mounts []runtimeHostMountIdentityV1, + visibleParentID uint64, +) (runtimeHostMountIdentityV1, bool, error) { + children := make(map[uint64][]runtimeHostMountIdentityV1, len(mounts)) + for _, mount := range mounts { + children[mount.parentID] = append(children[mount.parentID], mount) + } + currentID := visibleParentID + var visible runtimeHostMountIdentityV1 + found := false + seen := make(map[uint64]struct{}, len(mounts)) + for { + candidates := children[currentID] + if len(candidates) == 0 { + return visible, found, nil + } + if len(candidates) != 1 { + return runtimeHostMountIdentityV1{}, false, fmt.Errorf("mount topology has multiple visible children") + } + next := candidates[0] + if _, duplicate := seen[next.mountID]; duplicate { + return runtimeHostMountIdentityV1{}, false, fmt.Errorf("mount topology contains a cycle") + } + seen[next.mountID] = struct{}{} + visible = next + found = true + currentID = next.mountID + } +} + +func runtimeHostMountsExposeSameRootV1(data []byte, candidateID uint64, rootID uint64, path string) (bool, error) { + if candidateID == rootID { + return false, nil + } + candidate, found, err := runtimeHostMountIdentityByIDV1(data, candidateID) + if err != nil { + return false, err + } + if !found { + return false, fmt.Errorf("mount ID %d is absent from /proc/self/mountinfo", candidateID) + } + root, found, err := runtimeHostMountIdentityByIDV1(data, rootID) + if err != nil { + return false, err + } + if !found { + return false, fmt.Errorf("root mount ID %d is absent from /proc/self/mountinfo", rootID) + } + return runtimeHostMountIdentitiesExposeSameRootV1(candidate, root, path) +} + +func runtimeHostMountIdentitiesExposeSameRootV1(candidate runtimeHostMountIdentityV1, root runtimeHostMountIdentityV1, path string) (bool, error) { + if candidate.device != root.device { + return false, nil + } + effective, err := runtimeHostEffectiveBackingPathV1(candidate, path) + if err != nil { + return false, err + } + return pathWithinV1(root.root, effective), nil +} + +func runtimeHostMountSharesProtectedTreeV1(data []byte, candidateID uint64, path string, protectedTree string) (bool, error) { + candidate, found, err := runtimeHostMountIdentityByIDV1(data, candidateID) + if err != nil { + return false, err + } + if !found { + return false, fmt.Errorf("mount ID %d is absent from /proc/self/mountinfo", candidateID) + } + return runtimeHostMountIdentitySharesProtectedTreeV1(data, candidate, path, protectedTree) +} + +func runtimeHostMountIdentitySharesProtectedTreeV1(data []byte, candidate runtimeHostMountIdentityV1, path string, protectedTree string) (bool, error) { + effective, err := runtimeHostEffectiveBackingPathV1(candidate, path) + if err != nil { + return false, err + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + if err != nil { + return false, err + } + if !pathWithinV1(identity.mountPoint, protectedTree) || identity.device != candidate.device { + continue + } + visible, err := runtimeHostMountIdentityByPathV1(data, identity.mountPoint) + if err != nil { + return false, fmt.Errorf("resolve visible mount at %q: %w", identity.mountPoint, err) + } + if visible.mountID != identity.mountID { + continue + } + if pathWithinV1(effective, identity.root) || pathWithinV1(identity.root, effective) { + return true, nil + } + } + return false, nil +} + +func runtimeHostEffectiveBackingPathV1(identity runtimeHostMountIdentityV1, path string) (string, error) { + if !pathWithinV1(path, identity.mountPoint) { + return "", fmt.Errorf("host path %q is outside mount point %q", path, identity.mountPoint) + } + relative, err := filepath.Rel(filepath.Clean(identity.mountPoint), filepath.Clean(path)) + if err != nil { + return "", fmt.Errorf("resolve path within host mount: %w", err) + } + return filepath.Clean(filepath.Join(identity.root, relative)), nil +} + +func runtimeHostMountIdentityByIDV1(data []byte, mountID uint64) (runtimeHostMountIdentityV1, bool, error) { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + candidate, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return runtimeHostMountIdentityV1{}, false, fmt.Errorf("parse mount ID %q: %w", fields[0], err) + } + if candidate != mountID { + continue + } + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + return identity, true, err + } + return runtimeHostMountIdentityV1{}, false, nil +} + +func runtimeHostMountFilesystemFromFieldsV1(fields []string) (string, error) { + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + return identity.filesystem, err +} + +func runtimeHostMountIdentityFromFieldsV1(fields []string) (runtimeHostMountIdentityV1, error) { + if len(fields) < 5 { + return runtimeHostMountIdentityV1{}, fmt.Errorf("mountinfo record is missing identity fields") + } + mountID, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return runtimeHostMountIdentityV1{}, fmt.Errorf("parse mount ID %q: %w", fields[0], err) + } + parentID, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return runtimeHostMountIdentityV1{}, fmt.Errorf("parse parent mount ID %q: %w", fields[1], err) + } + for index, field := range fields { + if field == "-" { + if index+1 >= len(fields) { + return runtimeHostMountIdentityV1{}, fmt.Errorf("mountinfo record is missing filesystem type") + } + return runtimeHostMountIdentityV1{ + mountID: mountID, + parentID: parentID, + device: fields[2], + root: runtimeHostMountPathV1(fields[3]), + mountPoint: runtimeHostMountPathV1(fields[4]), + filesystem: fields[index+1], + }, nil + } + } + return runtimeHostMountIdentityV1{}, fmt.Errorf("mountinfo record is missing field separator") +} + +func runtimeHostMountPathV1(path string) string { + return strings.NewReplacer( + `\040`, " ", + `\011`, "\t", + `\012`, "\n", + `\134`, `\`, + ).Replace(path) +} diff --git a/internal/dockerdeploy/runtime_host_filesystem_linux_test.go b/internal/dockerdeploy/runtime_host_filesystem_linux_test.go new file mode 100644 index 00000000..e94863ce --- /dev/null +++ b/internal/dockerdeploy/runtime_host_filesystem_linux_test.go @@ -0,0 +1,481 @@ +//go:build linux + +package dockerdeploy + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "golang.org/x/sys/unix" +) + +func TestProtectedRuntimeHostPathV1RejectsProcMagicLinkAliases(t *testing.T) { + root := t.TempDir() + directAlias := filepath.Join(root, "cwd") + if err := os.Symlink("/proc/self/cwd", directAlias); err != nil { + t.Fatal(err) + } + procAlias := filepath.Join(root, "proc") + if err := os.Symlink("/proc", procAlias); err != nil { + t.Fatal(err) + } + for _, path := range []string{directAlias, filepath.Join(procAlias, "self", "cwd")} { + got, err := protectedRuntimeHostPathV1(path) + if err != nil { + t.Fatal(err) + } + if got != "procfs magic link" { + t.Fatalf("protected path %q = %q, want procfs magic link", path, got) + } + } +} + +func TestProtectedRuntimeHostPathV1AllowsOrdinarySymlinks(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + got, err := protectedRuntimeHostPathV1(alias) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("ordinary symlink classified as %q", got) + } +} + +func TestProtectedRuntimeHostPathWithResolverV1FallsBackWithoutOpenat2(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + unsupported := func(string) error { return unix.ENOSYS } + + got, err := protectedRuntimeHostPathWithResolverV1(target, unsupported) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("ordinary path classified as %q", got) + } + if _, err := protectedRuntimeHostPathWithResolverV1(alias, unsupported); err == nil { + t.Fatal("symlinked path accepted without openat2") + } +} + +func TestProtectedRuntimeHostPathWithResolverV1ClassifiesMagicLinks(t *testing.T) { + magicLink := func(string) error { return unix.ELOOP } + got, err := protectedRuntimeHostPathWithResolverV1("/ordinary/path", magicLink) + if err != nil { + t.Fatal(err) + } + if got != "procfs magic link" { + t.Fatalf("magic link classified as %q", got) + } +} + +func TestProtectedRuntimeHostFilesystemV1RecognizesKernelFilesystems(t *testing.T) { + for _, test := range []struct { + path string + want string + }{ + {path: "/proc", want: "procfs"}, + {path: "/sys", want: "sysfs"}, + {path: "/dev/pts", want: "devpts"}, + } { + t.Run(test.want, func(t *testing.T) { + if _, err := os.Stat(test.path); os.IsNotExist(err) { + t.Skipf("host path %q is absent", test.path) + } else if err != nil { + t.Fatal(err) + } + got, err := protectedRuntimeHostFilesystemV1(test.path) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("filesystem for %q = %q, want %q", test.path, got, test.want) + } + }) + } +} + +func TestProtectedRuntimeHostFilesystemV1DoesNotRejectOrdinaryTmpfs(t *testing.T) { + for _, candidate := range []string{"/run", "/tmp"} { + if _, err := os.Stat(candidate); err != nil { + continue + } + kind, err := runtimeHostMountFilesystemV1(candidate) + if err != nil { + t.Fatal(err) + } + if kind != "tmpfs" { + continue + } + protected, err := protectedRuntimeHostFilesystemV1(candidate) + if err != nil { + t.Fatal(err) + } + if protected == "protected nested host submount" { + continue + } + if protected != "" { + t.Fatalf("ordinary tmpfs %q classified as %q", candidate, protected) + } + return + } + t.Skip("host has no ordinary tmpfs candidate") +} + +func TestProtectedRuntimeHostFilesystemKindV1RecognizesKernelControlFilesystems(t *testing.T) { + for _, test := range []struct { + magic uint64 + want string + }{ + {magic: unix.CGROUP_SUPER_MAGIC, want: "cgroup"}, + {magic: unix.CGROUP2_SUPER_MAGIC, want: "cgroup2"}, + {magic: unix.DEBUGFS_MAGIC, want: "debugfs"}, + {magic: unix.TRACEFS_MAGIC, want: "tracefs"}, + {magic: unix.SECURITYFS_MAGIC, want: "securityfs"}, + {magic: unix.BPF_FS_MAGIC, want: "bpf"}, + {magic: unix.BINFMTFS_MAGIC, want: "binfmt_misc"}, + {magic: unix.EFIVARFS_MAGIC, want: "efivarfs"}, + {magic: unix.NSFS_MAGIC, want: "nsfs"}, + {magic: unix.PSTOREFS_MAGIC, want: "pstore"}, + {magic: unix.SELINUX_MAGIC, want: "selinuxfs"}, + {magic: fuseCtlSuperMagicV1, want: "fusectl"}, + {magic: mqueueMagicV1, want: "mqueue"}, + } { + if got := protectedRuntimeHostFilesystemKindV1(test.magic); got != test.want { + t.Fatalf("filesystem magic %#x = %q, want %q", test.magic, got, test.want) + } + } +} + +func TestProtectedRuntimeHostFilesystemNameV1RecognizesKernelInterfaces(t *testing.T) { + for _, filesystem := range []string{ + "proc", "sysfs", "devpts", "devtmpfs", "cgroup2", "configfs", + "debugfs", "securityfs", "bpf", "binfmt_misc", "efivarfs", + "fusectl", "hugetlbfs", "mqueue", "resctrl", "rpc_pipefs", + } { + if !protectedRuntimeHostFilesystemNameV1(filesystem) { + t.Fatalf("kernel interface filesystem %q was not protected", filesystem) + } + } + for _, filesystem := range []string{"ext4", "tmpfs", "fuse", "overlay", "9p"} { + if protectedRuntimeHostFilesystemNameV1(filesystem) { + t.Fatalf("ordinary data filesystem %q was protected", filesystem) + } + } +} + +func TestRuntimeHostSharesDedicatedDevFilesystemV1(t *testing.T) { + dev, err := runtimeHostSharesDedicatedDevFilesystemV1("/dev") + if err != nil { + t.Fatal(err) + } + var devInfo, rootInfo syscall.Stat_t + if err := syscall.Stat("/dev", &devInfo); err != nil { + t.Fatal(err) + } + if err := syscall.Stat("/", &rootInfo); err != nil { + t.Fatal(err) + } + if devInfo.Dev == rootInfo.Dev { + if dev { + t.Fatal("non-dedicated /dev filesystem classified as dedicated") + } + return + } + if !dev { + t.Fatal("dedicated /dev filesystem was not recognized") + } +} + +func TestRuntimeHostMountFilesystemV1ParsesIdentity(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /safe\\040tree rw - devtmpfs udev rw\n" + + "43 42 0:3 / /safe\\040tree/nested rw - proc proc rw\n") + + kind, found, err := runtimeHostMountFilesystemByIDV1(data, 42) + if err != nil { + t.Fatal(err) + } + if !found || kind != "devtmpfs" { + t.Fatalf("mount ID lookup = %q, %t", kind, found) + } + identity, found, err := runtimeHostMountIdentityByIDV1(data, 42) + if err != nil { + t.Fatal(err) + } + if !found || identity.mountPoint != "/safe tree" { + t.Fatalf("mount point lookup = %q, %t", identity.mountPoint, found) + } +} + +func TestRuntimeHostMountsExposeSameRootV1(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:1 / /safe/root rw - ext4 /dev/root rw\n" + + "43 41 0:1 /var /safe/var rw - ext4 /dev/root rw\n" + + "44 41 0:2 / /other rw - ext4 /dev/other rw\n") + + for _, test := range []struct { + name string + candidate uint64 + path string + want bool + }{ + {name: "root alias", candidate: 42, path: "/safe/root", want: true}, + {name: "path below root alias", candidate: 42, path: "/safe/root/home/me/app", want: false}, + {name: "subdirectory bind", candidate: 43, path: "/safe/var", want: false}, + {name: "different filesystem", candidate: 44, path: "/other", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := runtimeHostMountsExposeSameRootV1(data, test.candidate, 41, test.path) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("same root = %t, want %t", got, test.want) + } + }) + } +} + +func TestRuntimeHostMountIdentityForPathV1FallsBackWithoutMountIDs(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:1 / /safe/root rw - ext4 /dev/root rw\n" + + "43 41 0:3 / /dev/shm rw - tmpfs shm rw\n" + + "44 41 0:3 / /safe/shm rw - tmpfs shm rw\n") + unsupported := func(string) (uint64, bool, error) { return 0, false, nil } + + rootAlias, err := runtimeHostMountIdentityForPathWithResolverV1(data, "/safe/root", unsupported) + if err != nil { + t.Fatal(err) + } + root, err := runtimeHostMountIdentityForPathWithResolverV1(data, "/", unsupported) + if err != nil { + t.Fatal(err) + } + exposesRoot, err := runtimeHostMountIdentitiesExposeSameRootV1(rootAlias, root, "/safe/root") + if err != nil { + t.Fatal(err) + } + if !exposesRoot { + t.Fatal("root bind alias was not recognized without statx mount IDs") + } + + devAlias, err := runtimeHostMountIdentityForPathWithResolverV1(data, "/safe/shm", unsupported) + if err != nil { + t.Fatal(err) + } + protected, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, devAlias, "/safe/shm", "/dev") + if err != nil { + t.Fatal(err) + } + if !protected { + t.Fatal("protected submount alias was not recognized without statx mount IDs") + } +} + +func TestRuntimeHostMountIdentityByPathV1ResolvesVisibleMountTopology(t *testing.T) { + data := []byte("41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /stack rw - tmpfs lower rw\n" + + "43 42 0:3 / /stack/hidden rw - tmpfs hidden rw\n" + + "44 42 0:4 / /stack rw - tmpfs upper rw\n" + + "45 44 0:5 / /stack/visible rw - tmpfs visible rw\n") + + for _, test := range []struct { + path string + wantMountID uint64 + }{ + {path: "/stack", wantMountID: 44}, + {path: "/stack/file", wantMountID: 44}, + {path: "/stack/hidden", wantMountID: 44}, + {path: "/stack/visible", wantMountID: 45}, + {path: "/stack/visible/file", wantMountID: 45}, + } { + identity, err := runtimeHostMountIdentityByPathV1(data, test.path) + if err != nil { + t.Fatal(err) + } + if identity.mountID != test.wantMountID { + t.Fatalf("visible mount for %q = %d, want %d", test.path, identity.mountID, test.wantMountID) + } + } +} + +func TestRuntimeHostMountContainsProtectedSubmountV1(t *testing.T) { + for _, test := range []struct { + name string + data string + want bool + }{ + { + name: "root alias", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:1 / /safe/root rw - ext4 /dev/root rw\n", + want: true, + }, + { + name: "proc alias", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /proc rw - proc proc rw\n" + + "43 41 0:2 / /safe/proc rw - proc proc rw\n", + want: true, + }, + { + name: "hidden proc alias", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /proc rw - proc proc rw\n" + + "43 41 0:2 / /safe/proc rw - proc proc rw\n" + + "44 43 0:3 / /safe/proc rw - tmpfs visible rw\n", + want: false, + }, + { + name: "ordinary nested filesystem", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:4 / /safe/data rw - ext4 /dev/data rw\n", + want: false, + }, + { + name: "standalone nested kernel filesystem", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:9 / /safe/control rw - cgroup2 cgroup2 rw\n", + want: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + got, err := runtimeHostMountContainsProtectedSubmountV1([]byte(test.data), "/safe") + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("protected nested mount = %t, want %t", got, test.want) + } + }) + } +} + +func TestRuntimeHostMountIdentitiesExposeSameRootV1UsesEffectiveBackingPath(t *testing.T) { + root := runtimeHostMountIdentityV1{device: "0:1", root: "/@", mountPoint: "/", filesystem: "btrfs"} + topLevel := runtimeHostMountIdentityV1{device: "0:1", root: "/", mountPoint: "/mnt/btrfs", filesystem: "btrfs"} + + for _, test := range []struct { + path string + want bool + }{ + {path: "/mnt/btrfs", want: true}, + {path: "/mnt/btrfs/@", want: true}, + {path: "/mnt/btrfs/@/home", want: false}, + {path: "/mnt/btrfs/other", want: false}, + } { + got, err := runtimeHostMountIdentitiesExposeSameRootV1(topLevel, root, test.path) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("root exposure for %q = %t, want %t", test.path, got, test.want) + } + } +} + +func TestRuntimeHostMountSharesProtectedTreeV1(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /dev rw - tmpfs tmpfs rw\n" + + "43 42 0:3 / /dev/shm rw - tmpfs shm rw\n" + + "44 41 0:3 / /safe/shm rw - tmpfs shm rw\n" + + "45 41 0:4 / /run rw - tmpfs tmpfs rw\n" + + "46 41 0:3 /session /safe/session rw - tmpfs shm rw\n" + + "47 41 0:5 / /sys/fs/resctrl rw - resctrl resctrl rw\n" + + "48 41 0:5 / /safe/resctrl rw - resctrl resctrl rw\n") + + for _, test := range []struct { + name string + candidate uint64 + path string + tree string + want bool + }{ + {name: "dev shm alias", candidate: 44, path: "/safe/shm", tree: "/dev", want: true}, + {name: "dev shm subdirectory alias", candidate: 46, path: "/safe/session", tree: "/dev", want: true}, + {name: "sys resctrl alias", candidate: 48, path: "/safe/resctrl", tree: "/sys", want: true}, + {name: "unrelated tmpfs", candidate: 45, path: "/run", tree: "/dev", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := runtimeHostMountSharesProtectedTreeV1(data, test.candidate, test.path, test.tree) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("protected tree match = %t, want %t", got, test.want) + } + }) + } +} + +func TestRuntimeHostMountSharesProtectedTreeV1UsesEffectiveBackingPath(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:3 / /run rw - tmpfs tmpfs rw\n" + + "43 41 0:3 /protected /dev/protected rw - tmpfs tmpfs rw\n") + candidate, found, err := runtimeHostMountIdentityByIDV1(data, 42) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("candidate mount not found") + } + + for _, test := range []struct { + path string + want bool + }{ + {path: "/run", want: true}, + {path: "/run/protected", want: true}, + {path: "/run/protected/child", want: true}, + {path: "/run/ordinary", want: false}, + } { + got, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, candidate, test.path, "/dev") + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("protected mount exposure for %q = %t, want %t", test.path, got, test.want) + } + } +} + +func TestRuntimeHostMountSharesProtectedTreeV1IgnoresHiddenMount(t *testing.T) { + data := []byte("41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /dev rw - tmpfs tmpfs rw\n" + + "43 42 0:3 /secret /dev/x rw - tmpfs hidden rw\n" + + "44 43 0:4 / /dev/x rw - tmpfs visible rw\n" + + "45 41 0:3 / /safe rw - tmpfs candidate rw\n") + candidate, found, err := runtimeHostMountIdentityByIDV1(data, 45) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("candidate mount not found") + } + + got, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, candidate, "/safe/secret", "/dev") + if err != nil { + t.Fatal(err) + } + if got { + t.Fatal("hidden protected-tree mount was treated as visible") + } +} diff --git a/internal/dockerdeploy/runtime_host_filesystem_other.go b/internal/dockerdeploy/runtime_host_filesystem_other.go new file mode 100644 index 00000000..5954b7f2 --- /dev/null +++ b/internal/dockerdeploy/runtime_host_filesystem_other.go @@ -0,0 +1,11 @@ +//go:build !linux && !darwin + +package dockerdeploy + +func protectedRuntimeHostPathV1(string) (string, error) { + return "", nil +} + +func protectedRuntimeHostFilesystemV1(string) (string, error) { + return "", nil +} diff --git a/internal/dockerdeploy/runtime_host_preflight.go b/internal/dockerdeploy/runtime_host_preflight.go index 3ad5ad46..c1ae8b2f 100644 --- a/internal/dockerdeploy/runtime_host_preflight.go +++ b/internal/dockerdeploy/runtime_host_preflight.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "path/filepath" + "runtime" "sort" + "strings" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" @@ -123,6 +125,16 @@ func ValidateRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, planID string, if err != nil { return fmt.Errorf("runtime plan %q mount %q host source: %w", planID, mount.Destination, err) } + protected, err := protectedRuntimeHostTreeV1(source.HostPath) + if err != nil { + return fmt.Errorf("runtime plan %q mount %q host source: %w", planID, mount.Destination, err) + } + if protected != "" { + return fmt.Errorf( + "runtime plan %q mount %q host source resolves to protected host system source %q; ordinary host binds cannot expose the host filesystem root or protected kernel filesystems, including /proc, /dev, and /sys", + planID, mount.Destination, protected, + ) + } switch mount.SourceKind { case deploy.RuntimeMountSourceDirectory: if !info.IsDir() { @@ -147,6 +159,67 @@ func ValidateRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, planID string, return nil } +func protectedRuntimeHostTreeV1(hostPath string) (string, error) { + if runtime.GOOS != "windows" { + original := filepath.Clean(hostPath) + for _, candidate := range []string{"/proc", "/dev", "/sys"} { + if pathWithinV1(original, candidate) { + return candidate, nil + } + } + } + protected, err := protectedRuntimeHostPathV1(hostPath) + if err != nil { + return "", fmt.Errorf("validate host path resolution: %w", err) + } + if protected != "" { + return protected, nil + } + + resolved, err := filepath.EvalSymlinks(hostPath) + if err != nil { + return "", fmt.Errorf("resolve canonical path: %w", err) + } + resolved = filepath.Clean(resolved) + filesystem, err := protectedRuntimeHostFilesystemV1(resolved) + if err != nil { + return "", fmt.Errorf("identify host filesystem: %w", err) + } + if filesystem != "" { + return filesystem, nil + } + + volumeRoot := filepath.VolumeName(resolved) + string(filepath.Separator) + if filepath.Clean(volumeRoot) == resolved { + return volumeRoot, nil + } + if runtime.GOOS == "windows" { + return "", nil + } + + for _, candidate := range []string{"/proc", "/dev", "/sys"} { + canonical, err := filepath.EvalSymlinks(candidate) + if err != nil { + if os.IsNotExist(err) { + continue + } + return "", fmt.Errorf("resolve protected host system tree %q: %w", candidate, err) + } + if pathWithinV1(resolved, canonical) { + return candidate, nil + } + } + return "", nil +} + +func pathWithinV1(path string, root string) bool { + relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil { + return false + } + return relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + func ValidateRootRuntimeHostAuthorityV1(policy deploy.RuntimePolicyV1, plan DockerExecutionPlan) error { if plan.Sandbox.RuntimeUser.UID != 0 { return nil diff --git a/internal/dockerdeploy/runtime_host_preflight_test.go b/internal/dockerdeploy/runtime_host_preflight_test.go index af9ab4c5..383ad3a9 100644 --- a/internal/dockerdeploy/runtime_host_preflight_test.go +++ b/internal/dockerdeploy/runtime_host_preflight_test.go @@ -3,6 +3,7 @@ package dockerdeploy import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -79,6 +80,75 @@ func TestValidateRuntimeHostSourcesV1RejectsChangedFilesystemKind(t *testing.T) } } +func TestValidateRuntimeHostSourcesV1RejectsProtectedHostSystemTrees(t *testing.T) { + root := string(filepath.Separator) + if volume := filepath.VolumeName(t.TempDir()); volume != "" { + root = volume + string(filepath.Separator) + } + type testCase struct { + name string + path string + kind string + } + tests := []testCase{ + {name: "filesystem root", path: root, kind: deploy.RuntimeMountSourceDirectory}, + } + if runtime.GOOS != "windows" { + tests = append(tests, + testCase{name: "proc descendant", path: "/proc/self", kind: deploy.RuntimeMountSourceDirectory}, + testCase{name: "proc process-relative directory", path: "/proc/self/cwd", kind: deploy.RuntimeMountSourceDirectory}, + testCase{name: "dev descendant", path: "/dev/null", kind: deploy.RuntimeMountSourceFile}, + testCase{name: "sys", path: "/sys", kind: deploy.RuntimeMountSourceDirectory}, + ) + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := os.Stat(test.path); os.IsNotExist(err) { + t.Skipf("host path %q is absent", test.path) + } else if err != nil { + t.Fatal(err) + } + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/host", SourceKind: test.kind, ReadOnly: true, + }}) + err := ValidateRuntimeHostSourcesV1(policy, "command/check", 1000, []RuntimeHostSourceV1{{ + Destination: "/mnt/host", HostPath: test.path, SourceKind: test.kind, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, + }}) + if err == nil || !strings.Contains(err.Error(), "protected host system source") { + t.Fatalf("protected host tree error = %v", err) + } + }) + } +} + +func TestValidateRuntimeHostSourcesV1RejectsProtectedHostSystemTreeAlias(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating a directory symlink requires additional privileges on Windows") + } + protected := "/dev" + if _, err := os.Stat(protected); os.IsNotExist(err) { + t.Skipf("host path %q is absent", protected) + } else if err != nil { + t.Fatal(err) + } + alias := filepath.Join(t.TempDir(), "device-alias") + if err := os.Symlink(protected, alias); err != nil { + t.Fatal(err) + } + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/host", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + }}) + err := ValidateRuntimeHostSourcesV1(policy, "command/check", 1000, []RuntimeHostSourceV1{{ + Destination: "/mnt/host", HostPath: alias, SourceKind: deploy.RuntimeMountSourceDirectory, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, + }}) + if err == nil || !strings.Contains(err.Error(), "protected host system source") { + t.Fatalf("protected host tree alias error = %v", err) + } +} + func TestValidateRuntimeHostSourcesV1RejectsEveryHostAuthorityForRoot(t *testing.T) { root := t.TempDir() for _, test := range []struct { diff --git a/internal/dockerdeploy/runtime_host_preflight_unix_test.go b/internal/dockerdeploy/runtime_host_preflight_unix_test.go new file mode 100644 index 00000000..95e6e3ad --- /dev/null +++ b/internal/dockerdeploy/runtime_host_preflight_unix_test.go @@ -0,0 +1,27 @@ +//go:build linux || darwin + +package dockerdeploy + +import ( + "path/filepath" + "syscall" + "testing" + + "github.com/omry/reploy/internal/deploy" +) + +func TestValidateRuntimeHostSourcesV1PreservesExplicitDirectoryWithNestedSpecialObject(t *testing.T) { + root := t.TempDir() + if err := syscall.Mkfifo(filepath.Join(root, "service.pipe"), 0o600); err != nil { + t.Fatal(err) + } + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/project", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + }}) + if err := ValidateRuntimeHostSourcesV1(policy, "command/check", 1000, []RuntimeHostSourceV1{{ + Destination: "/mnt/project", HostPath: root, SourceKind: deploy.RuntimeMountSourceDirectory, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, + }}); err != nil { + t.Fatal(err) + } +}