Skip to content

Commit 2be3903

Browse files
authored
Add limit readers for readers that consume data from external sources. (#2042)
This adds a safety layer to tune resource usage for any reads of APKs, APK indexes, or other external data sources. These are all optional flags, that default to a default value if not set.
1 parent aa60fca commit 2be3903

File tree

20 files changed

+814
-25
lines changed

20 files changed

+814
-25
lines changed

internal/cli/build-cpio.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"chainguard.dev/apko/pkg/build"
2929
"chainguard.dev/apko/pkg/build/types"
3030
"chainguard.dev/apko/pkg/cpio"
31+
"chainguard.dev/apko/pkg/options"
3132
)
3233

3334
func buildCPIO() *cobra.Command {
@@ -38,6 +39,7 @@ func buildCPIO() *cobra.Command {
3839
var extraBuildRepos []string
3940
var extraRepos []string
4041
var extraPackages []string
42+
var sizeLimits options.SizeLimits
4143

4244
cmd := &cobra.Command{
4345
Use: "build-cpio",
@@ -56,6 +58,7 @@ func buildCPIO() *cobra.Command {
5658
build.WithBuildDate(buildDate),
5759
build.WithSBOM(sbomPath),
5860
build.WithArch(types.ParseArchitecture(buildArch)),
61+
build.WithSizeLimits(sizeLimits),
5962
)
6063
},
6164
}
@@ -67,6 +70,7 @@ func buildCPIO() *cobra.Command {
6770
cmd.Flags().StringSliceVarP(&extraBuildRepos, "build-repository-append", "b", []string{}, "path to extra repositories to include")
6871
cmd.Flags().StringSliceVarP(&extraRepos, "repository-append", "r", []string{}, "path to extra repositories to include")
6972
cmd.Flags().StringSliceVarP(&extraPackages, "package-append", "p", []string{}, "extra packages to include")
73+
addClientLimitFlags(cmd, &sizeLimits)
7074

7175
return cmd
7276
}

internal/cli/build-minirootfs.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626

2727
"chainguard.dev/apko/pkg/build"
2828
"chainguard.dev/apko/pkg/build/types"
29+
"chainguard.dev/apko/pkg/options"
2930
"chainguard.dev/apko/pkg/tarfs"
3031
)
3132

@@ -38,6 +39,7 @@ func buildMinirootFS() *cobra.Command {
3839
var extraBuildRepos []string
3940
var extraRepos []string
4041
var extraPackages []string
42+
var sizeLimits options.SizeLimits
4143

4244
cmd := &cobra.Command{
4345
Use: "build-minirootfs",
@@ -57,6 +59,7 @@ func buildMinirootFS() *cobra.Command {
5759
build.WithSBOM(sbomPath),
5860
build.WithArch(types.ParseArchitecture(buildArch)),
5961
build.WithIgnoreSignatures(ignoreSignatures),
62+
build.WithSizeLimits(sizeLimits),
6063
)
6164
},
6265
}
@@ -69,6 +72,7 @@ func buildMinirootFS() *cobra.Command {
6972
cmd.Flags().StringSliceVarP(&extraBuildRepos, "build-repository-append", "b", []string{}, "path to extra repositories to include")
7073
cmd.Flags().StringSliceVarP(&extraRepos, "repository-append", "r", []string{}, "path to extra repositories to include")
7174
cmd.Flags().StringSliceVarP(&extraPackages, "package-append", "p", []string{}, "extra packages to include")
75+
addClientLimitFlags(cmd, &sizeLimits)
7276

7377
return cmd
7478
}

