From b93b5f7e595c479876d21bba12e8f4764ee07623 Mon Sep 17 00:00:00 2001 From: Sakthi Harish Date: Sun, 13 Sep 2026 16:58:59 +0000 Subject: [PATCH] feat(engine): allow htpasswd to accept optional hash algorithm Override the sprig-provided htpasswd function so Helm templates can pass an optional third argument to select the hash algorithm (bcrypt or sha). Unsupported algorithms and invalid usernames return descriptive error strings instead of panicking. Signed-off-by: Sakthi Harish --- pkg/engine/funcs.go | 39 ++++++++++++++++++++++++++++++++ pkg/engine/funcs_test.go | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index e876df6c0..80fa53a1d 100644 --- a/pkg/engine/funcs.go +++ b/pkg/engine/funcs.go @@ -18,6 +18,8 @@ package engine import ( "bytes" + "crypto/sha1" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -31,6 +33,7 @@ import ( "github.com/BurntSushi/toml" "github.com/Masterminds/sprig/v3" + "golang.org/x/crypto/bcrypt" "sigs.k8s.io/yaml" goYaml "sigs.k8s.io/yaml/goyaml.v3" ) @@ -53,6 +56,10 @@ func funcMap() template.FuncMap { delete(f, "env") delete(f, "expandenv") + // Override the sprig "htpasswd" function so it accepts an optional + // third argument to select the hash algorithm. + f["htpasswd"] = htpasswd + // Add some extra functionality extra := template.FuncMap{ "toToml": toTOML, @@ -99,6 +106,38 @@ func funcMap() template.FuncMap { return f } +// htpasswd takes a username and password and returns an htpasswd entry. It +// extends the sprig-provided function (see https://github.com/helm/helm/issues/31924) +// by accepting an optional third argument to select the hash algorithm. +// +// Supported algorithms are "bcrypt" (the default, with the same cost as +// sprig) and "sha" ({SHA} base64-encoded SHA-1). Unlike a panic, invalid +// input is reported by returning a descriptive error string. +func htpasswd(username, password string, algorithm ...string) string { + if strings.Contains(username, ":") { + return fmt.Sprintf("invalid username: %s", username) + } + + alg := "bcrypt" + if len(algorithm) > 0 { + alg = algorithm[0] + } + + switch alg { + case "bcrypt": + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Sprintf("failed to encrypt string with bcrypt: %s", err) + } + return fmt.Sprintf("%s:%s", username, hash) + case "sha": + h := sha1.Sum([]byte(password)) + return fmt.Sprintf("%s:{SHA}%s", username, base64.StdEncoding.EncodeToString(h[:])) + default: + return fmt.Sprintf("invalid hash algorithm: %s", alg) + } +} + // toYAML takes an interface, marshals it to yaml, and returns a string. It will // always return a string, even on marshal error (empty string). // diff --git a/pkg/engine/funcs_test.go b/pkg/engine/funcs_test.go index 03ed64153..8fe93b980 100644 --- a/pkg/engine/funcs_test.go +++ b/pkg/engine/funcs_test.go @@ -195,6 +195,54 @@ keyInElement1 = "valueInElement1"`, } } +func TestHtpasswd(t *testing.T) { + tests := []struct { + name string + tpl string + expect string + // if non-empty, the output must have this prefix instead of matching expect exactly + prefix string + }{ + { + name: "defaults to bcrypt", + tpl: `{{ htpasswd "user" "pass" }}`, + prefix: "user:$2", + }, + { + name: "explicit bcrypt", + tpl: `{{ htpasswd "user" "pass" "bcrypt" }}`, + prefix: "user:$2", + }, + { + name: "sha produces {SHA} base64 hash", + tpl: `{{ htpasswd "user" "pass" "sha" }}`, + expect: "user:{SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ=", + }, + { + name: "username containing colon is rejected", + tpl: `{{ htpasswd "bad:user" "pass" }}`, + expect: "invalid username: bad:user", + }, + { + name: "unsupported algorithm returns error string", + tpl: `{{ htpasswd "user" "pass" "md5" }}`, + expect: "invalid hash algorithm: md5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b strings.Builder + require.NoError(t, template.Must(template.New("test").Funcs(funcMap()).Parse(tt.tpl)).Execute(&b, nil), tt.tpl) + if tt.prefix != "" { + assert.Truef(t, strings.HasPrefix(b.String(), tt.prefix), "expected output to start with %q, got %q", tt.prefix, b.String()) + return + } + assert.Equal(t, tt.expect, b.String(), tt.tpl) + }) + } +} + func TestDurationHelpers(t *testing.T) { tests := []struct { name string