Merge pull request #53377 from vvoland/checkpoint-id-sanitize

daemon: Prevent checkpoint ID path traversal
This commit is contained in:
Sebastiaan van Stijn authored and GitHub committed 2026-08-21 14:05:46 +02:00
commit f4cf04858e
37 files changed
+323 -28

No files matched your search

+1
View File
@@ -1095,6 +1095,7 @@ Start the container `id`
- **204** – no error
- **304** – container already started
- **400** – bad parameter, including an invalid checkpoint ID
- **404** – no such container
- **500** – server error
+4
View File
@@ -3596,6 +3596,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -3601,6 +3601,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -3675,6 +3675,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -3766,6 +3766,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -3799,6 +3799,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -4024,6 +4024,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -4097,6 +4097,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -5304,6 +5304,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -5308,6 +5308,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -5336,6 +5336,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -5345,6 +5345,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -5375,6 +5375,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -5395,6 +5395,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -5446,6 +5446,10 @@ paths:
description: "container already started"
schema:
$ref: "#/definitions/ErrorResponse"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -6737,6 +6737,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -7052,6 +7052,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -7341,6 +7341,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -7556,6 +7556,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -7574,6 +7574,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -7714,6 +7714,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -7700,6 +7700,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -7821,6 +7821,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -7957,6 +7957,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -8513,6 +8513,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -8513,6 +8513,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -8343,6 +8343,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -8364,6 +8364,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -8653,6 +8653,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -8901,6 +8901,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -8908,6 +8908,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -8927,6 +8927,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+4
View File
@@ -8938,6 +8938,10 @@ paths:
description: "no error"
304:
description: "container already started"
400:
description: "bad parameter, including an invalid checkpoint ID"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "no such container"
schema:
+59 -23
View File
@@ -2,30 +2,37 @@ package daemon
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/moby/moby/api/types/checkpoint"
"github.com/moby/moby/api/types/events"
"github.com/moby/moby/v2/daemon/names"
"github.com/moby/moby/v2/daemon/server/backend"
"github.com/moby/moby/v2/errdefs"
)
var (
validCheckpointNameChars = names.RestrictedNameChars
validCheckpointNamePattern = names.RestrictedNamePattern
)
// getCheckpointDir verifies checkpoint directory for create,remove, list options and checks if checkpoint already exists
func getCheckpointDir(checkDir, checkpointID, ctrName, ctrID, ctrCheckpointDir string, create bool) (string, error) {
var checkpointDir string
var err2 error
if checkDir != "" {
checkpointDir = checkDir
} else {
checkpointDir = ctrCheckpointDir
func validateCheckpointID(checkpointID string) error {
if !names.RestrictedNamePattern.MatchString(checkpointID) || strings.HasSuffix(checkpointID, ".") {
return errdefs.InvalidParameter(fmt.Errorf("invalid checkpoint ID %q, only %s are allowed", checkpointID, names.RestrictedNameChars))
}
return nil
}
func checkpointRoot(checkDir, ctrCheckpointDir string) string {
if checkDir != "" {
return checkDir
}
return ctrCheckpointDir
}
// getCheckpointDir resolves and verifies a named checkpoint directory.
func getCheckpointDir(checkDir, checkpointID, ctrName, ctrID, ctrCheckpointDir string, create bool) (string, error) {
checkpointDir := checkpointRoot(checkDir, ctrCheckpointDir)
var err2 error
checkpointAbsDir := filepath.Join(checkpointDir, checkpointID)
stat, err := os.Stat(checkpointAbsDir)
if create {
@@ -52,8 +59,32 @@ func getCheckpointDir(checkDir, checkpointID, ctrName, ctrID, ctrCheckpointDir s
return checkpointAbsDir, err2
}
// getCheckpointRoot resolves the checkpoint root used by CheckpointList.
func getCheckpointRoot(checkDir, ctrName, ctrCheckpointDir string) (string, error) {
checkpointDir := checkpointRoot(checkDir, ctrCheckpointDir)
// The API intentionally supports caller-selected checkpoint roots.
stat, err := os.Stat(checkpointDir)
if err != nil {
if checkDir != "" && errors.Is(err, os.ErrNotExist) {
return checkpointDir, errdefs.NotFound(fmt.Errorf("checkpoint directory %q does not exist for container %s: %w", checkpointDir, ctrName, err))
}
return checkpointDir, fmt.Errorf("failed to stat checkpoint directory %q for container %s: %w", checkpointDir, ctrName, err)
}
if !stat.IsDir() {
if checkDir != "" {
return checkpointDir, errdefs.InvalidParameter(fmt.Errorf("checkpoint directory %q exists but is not a directory", checkpointDir))
}
return checkpointDir, fmt.Errorf("%s exists and is not a directory", checkpointDir)
}
return checkpointDir, nil
}
// CheckpointCreate checkpoints the process running in a container with CRIU
func (daemon *Daemon) CheckpointCreate(name string, config checkpoint.CreateRequest) error {
if err := validateCheckpointID(config.CheckpointID); err != nil {
return err
}
container, err := daemon.GetContainer(name)
if err != nil {
return err
@@ -66,13 +97,9 @@ func (daemon *Daemon) CheckpointCreate(name string, config checkpoint.CreateRequ
return err
}
if !validCheckpointNamePattern.MatchString(config.CheckpointID) {
return fmt.Errorf("Invalid checkpoint ID (%s), only %s are allowed", config.CheckpointID, validCheckpointNameChars)
}
checkpointDir, err := getCheckpointDir(config.CheckpointDir, config.CheckpointID, name, container.ID, container.CheckpointDir(), true)
if err != nil {
return fmt.Errorf("cannot checkpoint container %s: %s", name, err)
return fmt.Errorf("cannot checkpoint container %s: %w", name, err)
}
err = tsk.CreateCheckpoint(context.Background(), checkpointDir, config.Exit)
@@ -88,6 +115,10 @@ func (daemon *Daemon) CheckpointCreate(name string, config checkpoint.CreateRequ
// CheckpointDelete deletes the specified checkpoint
func (daemon *Daemon) CheckpointDelete(name string, config backend.CheckpointDeleteOptions) error {
if err := validateCheckpointID(config.CheckpointID); err != nil {
return err
}
container, err := daemon.GetContainer(name)
if err != nil {
return err
@@ -108,17 +139,22 @@ func (daemon *Daemon) CheckpointList(name string, config backend.CheckpointListO
return nil, err
}
checkpointDir, err := getCheckpointDir(config.CheckpointDir, "", name, container.ID, container.CheckpointDir(), false)
checkpointDir, err := getCheckpointRoot(config.CheckpointDir, name, container.CheckpointDir())
if err != nil {
return nil, err
}
if err := os.MkdirAll(checkpointDir, 0o755); err != nil {
if config.CheckpointDir == "" && errors.Is(err, os.ErrNotExist) {
return out, nil
}
return nil, err
}
dirs, err := os.ReadDir(checkpointDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if config.CheckpointDir == "" {
return out, nil
}
return nil, errdefs.NotFound(fmt.Errorf("checkpoint directory %q does not exist for container %s: %w", checkpointDir, name, err))
}
return nil, err
}
+106
View File
@@ -0,0 +1,106 @@
package daemon
import (
"context"
"path/filepath"
"runtime"
"testing"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/checkpoint"
"github.com/moby/moby/v2/daemon/container"
"github.com/moby/moby/v2/daemon/server/backend"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestValidateCheckpointID(t *testing.T) {
type testCase struct {
name string
checkpointID string
valid bool
}
tests := []testCase{
{name: "restricted name", checkpointID: "a1", valid: true},
{name: "space", checkpointID: "space name"},
{name: "unicode", checkpointID: "unicode-世界"},
{name: "dots", checkpointID: "name.with-dots", valid: true},
{name: "empty", checkpointID: ""},
{name: "dot", checkpointID: "."},
{name: "dot-dot", checkpointID: ".."},
{name: "traversal", checkpointID: "../outside"},
{name: "nested traversal", checkpointID: "foo/../outside"},
{name: "separator", checkpointID: "foo/bar"},
{name: "windows separator", checkpointID: `foo\bar`},
{name: "NUL", checkpointID: "contains\x00nul"},
{name: "absolute path", checkpointID: filepath.Join(string(filepath.Separator), "absolute")},
}
if runtime.GOOS == "windows" {
tests = append(tests,
testCase{name: "dot trailing space", checkpointID: ". "},
testCase{name: "dot-dot trailing space", checkpointID: ".. "},
testCase{name: "trailing dot", checkpointID: "name."},
testCase{name: "trailing space", checkpointID: "name "},
)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateCheckpointID(tc.checkpointID)
if tc.valid {
assert.NilError(t, err)
return
}
assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
})
}
}
func TestCheckpointOperationsValidateBeforeContainerLookup(t *testing.T) {
daemon := &Daemon{}
tests := []struct {
name string
operation func() error
}{
{
name: "create",
operation: func() error {
return daemon.CheckpointCreate("missing", checkpoint.CreateRequest{CheckpointID: "../outside"})
},
},
{
name: "delete",
operation: func() error {
return daemon.CheckpointDelete("missing", backend.CheckpointDeleteOptions{CheckpointID: "../outside"})
},
},
{
name: "restore",
operation: func() error {
return daemon.ContainerStart(context.Background(), "missing", "../outside", "")
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.operation()
assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
assert.Check(t, is.ErrorContains(err, `invalid checkpoint ID "../outside"`))
})
}
}
func TestCheckpointListMissingDefaultDirectory(t *testing.T) {
t.Parallel()
root := t.TempDir()
ctr := container.NewBaseContainer("container-id", root)
store := container.NewMemoryStore()
store.Add(ctr.ID, ctr)
daemon := &Daemon{containers: store}
checkpoints, err := daemon.CheckpointList(ctr.ID, backend.CheckpointListOptions{})
assert.NilError(t, err)
assert.Assert(t, is.Len(checkpoints, 0))
}
+11 -5
View File
@@ -47,6 +47,12 @@ func validateState(ctr *container.Container) error {
// ContainerStart starts a container.
func (daemon *Daemon) ContainerStart(ctx context.Context, name string, checkpoint string, checkpointDir string) error {
if checkpoint != "" {
if err := validateCheckpointID(checkpoint); err != nil {
return err
}
}
daemonCfg := daemon.config()
if checkpoint != "" && !daemonCfg.Experimental {
return errdefs.InvalidParameter(errors.New("checkpoint is only supported in experimental mode"))
@@ -84,6 +90,11 @@ func (daemon *Daemon) containerStart(ctx context.Context, daemonCfg *configStore
span.End()
}()
if checkpointDir != "" {
// TODO(mlaventure): how would we support that?
return errdefs.Forbidden(errors.New("custom checkpointdir is not supported"))
}
start := time.Now()
container.Lock()
defer container.Unlock()
@@ -96,11 +107,6 @@ func (daemon *Daemon) containerStart(ctx context.Context, daemonCfg *configStore
return errdefs.Conflict(errors.New("container is marked for removal and cannot be started"))
}
if checkpointDir != "" {
// TODO(mlaventure): how would we support that?
return errdefs.Forbidden(errors.New("custom checkpointdir is not supported"))
}
// if we encounter an error during start we need to ensure that any other
// setup has been cleaned up properly
defer func() {
+18
View File
@@ -1,11 +1,16 @@
package container
import (
"encoding/json"
"net/http"
"net/url"
"os/exec"
"regexp"
"sort"
"testing"
"github.com/moby/moby/api/types/common"
containertypes "github.com/moby/moby/api/types/container"
mounttypes "github.com/moby/moby/api/types/mount"
"github.com/moby/moby/client"
@@ -17,6 +22,19 @@ import (
"gotest.tools/v3/skip"
)
func TestCheckpointInvalidID(t *testing.T) {
ctx := setupTest(t)
query := url.Values{"checkpoint": {"../outside"}}
res, body, err := request.Post(ctx, "/containers/missing/start?"+query.Encode())
assert.NilError(t, err)
defer body.Close()
assert.Equal(t, res.StatusCode, http.StatusBadRequest)
var errorResponse common.ErrorResponse
assert.NilError(t, json.NewDecoder(body).Decode(&errorResponse))
assert.Check(t, is.ErrorContains(errorResponse, `invalid checkpoint ID "../outside"`))
}
func TestCheckpoint(t *testing.T) {
t.Skip("TestCheckpoint is broken; see https://github.com/moby/moby/issues/38963")
skip.If(t, testEnv.DaemonInfo.OSType == "windows")