pull/31746/merge
owan 3 days ago committed by GitHub
commit 71f100e4c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -18,9 +18,12 @@ package engine
import ( import (
"bytes" "bytes"
"compress/gzip"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"maps" "maps"
"math" "math"
"reflect" "reflect"
@ -28,6 +31,7 @@ import (
"strings" "strings"
"text/template" "text/template"
"time" "time"
"unicode/utf8"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/Masterminds/sprig/v3" "github.com/Masterminds/sprig/v3"
@ -67,6 +71,8 @@ func funcMap() template.FuncMap {
"mustToJson": mustToJSON, "mustToJson": mustToJSON,
"fromJson": fromJSON, "fromJson": fromJSON,
"fromJsonArray": fromJSONArray, "fromJsonArray": fromJSONArray,
"gzip": gzipFunc,
"gunzip": gunzipFunc,
// Duration helpers // Duration helpers
"mustToDuration": mustToDuration, "mustToDuration": mustToDuration,
@ -474,3 +480,77 @@ func durationTruncateTo(v, m any) time.Duration {
} }
return d.Truncate(mul) 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) {
if !utf8.ValidString(str) {
return "", errors.New("gzip: content is not valid UTF-8 text")
}
var b bytes.Buffer
w := gzip.NewWriter(&b)
if _, err := w.Write([]byte(str)); err != nil {
return "", fmt.Errorf("gzip: write failed: %w", err)
}
if err := w.Close(); err != nil {
return "", fmt.Errorf("gzip: close failed: %w", 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
}
// gunzipFunc 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 gunzipFunc(str string) (string, error) {
// Kubernetes limit for Secret/ConfigMap is 1MB.
const maxLimit = 1048576
if len(str) > maxLimit {
return "", fmt.Errorf("gunzip: input size %d exceeds limit of %d bytes", len(str), maxLimit)
}
decoded, err := base64.StdEncoding.DecodeString(str)
if err != nil {
return "", fmt.Errorf("gunzip: base64 decode failed: %w", err)
}
r, err := gzip.NewReader(bytes.NewReader(decoded))
if err != nil {
return "", fmt.Errorf("gunzip: gzip reader failed: %w", 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 "", fmt.Errorf("gunzip: read failed: %w", err)
}
if len(b) > maxLimit {
return "", fmt.Errorf("gunzip: 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("gunzip: content is not valid UTF-8 text")
}
return string(b), nil
}

@ -17,6 +17,9 @@ limitations under the License.
package engine package engine
import ( import (
"bytes"
"compress/gzip"
"encoding/base64"
"math" "math"
"strings" "strings"
"testing" "testing"
@ -130,6 +133,14 @@ keyInElement1 = "valueInElement1"`,
tpl: `{{ lookup "v1" "Namespace" "" "unlikelynamespace99999999" }}`, tpl: `{{ lookup "v1" "Namespace" "" "unlikelynamespace99999999" }}`,
expect: `map[]`, expect: `map[]`,
vars: `["one", "two"]`, vars: `["one", "two"]`,
}, {
tpl: `{{ "hello world" | gzip }}`,
expect: `H4sIAAAAAAAA/8pIzcnJVyjPL8pJAQQAAP//hRFKDQsAAAA=`,
vars: nil,
}, {
tpl: `{{ "H4sIAAAAAAAA/8pIzcnJVyjPL8pJAQQAAP//hRFKDQsAAAA=" | gunzip }}`,
expect: `hello world`,
vars: nil,
}} }}
for _, tt := range tests { for _, tt := range tests {
@ -493,3 +504,49 @@ func TestMerge(t *testing.T) {
} }
assert.Equal(t, expected, dict["dst"]) assert.Equal(t, expected, dict["dst"])
} }
// TestGunzipDecompressionBomb verifies that the gunzip template function
// correctly mitigates decompression bomb (zip bomb) attacks by strictly
// enforcing a 1MB limit on the decompressed output.
func TestGunzipDecompressionBomb(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 gunzipFunc
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
// Attempting to gunzip should result in an error since it exceeds 1MB limit
_, err := gunzipFunc(encoded)
require.Error(t, err)
assert.Contains(t, err.Error(), "gunzip: decompressed content exceeds size limit of 1048576 bytes")
}
func TestGzipGunzipUTF8Validation(t *testing.T) {
// gzipFunc rejects invalid UTF-8 string
invalidUTF8 := string([]byte{0xff, 0xfe, 0xfd})
_, err := gzipFunc(invalidUTF8)
require.Error(t, err)
assert.Contains(t, err.Error(), "gzip: content is not valid UTF-8 text")
// gunzipFunc rejects decompressed content that is not valid UTF-8
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
_, err = w.Write([]byte{0xff, 0xfe, 0xfd})
require.NoError(t, err)
require.NoError(t, w.Close())
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
_, err = gunzipFunc(encoded)
require.Error(t, err)
assert.Contains(t, err.Error(), "gunzip: content is not valid UTF-8 text")
}

Loading…
Cancel
Save