From 77ae94d710840e721b1474db2f716a4ec70487d4 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Wed, 8 Apr 2026 13:54:53 +0100 Subject: [PATCH] fix: verify blob digests and sandbox Python backends Addresses two security vulnerabilities reported against Model Runner: - Blob digest verification: after downloading each layer, hash the complete file and reject it if the SHA256 does not match the digest declared in the manifest. A malicious registry could previously serve arbitrary bytes under any digest name and they would be stored without error. The full file is hashed (not just the streamed bytes) so that resumed downloads are also verified correctly. - Python backend sandboxing: add ConfigurationPython to the sandbox package and apply it to all Python-based inference backends (vLLM, vllm-metal, SGLang, MLX, Diffusers). On Darwin this uses sandbox-exec with a deny-by-default seatbelt profile matching the existing llama.cpp policy; on Windows the same job object limits are applied. Previously these backends ran completely unsandboxed, meaning attacker-controlled model files could execute arbitrary code on the host. --- pkg/distribution/internal/store/blobs.go | 31 ++++++++ pkg/distribution/internal/store/blobs_test.go | 32 ++++++++- pkg/inference/backends/diffusers/diffusers.go | 3 +- pkg/inference/backends/mlx/mlx.go | 3 +- pkg/inference/backends/sglang/sglang.go | 3 +- pkg/inference/backends/vllm/vllm.go | 3 +- pkg/inference/backends/vllm/vllm_metal.go | 5 +- pkg/sandbox/sandbox_darwin.go | 70 +++++++++++++++++++ pkg/sandbox/sandbox_other.go | 4 ++ pkg/sandbox/sandbox_windows.go | 14 ++++ 10 files changed, 160 insertions(+), 8 deletions(-) diff --git a/pkg/distribution/internal/store/blobs.go b/pkg/distribution/internal/store/blobs.go index 61cf28de3..05717e725 100644 --- a/pkg/distribution/internal/store/blobs.go +++ b/pkg/distribution/internal/store/blobs.go @@ -2,6 +2,8 @@ package store import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -268,6 +270,35 @@ func (s *LocalStore) WriteBlobWithResume(diffID oci.Hash, r io.Reader, digestStr f.Close() // Rename will fail on Windows if the file is still open. + // Verify the digest of the completed file before making it visible. This + // must be done after closing the file (to flush all writes) and before the + // rename so that a mismatch never results in a corrupt blob being stored. + // We hash the whole file rather than the streamed bytes so that resumed + // downloads (which append to an existing partial file) are verified + // correctly over their entire contents. + if diffID.Algorithm == "sha256" { + completedFile, openErr := os.Open(incompletePath) + if openErr != nil { + _ = os.Remove(incompletePath) + return fmt.Errorf("open completed blob file for verification: %w", openErr) + } + + hasher := sha256.New() + if _, copyErr := io.Copy(hasher, completedFile); copyErr != nil { + completedFile.Close() + _ = os.Remove(incompletePath) + return fmt.Errorf("hash completed blob file: %w", copyErr) + } + completedFile.Close() + + computed := hex.EncodeToString(hasher.Sum(nil)) + if computed != diffID.Hex { + _ = os.Remove(incompletePath) + return fmt.Errorf("blob digest mismatch for %q: expected sha256:%s, got sha256:%s", + diffID.String(), diffID.Hex, computed) + } + } + if renameFinalErr := os.Rename(incompletePath, path); renameFinalErr != nil { return fmt.Errorf("rename blob file: %w", renameFinalErr) } diff --git a/pkg/distribution/internal/store/blobs_test.go b/pkg/distribution/internal/store/blobs_test.go index d519f3c98..c3ceb047b 100644 --- a/pkg/distribution/internal/store/blobs_test.go +++ b/pkg/distribution/internal/store/blobs_test.go @@ -123,12 +123,40 @@ func TestBlobs(t *testing.T) { } }) - t.Run("WriteBlob reuses existing blob", func(t *testing.T) { - // simulate existing blob + t.Run("WriteBlob rejects blob with wrong digest", func(t *testing.T) { + // Use a well-known sha256 hash (of empty string) but supply different content. hash := oci.Hash{ Algorithm: "sha256", Hex: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", } + blobPath, err := store.blobPath(hash) + if err != nil { + t.Fatalf("error getting blob path: %v", err) + } + // Remove any pre-existing blob so we hit the download path. + _ = os.Remove(blobPath) + _ = os.Remove(incompletePath(blobPath)) + + if err := store.WriteBlob(hash, bytes.NewBufferString("wrong content")); err == nil { + t.Fatal("expected digest mismatch error, got nil") + } + + // The incomplete file must be cleaned up after a digest mismatch. + if _, err := os.Stat(incompletePath(blobPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("expected incomplete file to be removed after digest mismatch") + } + // The final blob must not exist. + if _, err := os.Stat(blobPath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("expected final blob file not to exist after digest mismatch") + } + }) + + t.Run("WriteBlob reuses existing blob", func(t *testing.T) { + // Use the correct hash for "some-data" so digest verification passes. + hash, _, err := oci.SHA256(bytes.NewReader([]byte("some-data"))) + if err != nil { + t.Fatalf("error computing hash: %v", err) + } if err := store.WriteBlob(hash, bytes.NewReader([]byte("some-data"))); err != nil { t.Fatalf("error writing blob: %v", err) diff --git a/pkg/inference/backends/diffusers/diffusers.go b/pkg/inference/backends/diffusers/diffusers.go index a082dc8ed..b87fb0446 100644 --- a/pkg/inference/backends/diffusers/diffusers.go +++ b/pkg/inference/backends/diffusers/diffusers.go @@ -18,6 +18,7 @@ import ( "github.com/docker/model-runner/pkg/internal/dockerhub" "github.com/docker/model-runner/pkg/internal/utils" "github.com/docker/model-runner/pkg/logging" + "github.com/docker/model-runner/pkg/sandbox" ) const ( @@ -255,7 +256,7 @@ func (d *diffusers) Run(ctx context.Context, socket, model string, modelRef stri Socket: socket, BinaryPath: d.pythonPath, SandboxPath: "", - SandboxConfig: "", + SandboxConfig: sandbox.ConfigurationPython, Args: args, Logger: d.log, ServerLogWriter: logging.NewWriter(d.serverLog), diff --git a/pkg/inference/backends/mlx/mlx.go b/pkg/inference/backends/mlx/mlx.go index ebd6c924b..cd9dd8694 100644 --- a/pkg/inference/backends/mlx/mlx.go +++ b/pkg/inference/backends/mlx/mlx.go @@ -13,6 +13,7 @@ import ( "github.com/docker/model-runner/pkg/inference/models" "github.com/docker/model-runner/pkg/inference/platform" "github.com/docker/model-runner/pkg/logging" + "github.com/docker/model-runner/pkg/sandbox" ) const ( @@ -140,7 +141,7 @@ func (m *mlx) Run(ctx context.Context, socket, model string, modelRef string, mo Socket: socket, BinaryPath: m.pythonPath, SandboxPath: "", - SandboxConfig: "", + SandboxConfig: sandbox.ConfigurationPython, Args: args, Logger: m.log, ServerLogWriter: logging.NewWriter(m.serverLog), diff --git a/pkg/inference/backends/sglang/sglang.go b/pkg/inference/backends/sglang/sglang.go index bf02b45d6..2e083ba99 100644 --- a/pkg/inference/backends/sglang/sglang.go +++ b/pkg/inference/backends/sglang/sglang.go @@ -16,6 +16,7 @@ import ( "github.com/docker/model-runner/pkg/inference/models" "github.com/docker/model-runner/pkg/inference/platform" "github.com/docker/model-runner/pkg/logging" + "github.com/docker/model-runner/pkg/sandbox" ) const ( @@ -169,7 +170,7 @@ func (s *sglang) Run(ctx context.Context, socket, model string, modelRef string, Socket: socket, BinaryPath: s.pythonPath, SandboxPath: sandboxPath, - SandboxConfig: "", + SandboxConfig: sandbox.ConfigurationPython, Args: args, Logger: s.log, ServerLogWriter: logging.NewWriter(s.serverLog), diff --git a/pkg/inference/backends/vllm/vllm.go b/pkg/inference/backends/vllm/vllm.go index 53c32c52a..28b7ef117 100644 --- a/pkg/inference/backends/vllm/vllm.go +++ b/pkg/inference/backends/vllm/vllm.go @@ -18,6 +18,7 @@ import ( "github.com/docker/model-runner/pkg/inference/models" "github.com/docker/model-runner/pkg/inference/platform" "github.com/docker/model-runner/pkg/logging" + "github.com/docker/model-runner/pkg/sandbox" ) const ( @@ -180,7 +181,7 @@ func (v *vLLM) Run(ctx context.Context, socket, model string, modelRef string, m Socket: socket, BinaryPath: v.binaryPath(), SandboxPath: vllmDir, - SandboxConfig: "", + SandboxConfig: sandbox.ConfigurationPython, Args: args, Logger: v.log, ServerLogWriter: logging.NewWriter(v.serverLog), diff --git a/pkg/inference/backends/vllm/vllm_metal.go b/pkg/inference/backends/vllm/vllm_metal.go index db1abda95..efc63c423 100644 --- a/pkg/inference/backends/vllm/vllm_metal.go +++ b/pkg/inference/backends/vllm/vllm_metal.go @@ -20,6 +20,7 @@ import ( "github.com/docker/model-runner/pkg/internal/dockerhub" "github.com/docker/model-runner/pkg/internal/utils" "github.com/docker/model-runner/pkg/logging" + "github.com/docker/model-runner/pkg/sandbox" ) const ( @@ -216,8 +217,8 @@ func (v *vllmMetal) Run(ctx context.Context, socket, model string, modelRef stri BackendName: "vllm-metal", Socket: socket, BinaryPath: v.pythonPath, - SandboxPath: "", - SandboxConfig: "", + SandboxPath: v.installDir, + SandboxConfig: sandbox.ConfigurationPython, Args: args, Logger: v.log, ServerLogWriter: logging.NewWriter(v.serverLog), diff --git a/pkg/sandbox/sandbox_darwin.go b/pkg/sandbox/sandbox_darwin.go index e02628d6f..db6dbd861 100644 --- a/pkg/sandbox/sandbox_darwin.go +++ b/pkg/sandbox/sandbox_darwin.go @@ -10,6 +10,76 @@ import ( "strings" ) +// ConfigurationPython is the sandbox configuration for Python-based inference +// backends (vLLM, vllm-metal, SGLang, MLX). It mirrors the llama.cpp profile +// but additionally allows TCP loopback binding (used by vllm-metal and SGLang) +// and read access to the Python runtime install directory supplied via +// [UPDATEDBINPATH]. +const ConfigurationPython = `(version 1) + +;;; Keep a default allow policy (because encoding things like DYLD support and +;;; device access is quite difficult), but deny critical exploitation targets. +;;; Python backends run with the same constraint philosophy as llama.cpp. +(allow default) + +;;; Deny network access except for our IPC sockets. +;;; Python backends use either a Unix socket or a TCP loopback port. +;;; Allow Unix socket paths that match the inference socket naming convention +;;; as well as TCP loopback binding/inbound for backends that use TCP. +(deny network*) +(allow network-bind network-inbound + (regex #"inference.*-[0-9]+\.sock$") + (local tcp "localhost:*")) + +;;; Deny access to the camera and microphone. +(deny device*) + +;;; Deny access to NVRAM settings. +(deny nvram*) + +;;; Deny access to system-level privileges. +(deny system*) + +;;; Deny access to job creation. +(deny job-creation) + +;;; Deny access to launchservicesd to prevent sandbox escape via open(1). +(deny mach-lookup + (global-name "com.apple.launchservicesd") + (global-name "com.apple.coreservices.launchservicesd")) + +;;; Don't allow new executable code to be created in memory at runtime. +(deny dynamic-code-generation) + +;;; Disable access to user preferences. +(deny user-preference*) + +;;; Restrict file access. +(deny file-map-executable) +(deny file-write*) +(deny file-read* + (subpath "/Applications") + (subpath "/private/etc") + (subpath "/Library") + (subpath "/Users") + (subpath "/Volumes")) +(allow file-read* file-map-executable + (subpath "/usr") + (subpath "/System") + (regex #"Docker\.app/Contents/Resources/model-runner") + (subpath "[UPDATEDBINPATH]") + (subpath "[UPDATEDLIBPATH]")) +(allow file-write* + (literal "/dev/null") + (subpath "/private/var") + (subpath "[HOMEDIR]/Library/Containers/com.docker.docker/Data") + (subpath "[WORKDIR]")) +(allow file-read* + (subpath "[HOMEDIR]/.docker/models") + (subpath "[HOMEDIR]/Library/Containers/com.docker.docker/Data") + (subpath "[WORKDIR]")) +` + // ConfigurationLlamaCpp is the sandbox configuration for llama.cpp processes. const ConfigurationLlamaCpp = `(version 1) diff --git a/pkg/sandbox/sandbox_other.go b/pkg/sandbox/sandbox_other.go index 495ad372e..abd1295d8 100644 --- a/pkg/sandbox/sandbox_other.go +++ b/pkg/sandbox/sandbox_other.go @@ -8,6 +8,10 @@ import ( "os/exec" ) +// ConfigurationPython is the sandbox configuration for Python-based inference +// backends. On non-Darwin platforms no sandboxing is applied. +const ConfigurationPython = `` + // ConfigurationLlamaCpp is the sandbox configuration for llama.cpp processes. const ConfigurationLlamaCpp = `` diff --git a/pkg/sandbox/sandbox_windows.go b/pkg/sandbox/sandbox_windows.go index 0304f4fa3..b5e597e3d 100644 --- a/pkg/sandbox/sandbox_windows.go +++ b/pkg/sandbox/sandbox_windows.go @@ -28,6 +28,20 @@ var limitTokenToGenerator = map[string]func() winjob.Limit{ "(WithWriteClipboardLimit)": winjob.WithWriteClipboardLimit, } +// ConfigurationPython is the sandbox configuration for Python-based inference +// backends (vLLM, SGLang, MLX) on Windows. +const ConfigurationPython = `(WithDesktopLimit) +(WithDieOnUnhandledException) +(WithDisplaySettingsLimit) +(WithExitWindowsLimit) +(WithGlobalAtomsLimit) +(WithHandlesLimit) +(WithDisableOutgoingNetworking) +(WithReadClipboardLimit) +(WithSystemParametersLimit) +(WithWriteClipboardLimit) +` + // ConfigurationLlamaCpp is the sandbox configuration for llama.cpp processes. const ConfigurationLlamaCpp = `(WithDesktopLimit) (WithDieOnUnhandledException)