Skip to content

Commit

Permalink
artifact: protect against unbounded artifact decompression
Browse files Browse the repository at this point in the history
This PR applies mitigation enabled by go-getter against payloads which
decompress into an unbounded size.

Nomad will now fail an artifact download if the decompressed size of the
payload exceeds the value of the group's ephemeral_disk.size (default 300MiB).
  • Loading branch information
shoenig committed Feb 10, 2023
1 parent 2536c6f commit 1d7fd37
Show file tree
Hide file tree
Showing 9 changed files with 331 additions and 80 deletions.
3 changes: 3 additions & 0 deletions .changelog/16126.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:security
artifact: protect against unbounded artifact decompression
```
12 changes: 7 additions & 5 deletions client/allocrunner/taskrunner/artifact_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ import (

// artifactHook downloads artifacts for a task.
type artifactHook struct {
eventEmitter ti.EventEmitter
logger log.Logger
getter ci.ArtifactGetter
eventEmitter ti.EventEmitter
logger log.Logger
getter ci.ArtifactGetter
diskSizeBytes int64
}

func newArtifactHook(e ti.EventEmitter, getter ci.ArtifactGetter, logger log.Logger) *artifactHook {
func newArtifactHook(e ti.EventEmitter, getter ci.ArtifactGetter, diskSizeBytes int64, logger log.Logger) *artifactHook {
h := &artifactHook{
eventEmitter: e,
getter: getter,
diskSizeBytes: diskSizeBytes,
}
h.logger = logger.Named(h.Name())
return h
Expand All @@ -42,7 +44,7 @@ func (h *artifactHook) doWork(req *interfaces.TaskPrestartRequest, resp *interfa

h.logger.Debug("downloading artifact", "artifact", artifact.GetterSource, "aid", aid)
//XXX add ctx to GetArtifact to allow cancelling long downloads
if err := h.getter.GetArtifact(req.TaskEnv, artifact); err != nil {
if err := h.getter.GetArtifact(req.TaskEnv, artifact, h.diskSizeBytes); err != nil {

wrapped := structs.NewRecoverableError(
fmt.Errorf("failed to download artifact %q: %v", artifact.GetterSource, err),
Expand Down
14 changes: 10 additions & 4 deletions client/allocrunner/taskrunner/getter/getter.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/hashicorp/go-cleanhttp"
"github.com/hashicorp/go-getter"
gg "github.com/hashicorp/go-getter"
"github.com/hashicorp/go-hclog"

Expand Down Expand Up @@ -47,7 +48,7 @@ func NewGetter(logger hclog.Logger, config *config.ArtifactConfig) *Getter {
}

// GetArtifact downloads an artifact into the specified task directory.
func (g *Getter) GetArtifact(taskEnv interfaces.EnvReplacer, artifact *structs.TaskArtifact) (returnErr error) {
func (g *Getter) GetArtifact(taskEnv interfaces.EnvReplacer, artifact *structs.TaskArtifact, fileSizeLimit int64) (returnErr error) {
// Recover from panics to avoid crashing the entire Nomad client due to
// artifact download failures, such as bugs in go-getter.
defer func() {
Expand Down Expand Up @@ -83,24 +84,29 @@ func (g *Getter) GetArtifact(taskEnv interfaces.EnvReplacer, artifact *structs.T
}

headers := getHeaders(taskEnv, artifact.GetterHeaders)
if err := g.getClient(ggURL, headers, mode, dest).Get(); err != nil {

if err := g.getClient(ggURL, headers, mode, dest, fileSizeLimit).Get(); err != nil {
return newGetError(ggURL, err, true)
}

return nil
}

// getClient returns a client that is suitable for Nomad downloading artifacts.
func (g *Getter) getClient(src string, headers http.Header, mode gg.ClientMode, dst string) *gg.Client {
func (g *Getter) getClient(src string, headers http.Header, mode gg.ClientMode, dst string, fileSizeLimit int64) *gg.Client {
const maxFilesLimit = 0 // no limit
return &gg.Client{
Src: src,
Dst: dst,
Mode: mode,
Umask: 060000000,
Getters: g.createGetters(headers),

// This will prevent copying or writing files through symlinks
// This will prevent copying or writing files through symlinks.
DisableSymlinks: true,

// This will protect against decompression bombs.
Decompressors: getter.LimitedDecompressors(maxFilesLimit, fileSizeLimit),
}
}

Expand Down
32 changes: 22 additions & 10 deletions client/allocrunner/taskrunner/getter/getter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import (
"github.com/stretchr/testify/require"
)

const (
oneMegabyte = 1 * 1024 * 1024
)

// noopReplacer is a noop version of taskenv.TaskEnv.ReplaceEnv.
type noopReplacer struct {
taskDir string
Expand Down Expand Up @@ -94,12 +98,20 @@ func TestGetter_getClient(t *testing.T) {
HgTimeout: 3 * time.Minute,
S3Timeout: 4 * time.Minute,
})
client := getter.getClient("src", nil, gg.ClientModeAny, "dst")

const fileSizeLimit = 9999
client := getter.getClient("src", nil, gg.ClientModeAny, "dst", fileSizeLimit)

t.Run("check symlink config", func(t *testing.T) {
require.True(t, client.DisableSymlinks)
})

t.Run("check file size limits", func(t *testing.T) {
require.Equal(t, int64(fileSizeLimit), client.Decompressors["zip"].(*gg.ZipDecompressor).FileSizeLimit)
require.Equal(t, int64(fileSizeLimit), client.Decompressors["tar.gz"].(*gg.TarGzipDecompressor).FileSizeLimit)
require.Equal(t, int64(fileSizeLimit), client.Decompressors["xz"].(*gg.XzDecompressor).FileSizeLimit)
})

t.Run("check http config", func(t *testing.T) {
require.True(t, client.Getters["http"].(*gg.HttpGetter).XTerraformGetDisabled)
require.Equal(t, time.Minute, client.Getters["http"].(*gg.HttpGetter).ReadTimeout)
Expand Down Expand Up @@ -151,7 +163,7 @@ func TestGetArtifact_getHeaders(t *testing.T) {
}

func TestGetArtifact_Headers(t *testing.T) {
file := "output.txt"
const file = "output.txt"

// Create the test server with a handler that will validate headers are set.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -186,7 +198,7 @@ func TestGetArtifact_Headers(t *testing.T) {
taskDir: taskDir,
}

err := getter.GetArtifact(taskEnv, artifact)
err := getter.GetArtifact(taskEnv, artifact, oneMegabyte)
require.NoError(t, err)

// Verify artifact exists.
Expand Down Expand Up @@ -217,7 +229,7 @@ func TestGetArtifact_FileAndChecksum(t *testing.T) {

// Download the artifact
getter := TestDefaultGetter(t)
if err := getter.GetArtifact(noopTaskEnv(taskDir), artifact); err != nil {
if err := getter.GetArtifact(noopTaskEnv(taskDir), artifact, oneMegabyte); err != nil {
t.Fatalf("GetArtifact failed: %v", err)
}

Expand Down Expand Up @@ -248,7 +260,7 @@ func TestGetArtifact_File_RelativeDest(t *testing.T) {

// Download the artifact
getter := TestDefaultGetter(t)
if err := getter.GetArtifact(noopTaskEnv(taskDir), artifact); err != nil {
if err := getter.GetArtifact(noopTaskEnv(taskDir), artifact, oneMegabyte); err != nil {
t.Fatalf("GetArtifact failed: %v", err)
}

Expand Down Expand Up @@ -279,7 +291,7 @@ func TestGetArtifact_File_EscapeDest(t *testing.T) {

// attempt to download the artifact
getter := TestDefaultGetter(t)
err := getter.GetArtifact(noopTaskEnv(taskDir), artifact)
err := getter.GetArtifact(noopTaskEnv(taskDir), artifact, oneMegabyte)
if err == nil || !strings.Contains(err.Error(), "escapes") {
t.Fatalf("expected GetArtifact to disallow sandbox escape: %v", err)
}
Expand Down Expand Up @@ -326,7 +338,7 @@ func TestGetArtifact_InvalidChecksum(t *testing.T) {

// Download the artifact and expect an error
getter := TestDefaultGetter(t)
if err := getter.GetArtifact(noopTaskEnv(taskDir), artifact); err == nil {
if err := getter.GetArtifact(noopTaskEnv(taskDir), artifact, oneMegabyte); err == nil {
t.Fatalf("GetArtifact should have failed")
}
}
Expand Down Expand Up @@ -388,7 +400,7 @@ func TestGetArtifact_Archive(t *testing.T) {
}

getter := TestDefaultGetter(t)
if err := getter.GetArtifact(noopTaskEnv(taskDir), artifact); err != nil {
if err := getter.GetArtifact(noopTaskEnv(taskDir), artifact, oneMegabyte); err != nil {
t.Fatalf("GetArtifact failed: %v", err)
}

Expand Down Expand Up @@ -420,7 +432,7 @@ func TestGetArtifact_Setuid(t *testing.T) {
}

getter := TestDefaultGetter(t)
require.NoError(t, getter.GetArtifact(noopTaskEnv(taskDir), artifact))
require.NoError(t, getter.GetArtifact(noopTaskEnv(taskDir), artifact, oneMegabyte))

var expected map[string]int

Expand Down Expand Up @@ -454,7 +466,7 @@ func TestGetArtifact_Setuid(t *testing.T) {
// does not cause its goroutine to crash.
func TestGetArtifact_handlePanic(t *testing.T) {
getter := TestDefaultGetter(t)
err := getter.GetArtifact(panicTaskEnv(), &structs.TaskArtifact{})
err := getter.GetArtifact(panicTaskEnv(), &structs.TaskArtifact{}, oneMegabyte)
require.Error(t, err)
require.Contains(t, err.Error(), "panic")
}
Expand Down
3 changes: 2 additions & 1 deletion client/allocrunner/taskrunner/task_runner_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,15 @@ func (tr *TaskRunner) initHooks() {
// Create the task directory hook. This is run first to ensure the
// directory path exists for other hooks.
alloc := tr.Alloc()

tr.runnerHooks = []interfaces.TaskHook{
newValidateHook(tr.clientConfig, hookLogger),
newTaskDirHook(tr, hookLogger),
newIdentityHook(tr, hookLogger),
newLogMonHook(tr, hookLogger),
newDispatchHook(alloc, hookLogger),
newVolumeHook(tr, hookLogger),
newArtifactHook(tr, tr.getter, hookLogger),
newArtifactHook(tr, tr.getter, alloc.SharedDiskBytes(), hookLogger),
newStatsHook(tr, tr.clientConfig.StatsCollectionInterval, hookLogger),
newDeviceHook(tr.devicemanager, hookLogger),
}
Expand Down
2 changes: 1 addition & 1 deletion client/interfaces/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ type EnvReplacer interface {

// ArtifactGetter is an interface satisfied by the helper/getter package.
type ArtifactGetter interface {
GetArtifact(taskEnv EnvReplacer, artifact *structs.TaskArtifact) error
GetArtifact(taskEnv EnvReplacer, artifact *structs.TaskArtifact, maxFileSize int64) error
}
28 changes: 13 additions & 15 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ require (
// versions.
github.com/hashicorp/go-discover v0.0.0-20220621183603-a413e131e836
github.com/hashicorp/go-envparse v0.0.0-20180119215841-310ca1881b22
github.com/hashicorp/go-getter v1.6.2
github.com/hashicorp/go-getter v1.6.3-0.20230210143508-0edab8534827
github.com/hashicorp/go-hclog v1.3.1
github.com/hashicorp/go-immutable-radix v1.3.1
github.com/hashicorp/go-kms-wrapping/v2 v2.0.5
Expand Down Expand Up @@ -120,7 +120,7 @@ require (
go.uber.org/goleak v1.2.0
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
golang.org/x/exp v0.0.0-20220921164117-439092de6870
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
golang.org/x/sys v0.2.0
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
google.golang.org/grpc v1.51.0
Expand All @@ -131,8 +131,10 @@ require (
)

require (
cloud.google.com/go v0.97.0 // indirect
cloud.google.com/go/storage v1.18.2 // indirect
cloud.google.com/go v0.104.0 // indirect
cloud.google.com/go/compute v1.10.0 // indirect
cloud.google.com/go/iam v0.5.0 // indirect
cloud.google.com/go/storage v1.27.0 // indirect
github.com/Azure/azure-sdk-for-go v56.3.0+incompatible // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
Expand Down Expand Up @@ -165,15 +167,12 @@ require (
github.com/boltdb/bolt v1.3.1 // indirect
github.com/brianvoe/gofakeit/v6 v6.19.0
github.com/cenkalti/backoff/v3 v3.2.2 // indirect
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/checkpoint-restore/go-criu/v5 v5.3.0 // indirect
github.com/cheggaaa/pb/v3 v3.0.5 // indirect
github.com/cilium/ebpf v0.9.1 // indirect
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible // indirect
github.com/circonus-labs/circonusllhist v0.1.3 // indirect
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect
github.com/containerd/cgroups v1.0.3 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/containerd/containerd v1.6.6 // indirect
Expand All @@ -187,8 +186,6 @@ require (
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1 // indirect
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
Expand All @@ -199,7 +196,8 @@ require (
github.com/google/btree v1.0.0 // indirect
github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect
github.com/googleapis/gax-go/v2 v2.6.0 // indirect
github.com/gookit/color v1.3.1 // indirect
github.com/gophercloud/gophercloud v0.1.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
Expand All @@ -220,7 +218,7 @@ require (
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/joyent/triton-go v0.0.0-20190112182421-51ffac552869 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/klauspost/compress v1.15.11 // indirect
github.com/linode/linodego v0.7.1 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
Expand Down Expand Up @@ -269,14 +267,14 @@ require (
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.1.0 // indirect
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
golang.org/x/oauth2 v0.1.0 // indirect
golang.org/x/term v0.1.0 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/tools v0.1.12 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/api v0.60.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.100.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 // indirect
google.golang.org/genproto v0.0.0-20221025140454-527a21cfbd71 // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/resty.v1 v1.12.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
Expand Down
Loading

0 comments on commit 1d7fd37

Please sign in to comment.