internal/cli/build.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"chainguard.dev/apko/pkg/build"
3838
"chainguard.dev/apko/pkg/build/oci"
3939
"chainguard.dev/apko/pkg/build/types"
40+
"chainguard.dev/apko/pkg/options"
4041
"chainguard.dev/apko/pkg/sbom/generator"
4142
"chainguard.dev/apko/pkg/tarfs"
4243
)
@@ -58,6 +59,7 @@ func buildCmd() *cobra.Command {
5859
var lockfile string
5960
var includePaths []string
6061
var ignoreSignatures bool
62+
var sizeLimits options.SizeLimits
6163

6264
cmd := &cobra.Command{
6365
Use: "build",
@@ -116,6 +118,7 @@ Along the image, apko will generate SBOMs (software bill of materials) describin
116118
build.WithTempDir(tmp),
117119
build.WithIncludePaths(includePaths),
118120
build.WithIgnoreSignatures(ignoreSignatures),
121+
build.WithSizeLimits(sizeLimits),
119122
)
120123
},
121124
}
@@ -136,6 +139,7 @@ Along the image, apko will generate SBOMs (software bill of materials) describin
136139
cmd.Flags().StringVar(&lockfile, "lockfile", "", "a path to .lock.json file (e.g. produced by apko lock) that constraints versions of packages to the listed ones (default '' means no additional constraints)")
137140
cmd.Flags().StringSliceVar(&includePaths, "include-paths", []string{}, "Additional include paths where to look for input files (config, base image, etc.). By default apko will search for paths only in workdir. Include paths may be absolute, or relative. Relative paths are interpreted relative to workdir. For adding extra paths for packages, use --repository-append.")
138141
cmd.Flags().BoolVar(&ignoreSignatures, "ignore-signatures", false, "ignore repository signature verification")
142+
addClientLimitFlags(cmd, &sizeLimits)
139143
return cmd
140144
}
141145

internal/cli/flags.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2022, 2023 Chainguard, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cli
16+
17+
import (
18+
"github.com/spf13/cobra"
19+
20+
"chainguard.dev/apko/pkg/options"
21+
)
22+
23+
// addClientLimitFlags adds size limit flags for APK client operations (fetching indexes, expanding packages).
24+
func addClientLimitFlags(cmd *cobra.Command, limits *options.SizeLimits) {
25+
defaults := options.DefaultSizeLimits()
26+
27+
cmd.Flags().Int64Var(&limits.APKIndexDecompressedMaxSize, "max-apkindex-decompressed-size", defaults.APKIndexDecompressedMaxSize,
28+
"maximum decompressed size for APKINDEX archives in bytes, protects against gzip bombs (0=default, -1=no limit)")
29+
cmd.Flags().Int64Var(&limits.APKControlMaxSize, "max-apk-control-size", defaults.APKControlMaxSize,
30+
"maximum decompressed size for APK control sections in bytes (0=default, -1=no limit)")
31+
cmd.Flags().Int64Var(&limits.APKDataMaxSize, "max-apk-data-size", defaults.APKDataMaxSize,
32+
"maximum decompressed size for APK data sections in bytes, protects against gzip bombs (0=default, -1=no limit)")
33+
cmd.Flags().Int64Var(&limits.HTTPResponseMaxSize, "max-http-response-size", defaults.HTTPResponseMaxSize,
34+
"maximum size for HTTP responses in bytes (0=default, -1=no limit)")
35+
}

pkg/apk/apk/apkindex.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,17 @@ import (
1414
"strings"
1515
"text/template"
1616
"time"
17+
18+
"chainguard.dev/apko/pkg/limitio"
1719
)
1820

1921
const apkIndexFilename = "APKINDEX"
2022
const descriptionFilename = "DESCRIPTION"
2123

