From 576a77be5a3beede7d5f113b4912274fae39a427 Mon Sep 17 00:00:00 2001 From: owan Date: Wed, 21 Jan 2026 20:44:49 +0800 Subject: [PATCH 1/2] fix(engine): enforce 1MB limit and base64 encoding for gzip/ungzip Signed-off-by: owan --- pkg/engine/funcs.go | 77 ++++++++++++++++++++++++++++++++++++++++ pkg/engine/funcs_test.go | 8 +++++ 2 files changed, 85 insertions(+) diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index a97f8f104..620fd7add 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,73 @@ func fromJSONArray(str string) []interface{} { } 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 71a72e2e4..dd42f7010 100644 --- a/pkg/engine/funcs_test.go +++ b/pkg/engine/funcs_test.go @@ -127,6 +127,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 { From d4bb18e68c235d228d19900f3ee6af0fffe6350c Mon Sep 17 00:00:00 2001 From: owan Date: Fri, 23 Jan 2026 09:25:31 +0800 Subject: [PATCH 2/2] feat(engine): add zstd, lz4, snappy, and brotli template functions Signed-off-by: owan --- go.mod | 7 +- go.sum | 6 + pkg/engine/funcs.go | 265 +++++++++++++++++++++++++++++++++++++++ pkg/engine/funcs_test.go | 36 ++++++ 4 files changed, 313 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 4b0bb8977..c16e3a6fc 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,12 @@ require ( sigs.k8s.io/yaml v1.6.0 ) +require ( + github.com/andybalholm/brotli v1.2.0 + github.com/klauspost/compress v1.18.0 + github.com/pierrec/lz4/v4 v4.1.25 +) + require ( dario.cat/mergo v1.0.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -100,7 +106,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect diff --git a/go.sum b/go.sum index 6e010e4b2..63d1d35dd 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -242,6 +244,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= @@ -313,6 +317,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index 620fd7add..219dd1d7e 100644 --- a/pkg/engine/funcs.go +++ b/pkg/engine/funcs.go @@ -30,6 +30,10 @@ import ( "github.com/BurntSushi/toml" "github.com/Masterminds/sprig/v3" + "github.com/andybalholm/brotli" + "github.com/klauspost/compress/snappy" + "github.com/klauspost/compress/zstd" + "github.com/pierrec/lz4/v4" "sigs.k8s.io/yaml" goYaml "sigs.k8s.io/yaml/goyaml.v3" ) @@ -67,6 +71,14 @@ func funcMap() template.FuncMap { "fromJsonArray": fromJSONArray, "gzip": gzipFunc, "ungzip": ungzipFunc, + "zstd": zstdFunc, + "unzstd": unzstdFunc, + "lz4": lz4Func, + "unlz4": unlz4Func, + "snappy": snappyFunc, + "unsnappy": unsnappyFunc, + "brotli": brotliFunc, + "unbrotli": unbrotliFunc, // This is a placeholder for the "include" function, which is // late-bound to a template. By declaring it here, we preserve the @@ -309,3 +321,256 @@ func ungzipFunc(str string) (string, error) { return string(b), nil } + +// zstdFunc compresses a string with zstd. +func zstdFunc(str string) (string, error) { + // Kubernetes limit for Secret/ConfigMap is 1MB. + const maxLimit = 1048576 + + encoder, err := zstd.NewWriter(nil) + if err != nil { + return "", err + } + compressed := encoder.EncodeAll([]byte(str), nil) + + encoded := base64.StdEncoding.EncodeToString(compressed) + + if len(encoded) > maxLimit { + return "", fmt.Errorf("zstd: output size %d exceeds limit of %d bytes", len(encoded), maxLimit) + } + return encoded, nil +} + +// unzstdFunc decodes a base64 encoded and zstd-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 unzstdFunc(str string) (string, error) { + // Kubernetes limit for Secret/ConfigMap is 1MB. + const maxLimit = 1048576 + + if len(str) > maxLimit { + return "", fmt.Errorf("unzstd: input size %d exceeds limit of %d bytes", len(str), maxLimit) + } + + decoded, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return "", fmt.Errorf("unzstd: base64 decode failed: %w", err) + } + + decoder, err := zstd.NewReader(nil) + if err != nil { + return "", err + } + defer decoder.Close() + + // Enforce a size limit on the decompressed content. + const maxLimitOutput = 1048576 + + // Ideally we would use LimitReader, but zstd.Decoder doesn't use io.Reader directly for simple DecodeAll + // But `zstd.NewReader` returns a decoder that implements io.Reader? Yes. + // But `EncodeAll`/`DecodeAll` are stateless faster paths. + // Let's use DecodeAll for simplicity and valid memory check if possible, OR standard io.Copy with limit. + // zstd's DecodeAll allocates. To be safe against bombs, we should probably check size *if possible* or use the reader. + // Let's use the Reader interface to be consistent with ungzip and reusable limit logic. + + // However, zstd.DecodeAll is highly optimized. + // Let's try DecodeAll but pre-check if we can? No, we don't know decompressed size easily. + // Let's stick to Reader for safety. + + // Re-creating reader: + // decoder, _ := zstd.NewReader(bytes.NewReader(decoded)) + // But zstd.NewReader takes io.Reader. + + reader, err := zstd.NewReader(bytes.NewReader(decoded)) + if err != nil { + return "", err + } + defer reader.Close() + + limitR := io.LimitReader(reader, maxLimitOutput+1) + b, err := io.ReadAll(limitR) + if err != nil { + return "", err + } + + if len(b) > maxLimitOutput { + return "", fmt.Errorf("unzstd: decompressed content exceeds size limit of %d bytes", maxLimitOutput) + } + + if !utf8.Valid(b) { + return "", fmt.Errorf("unzstd: content is not valid UTF-8 text") + } + + return string(b), nil +} + +// lz4Func compresses a string with lz4. +func lz4Func(str string) (string, error) { + const maxLimit = 1048576 + + var buf bytes.Buffer + writer := lz4.NewWriter(&buf) + if _, err := writer.Write([]byte(str)); err != nil { + return "", err + } + if err := writer.Close(); err != nil { + return "", err + } + + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + + if len(encoded) > maxLimit { + return "", fmt.Errorf("lz4: output size %d exceeds limit of %d bytes", len(encoded), maxLimit) + } + return encoded, nil +} + +// unlz4Func decodes a base64 encoded and lz4-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 unlz4Func(str string) (string, error) { + const maxLimit = 1048576 + + if len(str) > maxLimit { + return "", fmt.Errorf("unlz4: input size %d exceeds limit of %d bytes", len(str), maxLimit) + } + + decoded, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return "", fmt.Errorf("unlz4: base64 decode failed: %w", err) + } + + reader := lz4.NewReader(bytes.NewReader(decoded)) + + // Enforce a size limit on the decompressed content. + const maxLimitOutput = 1048576 + limitR := io.LimitReader(reader, maxLimitOutput+1) + + b, err := io.ReadAll(limitR) + if err != nil { + return "", err + } + + if len(b) > maxLimitOutput { + return "", fmt.Errorf("unlz4: decompressed content exceeds size limit of %d bytes", maxLimitOutput) + } + + if !utf8.Valid(b) { + return "", fmt.Errorf("unlz4: content is not valid UTF-8 text") + } + return string(b), nil +} + +// snappyFunc compresses a string with snappy. +func snappyFunc(str string) (string, error) { + const maxLimit = 1048576 + + // Snappy Encode returns byte slice + compressed := snappy.Encode(nil, []byte(str)) + encoded := base64.StdEncoding.EncodeToString(compressed) + + if len(encoded) > maxLimit { + return "", fmt.Errorf("snappy: output size %d exceeds limit of %d bytes", len(encoded), maxLimit) + } + return encoded, nil +} + +// unsnappyFunc decodes a base64 encoded and snappy-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 unsnappyFunc(str string) (string, error) { + const maxLimit = 1048576 + + if len(str) > maxLimit { + return "", fmt.Errorf("unsnappy: input size %d exceeds limit of %d bytes", len(str), maxLimit) + } + + decoded, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return "", fmt.Errorf("unsnappy: base64 decode failed: %w", err) + } + + // Snappy DecodedLen is available, use it for safety check before allocation if possible + decodedLen, err := snappy.DecodedLen(decoded) + if err != nil { + return "", err + } + const maxLimitOutput = 1048576 + if decodedLen > maxLimitOutput { + return "", fmt.Errorf("unsnappy: decompressed content exceeds size limit of %d bytes", maxLimitOutput) + } + + b, err := snappy.Decode(nil, decoded) + if err != nil { + return "", err + } + + if !utf8.Valid(b) { + return "", fmt.Errorf("unsnappy: content is not valid UTF-8 text") + } + return string(b), nil +} + +// brotliFunc compresses a string with brotli. +func brotliFunc(str string) (string, error) { + const maxLimit = 1048576 + + var buf bytes.Buffer + writer := brotli.NewWriter(&buf) + if _, err := writer.Write([]byte(str)); err != nil { + return "", err + } + if err := writer.Close(); err != nil { + return "", err + } + + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + + if len(encoded) > maxLimit { + return "", fmt.Errorf("brotli: output size %d exceeds limit of %d bytes", len(encoded), maxLimit) + } + return encoded, nil +} + +// unbrotliFunc decodes a base64 encoded and brotli-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 unbrotliFunc(str string) (string, error) { + const maxLimit = 1048576 + + if len(str) > maxLimit { + return "", fmt.Errorf("unbrotli: input size %d exceeds limit of %d bytes", len(str), maxLimit) + } + + decoded, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return "", fmt.Errorf("unbrotli: base64 decode failed: %w", err) + } + + reader := brotli.NewReader(bytes.NewReader(decoded)) + + const maxLimitOutput = 1048576 + limitR := io.LimitReader(reader, maxLimitOutput+1) + + b, err := io.ReadAll(limitR) + if err != nil { + return "", err + } + + if len(b) > maxLimitOutput { + return "", fmt.Errorf("unbrotli: decompressed content exceeds size limit of %d bytes", maxLimitOutput) + } + + if !utf8.Valid(b) { + return "", fmt.Errorf("unbrotli: 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 dd42f7010..ef6c40090 100644 --- a/pkg/engine/funcs_test.go +++ b/pkg/engine/funcs_test.go @@ -135,6 +135,42 @@ keyInElement1 = "valueInElement1"`, tpl: `{{ "H4sIAAAAAAAA/8pIzcnJVyjPL8pJAQQAAP//hRFKDQsAAAA=" | ungzip }}`, expect: `hello world`, vars: nil, + }, { + // Test Zstd + tpl: `{{ "Hello world!" | zstd }}`, + expect: `KLUv/QQAYQAASGVsbG8gd29ybGQhsn39fw==`, + vars: nil, + }, { + tpl: `{{ "KLUv/QQAYQAASGVsbG8gd29ybGQhsn39fw==" | unzstd }}`, + expect: "Hello world!", + vars: nil, + }, { + // Test LZ4 + tpl: `{{ "Hello world!" | lz4 }}`, + expect: `BCJNGGRwuQwAAIBIZWxsbyB3b3JsZCEAAAAAcXerxA==`, + vars: nil, + }, { + tpl: `{{ "BCJNGGRwuQwAAIBIZWxsbyB3b3JsZCEAAAAAcXerxA==" | unlz4 }}`, + expect: "Hello world!", + vars: nil, + }, { + // Test Snappy + tpl: `{{ "Hello world!" | snappy }}`, + expect: `DCxIZWxsbyB3b3JsZCE=`, + vars: nil, + }, { + tpl: `{{ "DCxIZWxsbyB3b3JsZCE=" | unsnappy }}`, + expect: "Hello world!", + vars: nil, + }, { + // Test Brotli + tpl: `{{ "Hello world!" | brotli }}`, + expect: `GwsAACRBQpBKkUVq+pw1CQ==`, + vars: nil, + }, { + tpl: `{{ "GwsAACRBQpBKkUVq+pw1CQ==" | unbrotli }}`, + expect: "Hello world!", + vars: nil, }} for _, tt := range tests {