Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changes/unreleased/+root-host-authority.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Security
body: Reject host input, shared-state, and explicit output mounts for root application runtimes while continuing to allow Docker-managed volumes and tmpfs.
13 changes: 6 additions & 7 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,12 @@ This file is the day-to-day queue for design and implementation gaps.

## Now

- [ ] `P1` Inventory the global sandbox prerequisites for controlled sessions.
Use `CONTROLLED_SESSION_DESIGN.md` as the policy source. Map every staged,
installed, command, and shell container-launch path against the approved
identity, seccomp, `no-new-privileges`, capability, namespace, device,
mount, mask, secret, network, and root invariants. Record verified current
behavior and turn each missing invariant into a focused implementation
slice before starting the controlled-session lifecycle core.
- [ ] `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.

## Pre-release

Expand Down
7 changes: 7 additions & 0 deletions docs/BLUEPRINT_ENVIRONMENT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,13 @@ is deliberately independent of a Windows domain account or Unix host account.
An effective UID of zero uses the existing local name `root`; a blueprint
cannot request root merely by naming it.

An application runtime with effective UID zero cannot receive a host bind,
whether read-only input or writable shared state. It also cannot use
`--output-file` or `--output-dir` until the separately reviewed root-safe output
contract is implemented. Reploy rejects these combinations before container
creation or output-path preparation. Docker-managed volumes and tmpfs remain
available because they do not expose a host filesystem path directly.

This is a portable blueprint contract with target-specific realization. The
current backend writes Linux account databases. A future native-Windows or
other target backend may realize the same local identity through different OS
Expand Down
24 changes: 15 additions & 9 deletions docs/CONTROLLED_SESSION_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ 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 emits a warning equivalent to:
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
Expand Down Expand Up @@ -673,10 +673,10 @@ through a writable bind.
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
validates the complete effective mount plan and rejects the operation before
contacting Docker if a prohibited bind source would be visible. A separately
validated output-only bind is a narrow explicit result grant, not general host
filesystem authority.
validates the complete effective mount plan and rejects the application-runtime
launch before Docker can create a container with a prohibited bind source. A
separately validated output-only bind is a narrow explicit result grant, not
general host filesystem authority.

Root application containers may use image content, Docker-managed volumes,
tmpfs, or a disposable copied workspace because those do not expose the
Expand Down Expand Up @@ -1064,16 +1064,22 @@ prohibits privileged mode, host namespaces, and host devices in the common
plan. Live Docker tests inspect both runtime paths. Trusted production startup
verification is also implemented: Reploy packages the platform-specific probe
in a final runtime layer, creates the locked container-local account there,
records that layer outside the provider graph, and
uses its fixed verify-and-exec contract as the outermost process for persistent
records that layer outside the provider graph, and uses its fixed
verify-and-exec contract as the outermost process for persistent
workloads, transient commands, shells, and lifecycle commands. The verifier
fails closed unless `/proc/self/status` reports seccomp filtering,
`no-new-privileges`, and empty effective, permitted, and bounding capability
sets, then directly executes the exact application argv. Private-environment
workloads use one additional fixed Reploy step: after verification, the probe
executes the environment injector, which imports the private variables and then
executes the unchanged application argv. Mount/root authority, network denial,
and resource limits remain separate prerequisite slices.
executes the unchanged application argv. Network denial and resource limits
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.

### Slice 2: Controlled-Session Lifecycle Core

Expand Down
16 changes: 13 additions & 3 deletions internal/dockerdeploy/environment_lifecycle_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import (
"context"
"io"

"github.com/omry/reploy/internal/blueprint"
"github.com/omry/reploy/internal/providerstore"
"github.com/omry/reploy/internal/deploy"
)

func environmentLifecycleExecutor(options RuntimeOptions, plan DockerExecutionPlan, _ providerstore.Store, _ blueprint.Platform, stdout io.Writer, stderr io.Writer) LifecycleExecutor {
func environmentLifecycleExecutor(options RuntimeOptions, plan DockerExecutionPlan, policy deploy.RuntimePolicyV1, stdout io.Writer, stderr io.Writer) LifecycleExecutor {
return LifecycleExecutor{
RunCommand: func(ctx context.Context, command ResolvedEnvironmentCommand) error {
if err := validateLifecycleRuntimeHostSourcesV1(policy, plan, command.Name); err != nil {
return err
}
if _, err := preparePrivateWorkloadEnvironmentV1(options.Dir); err != nil {
return err
}
Expand All @@ -32,3 +34,11 @@ func environmentLifecycleExecutor(options RuntimeOptions, plan DockerExecutionPl
},
}
}

func validateLifecycleRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, plan DockerExecutionPlan, commandName string) error {
invocation, err := CommandRuntimeInvocationV1(plan, commandName, nil)
if err != nil {
return err
}
return ValidateRuntimeHostSourcesV1(policy, invocation.PlanID, plan.Sandbox.RuntimeUser.UID, invocation.Sources)
}
56 changes: 56 additions & 0 deletions internal/dockerdeploy/environment_lifecycle_executor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package dockerdeploy

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/omry/reploy/internal/blueprint"
"github.com/omry/reploy/internal/deploy"
)

func TestValidateLifecycleRuntimeHostSourcesV1EnforcesRootBindPolicy(t *testing.T) {
root := t.TempDir()
plan := DockerExecutionPlan{
Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 0, GID: 0, DockerUser: "0:0"}),
Mounts: []MountExecutionPlan{{
Name: "config", Mode: blueprint.MountBind, Source: root,
SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/mnt/config", ReadOnly: true,
}},
}
policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{
Destination: "/mnt/config", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true,
}})
policy.Plans[0].ID = "command/check"
if err := validateLifecycleRuntimeHostSourcesV1(policy, plan, "check"); err == nil || !strings.Contains(err.Error(), "root application runtime") {
t.Fatalf("root lifecycle bind error = %v", err)
}

plan.Sandbox = newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 1000, GID: 1000, DockerUser: "1000:1000"})
if err := validateLifecycleRuntimeHostSourcesV1(policy, plan, "check"); err != nil {
t.Fatal(err)
}
}

func TestValidateLifecycleRuntimeHostSourcesV1ChecksHostKind(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "config")
if err := os.WriteFile(file, []byte("value=true\n"), 0o600); err != nil {
t.Fatal(err)
}
plan := DockerExecutionPlan{
Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 1000, GID: 1000, DockerUser: "1000:1000"}),
Mounts: []MountExecutionPlan{{
Name: "config", Mode: blueprint.MountBind, Source: file,
SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/mnt/config", ReadOnly: true,
}},
}
policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{
Destination: "/mnt/config", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true,
}})
policy.Plans[0].ID = "command/check"
if err := validateLifecycleRuntimeHostSourcesV1(policy, plan, "check"); err == nil || !strings.Contains(err.Error(), "not a directory") {
t.Fatalf("lifecycle host kind error = %v", err)
}
}
3 changes: 3 additions & 0 deletions internal/dockerdeploy/one_shot_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func prepareOneShotOutputWithBackend(
if outputDir == "" && outputFile == "" {
return &oneShotOutputSession{}, nil
}
if runtimeUser.UID == 0 {
return nil, fmt.Errorf("root application runtime cannot use --output-dir or --output-file until the root-safe output contract is implemented")
}
if backend.currentUID == nil || backend.currentGID == nil || backend.chown == nil {
return nil, fmt.Errorf("prepare one-shot output requires a complete ownership backend")
}
Expand Down
17 changes: 16 additions & 1 deletion internal/dockerdeploy/one_shot_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import (

func currentOutputRuntimeUser() RuntimeUserPlan {
backend := oneShotOutputOwnershipBackend()
return RuntimeUserPlan{UID: backend.currentUID(), GID: backend.currentGID()}
uid, gid := backend.currentUID(), backend.currentGID()
if uid == 0 {
uid, gid = 1, 1
}
return RuntimeUserPlan{UID: uid, GID: gid}
}

func TestOneShotOutputDirectoryIsDirectAndPersistent(t *testing.T) {
Expand All @@ -32,6 +36,17 @@ func TestOneShotOutputDirectoryIsDirectAndPersistent(t *testing.T) {
}
}

func TestOneShotOutputRejectsRootBeforePreparingHostPaths(t *testing.T) {
root := t.TempDir()
destination := filepath.Join(root, "not-created")
if _, err := prepareOneShotOutput(destination, "", RuntimeUserPlan{UID: 0, GID: 0}); err == nil || !strings.Contains(err.Error(), "root-safe output contract") {
t.Fatalf("root output error = %v", err)
}
if _, err := os.Stat(destination); !os.IsNotExist(err) {
t.Fatalf("root output destination was mutated: %v", err)
}
}

func TestOneShotOutputFilePublishesCompleteFile(t *testing.T) {
final := filepath.Join(t.TempDir(), "report.json")
session, err := prepareOneShotOutput("", final, currentOutputRuntimeUser())
Expand Down
3 changes: 1 addition & 2 deletions internal/dockerdeploy/provider_install_lifecycle_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ func providerInstallLifecycleExecutorV1(locked lockedProviderInstallV1) Lifecycl
DockerPreflightTimeout: locked.Input.RunOptions.DockerPreflightTimeout,
},
locked.Plan.Docker,
locked.DestinationStore,
locked.InstallBuild.Platform,
locked.InstallBuild.RuntimePolicy,
locked.Input.RunOptions.Stdout,
locked.Input.RunOptions.Stderr,
)
Expand Down
3 changes: 3 additions & 0 deletions internal/dockerdeploy/provider_install_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ func runProviderInstallV1(
if plan.Installation.Status != deploy.InstallationStatusReady {
return deploy.StateV1{}, fmt.Errorf("provider installation plan must describe a ready installation")
}
if err := ValidateRootRuntimeHostAuthorityV1(sourceBuild.Lock.RuntimePolicy, plan.Docker); err != nil {
return deploy.StateV1{}, fmt.Errorf("validate installed root host authority: %w", err)
}
installBuild, err := backend.buildInstallRuntime(ctx, sourceStore, sourceBuild, plan.Docker, input.RunOptions)
if err != nil {
return deploy.StateV1{}, err
Expand Down
84 changes: 84 additions & 0 deletions internal/dockerdeploy/provider_install_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,90 @@ func TestRunProviderInstallV1RejectsServiceRenameBeforeDestinationPreparation(t
}
}

func TestRunProviderInstallV1RejectsRootHostBindBeforeRuntimeBuildOrDestinationPreparation(t *testing.T) {
sourceDir := t.TempDir()
destinationDir := filepath.Join(t.TempDir(), "installed")
hostSource := t.TempDir()
_, build := providerInstallRunBuildFixture(t, sourceDir)
build.Lock.RuntimePolicy = runtimeHostPolicy([]deploy.RuntimeMountV1{{
Destination: "/mnt/config", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true,
}})
build.Lock.RuntimePolicy.Plans[0].ID = runtimeShellPlanID

backend := providerInstallRunBackend{
acquire: deploy.AcquireOperationLock,
release: func(lock *deploy.OperationLock) error { return lock.Unlock() },
newStore: providerstore.NewStore,
buildSource: func(context.Context, LockedProviderBuildRunInputV1) (LockedProviderBuildExecutionResultV1, error) {
return build, nil
},
prepareAccount: providerInstallRunPrepareAccountFixture,
newReferences: func(string, string) (EnvironmentImageReferences, error) {
return fixedPublicationReferences(t, destinationDir, 0x84), nil
},
planInstallation: func(_ context.Context, input providerInstallPlanningV1) (providerInstallationPlanV1, error) {
plan := providerInstallRunPlanFixture(destinationDir, input.References)
plan.Docker.Sandbox = testApplicationSandboxPlanV1(0, 0)
plan.Docker.Mounts = []MountExecutionPlan{{
Name: "config", Mode: blueprint.MountBind, Source: hostSource,
SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/mnt/config", ReadOnly: true,
}}
return plan, nil
},
buildInstallRuntime: func(context.Context, providerstore.Store, CurrentBuild, DockerExecutionPlan, RunOptions) (installedRuntimeIdentityBuildV1, error) {
t.Fatal("built an installed runtime after rejecting root host authority")
return installedRuntimeIdentityBuildV1{}, nil
},
inspectHostTools: func(context.Context, installBackend) (providerInstallHostToolsV1, error) {
t.Fatal("inspected host tools after rejecting root host authority")
return providerInstallHostToolsV1{}, nil
},
preflightDestination: func(providerstore.Store, CurrentBuild, string) error {
t.Fatal("preflighted destination after rejecting root host authority")
return nil
},
ensureDestination: func(string) (bool, error) {
t.Fatal("created destination after rejecting root host authority")
return false, nil
},
cleanupDestination: func(string) error { return nil },
prepareDestination: func(context.Context, lockedProviderInstallV1) (preparedProviderInstallFilesV1, error) {
t.Fatal("prepared destination after rejecting root host authority")
return preparedProviderInstallFilesV1{}, nil
},
publish: func(context.Context, *deploy.OperationLock, *deploy.OperationLock, providerstore.Store, providerstore.Store, InstalledBuildPublicationInputV1) (deploy.StateV1, error) {
t.Fatal("published destination after rejecting root host authority")
return deploy.StateV1{}, nil
},
publishFiles: func(preparedProviderInstallFilesV1) error {
t.Fatal("published files after rejecting root host authority")
return nil
},
activateDestination: func(context.Context, lockedProviderInstallV1, deploy.StateV1) error {
t.Fatal("activated destination after rejecting root host authority")
return nil
},
markReady: func(*deploy.OperationLock, deploy.InstallationStateV1) (deploy.StateV1, bool, error) {
t.Fatal("marked destination ready after rejecting root host authority")
return deploy.StateV1{}, false, nil
},
startDestination: func(context.Context, lockedProviderInstallV1, deploy.StateV1) error {
t.Fatal("started destination after rejecting root host authority")
return nil
},
}

_, err := runProviderInstallV1(t.Context(), providerInstallRunInputV1{
SourceDeploymentDir: sourceDir, DestinationDeploymentDir: destinationDir,
}, providerInstallAdmissionTestBackend(backend, nil))
if err == nil || !strings.Contains(err.Error(), "root application runtime") {
t.Fatalf("root host-bind install error = %v", err)
}
if _, err := os.Lstat(destinationDir); !os.IsNotExist(err) {
t.Fatalf("root host-bind rejection mutated destination: %v", err)
}
}

func providerInstallRunBuildFixture(t *testing.T, sourceDir string) (deploy.StateV1, LockedProviderBuildExecutionResultV1) {
t.Helper()
operation, _, current := installedBuildPublicationSourceFixtureAtDir(t, sourceDir)
Expand Down
Loading
Loading