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