From 80c63bb6eb61a861fa1223b1e0ec14f12217d97b 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 | 78 ++++++++++++++++++++++++++++++++++++++++ pkg/engine/funcs_test.go | 38 ++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index ba842a51a..426b8c63c 100644 --- a/pkg/engine/funcs.go +++ b/pkg/engine/funcs.go @@ -18,10 +18,15 @@ package engine import ( "bytes" + "compress/gzip" + "encoding/base64" "encoding/json" + "fmt" + "io" "maps" "strings" "text/template" + "unicode/utf8" "github.com/BurntSushi/toml" "github.com/Masterminds/sprig/v3" @@ -60,6 +65,8 @@ func funcMap() template.FuncMap { "mustToJson": mustToJSON, "fromJson": fromJSON, "fromJsonArray": fromJSONArray, + "gzip": gzipFunc, + "ungzip": ungzipFunc, // This is a placeholder for the "include" function, which is // late-bound to a template. By declaring it here, we preserve the @@ -232,3 +239,74 @@ func fromJSONArray(str string) []any { } return a } + +// 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 "", fmt.Errorf("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 48202454e..96fdaae44 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" "strings" "testing" "text/template" @@ -127,6 +130,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 { @@ -241,3 +252,30 @@ 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) + assert.Error(t, err) + assert.Contains(t, err.Error(), "decompressed content exceeds size limit of 1048576 bytes") +}