24+
// DefaultMaxAPKIndexDecompressedSize is the maximum decompressed size for APKINDEX archives (100 MB).
25+
// This protects against gzip bombs where a small compressed file expands to a huge size.
26+
const DefaultMaxAPKIndexDecompressedSize = 100 << 20
27+
2228
// Go template for generating the APKINDEX file from an ApkIndex struct
2329
var apkIndexTemplate = template.Must(template.New(apkIndexFilename).Funcs(
2430
template.FuncMap{
@@ -197,15 +203,39 @@ func ParsePackageIndex(apkIndexUnpacked io.Reader) ([]*Package, error) {
197203
return packages, indexScanner.Err()
198204
}
199205

200-
func IndexFromArchive(archive io.ReadCloser) (*APKIndex, error) {
206+
// IndexFromArchiveOption configures IndexFromArchive behavior.
207+
type IndexFromArchiveOption func(*indexFromArchiveOpts)
208+
209+
type indexFromArchiveOpts struct {
210+
decompressedMaxSize int64
211+
}
212+
213+
// WithDecompressedMaxSize sets the maximum decompressed size for the APKINDEX archive.
214+
// Use 0 for default, or < 0 for unlimited.
215+
func WithDecompressedMaxSize(size int64) IndexFromArchiveOption {
216+
return func(o *indexFromArchiveOpts) {
217+
o.decompressedMaxSize = size
218+
}
219+
}
220+
221+
// IndexFromArchive parses an APKINDEX archive. Options can be used to configure
222+
// size limits to protect against gzip bombs.
223+
func IndexFromArchive(archive io.ReadCloser, opts ...IndexFromArchiveOption) (*APKIndex, error) {
224+
o := &indexFromArchiveOpts{}
225+
for _, opt := range opts {
226+
opt(o)
227+
}
228+
201229
gzipReader, err := gzip.NewReader(archive)
202230
if err != nil {
203231
return nil, err
204232
}
205233

206234
defer gzipReader.Close()
207235

208-
tarReader := tar.NewReader(gzipReader)
236+
// Wrap gzipReader with size limit, then create tar reader on top.
237+
// The limit protects against tar bombs where file headers claim huge sizes.
238+
tarReader := tar.NewReader(limitio.NewLimitedReaderWithDefault(gzipReader, o.decompressedMaxSize, DefaultMaxAPKIndexDecompressedSize))
209239
apkindex := &APKIndex{}
210240

211241
for {

pkg/apk/apk/implementation.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ import (
5353
"github.com/chainguard-dev/clog"
5454
)
5555

56+
const (
57+
// DefaultHTTPResponseSize is the default maximum size for HTTP responses (2 GB).
58+
DefaultHTTPResponseSize = 2 << 30
59+
)
60+
5661
type APK struct {
5762
arch string
5863
version string
@@ -65,6 +70,7 @@ type APK struct {
6570
noSignatureIndexes []string
6671
auth auth.Authenticator
6772
packageGetter PackageGetter
73+
sizeLimits *SizeLimits
6874

6975
// filename to owning package, last write wins
7076
installedFiles map[string]*Package
@@ -74,6 +80,14 @@ type APK struct {
7480
ByArch map[string]*APK
7581
}
7682

83+
// apkIndexDecompressedMaxSize returns the configured max decompressed APK index size or 0 for default.
84+
func (a *APK) apkIndexDecompressedMaxSize() int64 {
85+
if a.sizeLimits != nil && a.sizeLimits.APKIndexDecompressedMaxSize != 0 {
86+
return a.sizeLimits.APKIndexDecompressedMaxSize
87+
}
88+
return 0 // use default
89+
}
90+
7791
func New(ctx context.Context, options ...Option) (*APK, error) {
7892
opt := defaultOpts()
7993
for _, o := range options {
@@ -87,17 +101,33 @@ func New(ctx context.Context, options ...Option) (*APK, error) {
87101
opt.fs = apkfs.DirFS(ctx, "/")
88102
}
89103

90-
client := retryablehttp.NewClient()
104+
// Wrap transport with response size limiter
105+
transport := opt.transport
106+
var httpResponseMaxSize int64
107+
if opt.sizeLimits != nil {
108+
httpResponseMaxSize = opt.sizeLimits.HTTPResponseMaxSize
109+
}
110+
transport = newLimitedResponseTransport(transport, httpResponseMaxSize)
91111

92-
client.HTTPClient = &http.Client{Transport: opt.transport}
112+
client := retryablehttp.NewClient()
113+
client.HTTPClient = &http.Client{Transport: transport}
93114
client.Logger = clog.FromContext(ctx)
94115

95116
httpClient := client.StandardClient()
96117

97118
// Create default PackageGetter if none provided
98119
packageGetter := opt.packageGetter
99120
if packageGetter == nil {
100-
packageGetter = newDefaultPackageGetter(httpClient, opt.cache, opt.auth)
121+
var getterOpts []packageGetterOption
122+
if opt.sizeLimits != nil {
123+
if opt.sizeLimits.APKControlMaxSize != 0 {
124+
getterOpts = append(getterOpts, withAPKControlMaxSize(opt.sizeLimits.APKControlMaxSize))
125+
}
126+
if opt.sizeLimits.APKDataMaxSize != 0 {
127+
getterOpts = append(getterOpts, withAPKDataMaxSize(opt.sizeLimits.APKDataMaxSize))
128+
}
129+
}
130+
packageGetter = newDefaultPackageGetter(httpClient, opt.cache, opt.auth, getterOpts...)
101131
}
102132

103133
return &APK{
@@ -113,6 +143,7 @@ func New(ctx context.Context, options ...Option) (*APK, error) {
113143
installedFiles: map[string]*Package{},
114144
auth: opt.auth,
115145
packageGetter: packageGetter,
146+
sizeLimits: opt.sizeLimits,
116147
}, nil
117148
}
118149

pkg/apk/apk/index.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,11 @@ func parseRepositoryIndex(ctx context.Context, u string, keys map[string][]byte,
457457
}
458458
}
459459
// with a valid signature, convert it to an ApkIndex
460-
index, err := IndexFromArchive(io.NopCloser(bytes.NewReader(b)))
460+
var archiveOpts []IndexFromArchiveOption
461+
if opts.indexDecompressedMaxSize != 0 {
462+
archiveOpts = append(archiveOpts, WithDecompressedMaxSize(opts.indexDecompressedMaxSize))
463+
}
464+
index, err := IndexFromArchive(io.NopCloser(bytes.NewReader(b)), archiveOpts...)
461465
if err != nil {
462466
return nil, fmt.Errorf("unable to read convert repository index bytes to index struct: %w", err)
463467
}
@@ -466,10 +470,11 @@ func parseRepositoryIndex(ctx context.Context, u string, keys map[string][]byte,
466470
}
467471

468472
type indexOpts struct {
469-
ignoreSignatures bool
470-
noSignatureIndexes []string
471-
httpClient *http.Client
472-
auth auth.Authenticator
473+
ignoreSignatures bool
474+
noSignatureIndexes []string
475+
httpClient *http.Client
476+
auth auth.Authenticator
477+
indexDecompressedMaxSize int64
473478
}
474479
type IndexOption func(*indexOpts)
475480

@@ -497,6 +502,12 @@ func WithIndexAuthenticator(a auth.Authenticator) IndexOption {
497502
}
498503
}
499504

505+
func WithIndexDecompressedMaxSize(size int64) IndexOption {
506+
return func(o *indexOpts) {
507+
o.indexDecompressedMaxSize = size
508+
}
509+
}
510+
500511
func redact(in string) string {
501512
asURL, err := url.Parse(in)
502513
if err != nil {

pkg/apk/apk/limited_transport.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright 2026 Chainguard, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package apk
16+
17+
import (
18+
"io"
19+
"net/http"
20+
21+
"chainguard.dev/apko/pkg/limitio"
22+
)
23+
24+
// limitedResponseTransport wraps an http.RoundTripper and limits the size of response bodies.
25+
type limitedResponseTransport struct {
26+
wrapped http.RoundTripper
27+
maxSize int64
28+
}
29+
30+
// newLimitedResponseTransport creates a new transport that limits HTTP response body sizes.
31+
// If maxSize is -1, responses are unlimited.
32+
// If maxSize is 0, the default DefaultHTTPResponseSize is used.
33+
func newLimitedResponseTransport(wrapped http.RoundTripper, maxSize int64) http.RoundTripper {
34+
return &limitedResponseTransport{
35+
wrapped: wrapped,
36+
maxSize: maxSize,
37+
}
38+
}
39+
40+
func (t *limitedResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) {
41+
resp, err := t.wrapped.RoundTrip(req)
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
// Wrap the response body with a limited reader
47+
resp.Body = &limitedReadCloser{
48+
ReadCloser: resp.Body,
49+
limited: limitio.NewLimitedReaderWithDefault(resp.Body, t.maxSize, DefaultHTTPResponseSize),
50+
}
51+
52+
return resp, nil
53+
}
54+
55+
// limitedReadCloser wraps a ReadCloser with size limiting.
56+
type limitedReadCloser struct {
57+
io.ReadCloser
58+
limited io.Reader
59+
}
60+
61+
func (l *limitedReadCloser) Read(p []byte) (int, error) {
62+
return l.limited.Read(p)
63+
}

0 commit comments

Comments
 (0)