From 5a09a7c227d46c4799c1342eb14b16454a39aa47 Mon Sep 17 00:00:00 2001 From: owan Date: Sun, 22 Mar 2026 17:07:57 +0800 Subject: [PATCH] test(engine): add gzip decompression bomb test and simplify functions Signed-off-by: owan --- pkg/engine/funcs.go | 76 ++++++++++++++++++++++++++++++++++++++++ pkg/engine/funcs_test.go | 37 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index e876df6c0..5ef83df2e 100644 --- a/pkg/engine/funcs.go +++ b/pkg/engine/funcs.go @@ -18,9 +18,12 @@ package engine import ( "bytes" + "compress/gzip" + "encoding/base64" "encoding/json" "errors" "fmt" + "io" "maps" "math" "reflect" @@ -28,6 +31,7 @@ import ( "strings" "text/template" "time" + "unicode/utf8" "github.com/BurntSushi/toml" "github.com/Masterminds/sprig/v3" @@ -67,6 +71,8 @@ func funcMap() template.FuncMap { "mustToJson": mustToJSON, "fromJson": fromJSON, "fromJsonArray": fromJSONArray, + "gzip": gzipFunc, + "ungzip": ungzipFunc, // Duration helpers "mustToDuration": mustToDuration, @@ -474,3 +480,73 @@ func durationTruncateTo(v, m any) time.Duration { } return d.Truncate(mul) } + +// gzipFunc compresses a string using gzip and returns the base64 encoded result. +// +// It enforces a size limit (1MB) on the output to prevent creating objects too large for Kubernetes Secrets/ConfigMap. +// +// This is designed to be called from a template. +func gzipFunc(str string) (string, error) { + var b bytes.Buffer + w := gzip.NewWriter(&b) + if _, err := w.Write([]byte(str)); err != nil { + return "", err + } + if err := w.Close(); err != nil { + return "", err + } + + encoded := base64.StdEncoding.EncodeToString(b.Bytes()) + + // Kubernetes limit for Secret/ConfigMap is 1MB. + const maxLimit = 1048576 + if len(encoded) > maxLimit { + return "", fmt.Errorf("gzip: output size %d exceeds limit of %d bytes", len(encoded), maxLimit) + } + + return encoded, nil +} + +// ungzipFunc decodes a base64 encoded and gzip-compressed string. +// +// It enforces a size limit (1MB) on the input and output to prevent abuse. +// +// This is designed to be called from a template. +func ungzipFunc(str string) (string, error) { + // Kubernetes limit for Secret/ConfigMap is 1MB. + const maxLimit = 1048576 + + if len(str) > maxLimit { + return "", fmt.Errorf("ungzip: input size %d exceeds limit of %d bytes", len(str), maxLimit) + } + + decoded, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return "", fmt.Errorf("ungzip: base64 decode failed: %w", err) + } + + r, err := gzip.NewReader(bytes.NewReader(decoded)) + if err != nil { + return "", err + } + defer r.Close() + + // Enforce a size limit on the decompressed content. + limitR := io.LimitReader(r, maxLimit+1) + + b, err := io.ReadAll(limitR) + if err != nil { + return "", err + } + + if len(b) > maxLimit { + return "", fmt.Errorf("ungzip: decompressed content exceeds size limit of %d bytes", maxLimit) + } + + // Ensure the content is valid text (UTF-8) to prevent binary obfuscation. + if !utf8.Valid(b) { + return "", errors.New("ungzip: content is not valid UTF-8 text") + } + + return string(b), nil +} diff --git a/pkg/engine/funcs_test.go b/pkg/engine/funcs_test.go index 03ed64153..78a10e7c6 100644 --- a/pkg/engine/funcs_test.go +++ b/pkg/engine/funcs_test.go @@ -17,6 +17,9 @@ limitations under the License. package engine import ( + "bytes" + "compress/gzip" + "encoding/base64" "math" "strings" "testing" @@ -130,6 +133,14 @@ keyInElement1 = "valueInElement1"`, tpl: `{{ lookup "v1" "Namespace" "" "unlikelynamespace99999999" }}`, expect: `map[]`, vars: `["one", "two"]`, + }, { + tpl: `{{ "hello world" | gzip }}`, + expect: `H4sIAAAAAAAA/8pIzcnJVyjPL8pJAQQAAP//hRFKDQsAAAA=`, + vars: nil, + }, { + tpl: `{{ "H4sIAAAAAAAA/8pIzcnJVyjPL8pJAQQAAP//hRFKDQsAAAA=" | ungzip }}`, + expect: `hello world`, + vars: nil, }} for _, tt := range tests { @@ -493,3 +504,29 @@ func TestMerge(t *testing.T) { } assert.Equal(t, expected, dict["dst"]) } + +// TestUngzipDecompressionBomb verifies that the ungzip template function +// correctly mitigates decompression bomb (zip bomb) attacks by strictly +// enforcing a 1MB limit on the decompressed output. +func TestUngzipDecompressionBomb(t *testing.T) { + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + + // Generate a 2MB payload of highly compressible zeros. This simulates + // a malicious payload that is small when compressed but expands rapidly. + zeros := make([]byte, 2*1024*1024) + if _, err := w.Write(zeros); err != nil { + t.Fatalf("failed to write zeros: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("failed to close gzip: %v", err) + } + + // Base64 encode the compressed bomb as expected by ungzipFunc + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + + // Attempting to ungzip should result in an error since it exceeds 1MB limit + _, err := ungzipFunc(encoded) + require.Error(t, err) + assert.Contains(t, err.Error(), "decompressed content exceeds size limit of 1048576 bytes") +}