Skip to content

Commit e07f0a0

Browse files
authored
Escape auth passwords in generated Valkey config (#8)
1 parent 015a567 commit e07f0a0

4 files changed

Lines changed: 144 additions & 18 deletions

File tree

internal/controller/builders_coverage_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func TestRenderValkeyConfProfiles(t *testing.T) {
9696

9797
// Password → requirepass + masterauth.
9898
withPass := renderValkeyConf(withMem(cachev1beta1.ProfileCache, "1Gi"), "s3cr3t")
99-
if !strings.Contains(withPass, "requirepass s3cr3t") || !strings.Contains(withPass, "masterauth s3cr3t") {
99+
if !strings.Contains(withPass, `requirepass "s3cr3t"`) || !strings.Contains(withPass, `masterauth "s3cr3t"`) {
100100
t.Errorf("password conf must set requirepass + masterauth\n%s", withPass)
101101
}
102102

@@ -140,12 +140,15 @@ func TestRenderSentinelConf(t *testing.T) {
140140
}
141141

142142
// Password → sentinel auth-user (dedicated ACL user) + auth-pass + requirepass.
143-
if c := renderSentinelConf(sentinelCR(), "pw"); !strings.Contains(c, "auth-pass mymaster pw") || !strings.Contains(c, "requirepass pw") {
143+
if c := renderSentinelConf(sentinelCR(), "pw"); !strings.Contains(c, `auth-pass mymaster "pw"`) || !strings.Contains(c, `requirepass "pw"`) {
144144
t.Errorf("sentinel auth not rendered\n%s", c)
145145
}
146146
if c := renderSentinelConf(sentinelCR(), "pw"); !strings.Contains(c, "auth-user mymaster sentinel-user") {
147147
t.Errorf("sentinel must authenticate to master as the dedicated ACL user\n%s", c)
148148
}
149+
if c := renderSentinelConf(sentinelCR(), `abc"def`); !strings.Contains(c, `auth-pass mymaster "abc\"def"`) || !strings.Contains(c, `requirepass "abc\"def"`) {
150+
t.Errorf("sentinel auth password must be escaped\n%s", c)
151+
}
149152

150153
// TLS → tls-port + plaintext disabled + tls-replication.
151154
tlsCR := sentinelCR()

internal/controller/resources.go

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,13 @@ func passwordSecretName(vc *cachev1beta1.ValkeyCluster) string {
155155
return fmt.Sprintf("%s-auth", vc.Name)
156156
}
157157

158+
func authSecretName(vc *cachev1beta1.ValkeyCluster) string {
159+
if vc.Spec.Auth != nil && vc.Spec.Auth.ExistingSecret != "" {
160+
return vc.Spec.Auth.ExistingSecret
161+
}
162+
return passwordSecretName(vc)
163+
}
164+
158165
// hibernated reports whether the cluster is requested to be hibernated
159166
// (scaled to zero while keeping PVCs) via the hibernate annotation.
160167
func hibernated(vc *cachev1beta1.ValkeyCluster) bool {
@@ -196,6 +203,36 @@ func generatePassword(n int) (string, error) {
196203
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
197204
}
198205

206+
func valkeyConfigArg(value string) string {
207+
var b strings.Builder
208+
b.WriteByte('"')
209+
for i := range len(value) {
210+
switch c := value[i]; c {
211+
case '\\', '"':
212+
b.WriteByte('\\')
213+
b.WriteByte(c)
214+
case '\a':
215+
b.WriteString(`\a`)
216+
case '\b':
217+
b.WriteString(`\b`)
218+
case '\n':
219+
b.WriteString(`\n`)
220+
case '\r':
221+
b.WriteString(`\r`)
222+
case '\t':
223+
b.WriteString(`\t`)
224+
default:
225+
if c < 0x20 || c == 0x7f {
226+
fmt.Fprintf(&b, `\x%02x`, c)
227+
continue
228+
}
229+
b.WriteByte(c)
230+
}
231+
}
232+
b.WriteByte('"')
233+
return b.String()
234+
}
235+
199236
func buildHeadlessService(vc *cachev1beta1.ValkeyCluster) *corev1.Service {
200237
port := valkeyPort
201238
if tlsEnabled(vc) {
@@ -345,8 +382,9 @@ func renderValkeyConf(vc *cachev1beta1.ValkeyCluster, password string) string {
345382
fmt.Fprintf(&b, "protected-mode no\n")
346383
fmt.Fprintf(&b, "dir %s\n", dataMountPath)
347384
if password != "" {
348-
fmt.Fprintf(&b, "requirepass %s\n", password)
349-
fmt.Fprintf(&b, "masterauth %s\n", password)
385+
quotedPassword := valkeyConfigArg(password)
386+
fmt.Fprintf(&b, "requirepass %s\n", quotedPassword)
387+
fmt.Fprintf(&b, "masterauth %s\n", quotedPassword)
350388
}
351389

352390
// Persist ACL state on the data PVC so users survive pod restarts.
@@ -554,20 +592,23 @@ func renderInitScript(vc *cachev1beta1.ValkeyCluster) string {
554592
// follow-up that needs e2e failover validation.)
555593
sentinelACL := ""
556594
if vc.Spec.Topology == cachev1beta1.TopologySentinel {
557-
sentinelACL = fmt.Sprintf(" echo \"user %s on >$PW &* +@all\" >> %s/users.acl\n",
595+
sentinelACL = fmt.Sprintf(" echo \"user %s on #$PW_HASH &* +@all\" >> %s/users.acl\n",
558596
sentinelACLUser, dataMountPath)
559597
}
560598
common := fmt.Sprintf(`set -eu
561599
cp %[1]s/valkey.conf %[2]s/runtime.conf
562600
if [ ! -s %[2]s/users.acl ]; then
563-
PW=$(sed -n 's/^requirepass //p' %[2]s/runtime.conf | head -n1)
564-
if [ -n "$PW" ]; then
565-
echo "user default on >$PW ~* &* +@all" > %[2]s/users.acl
601+
if [ -n "${VALKEY_PASSWORD:-}" ]; then
602+
PW_HASH=$(printf '%%s' "$VALKEY_PASSWORD" | sha256sum | awk '{print $1}')
603+
echo "user default on #$PW_HASH ~* &* +@all" > %[2]s/users.acl
566604
%[3]s else
567605
: > %[2]s/users.acl
568606
fi
569607
fi
570-
`, configMountPath, dataMountPath, sentinelACL)
608+
valkey_config_arg() {
609+
awk 'BEGIN { printf "\"" } { if (NR > 1) printf "\\n"; gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); printf "%%s", $0 } END { printf "\"\n" }'
610+
}
611+
`, configMountPath, dataMountPath, sentinelACL)
571612

572613
// Multi-region: every pod (including pod-0) replicates from an external
573614
// primary. Local primary/replica entrypoint logic is bypassed.
@@ -594,7 +635,8 @@ fi
594635
}
595636
return common + fmt.Sprintf(`echo "replicaof %[1]s %[2]d" >> %[3]s/runtime.conf
596637
if [ -n "${SOURCE_PASSWORD:-}" ]; then
597-
echo "masterauth ${SOURCE_PASSWORD}" >> %[3]s/runtime.conf
638+
SOURCE_PASSWORD_ARG=$(printf '%%s' "$SOURCE_PASSWORD" | valkey_config_arg)
639+
echo "masterauth ${SOURCE_PASSWORD_ARG}" >> %[3]s/runtime.conf
598640
fi
599641
%[4]s%[5]s`, vc.Spec.ReplicateFrom.Host, extPort, dataMountPath, tlsLine, caMergeLine)
600642
}
@@ -707,6 +749,13 @@ func buildStatefulSet(vc *cachev1beta1.ValkeyCluster, configHash string, proacti
707749
},
708750
})
709751
}
752+
configInitEnv := append([]corev1.EnvVar(nil), envVars...)
753+
if vc.Spec.Auth != nil && vc.Spec.Auth.Enabled {
754+
configInitEnv = append(configInitEnv, corev1.EnvVar{
755+
Name: envValkeyPassword,
756+
ValueFrom: secretRef(authSecretName(vc), secretKeyPassword),
757+
})
758+
}
710759

711760
containerPorts := []corev1.ContainerPort{{
712761
Name: appValkey,
@@ -748,7 +797,7 @@ func buildStatefulSet(vc *cachev1beta1.ValkeyCluster, configHash string, proacti
748797
Image: vc.Spec.Image,
749798
ImagePullPolicy: vc.Spec.ImagePullPolicy,
750799
Command: []string{shellCmd, "-c", renderInitScript(vc)},
751-
Env: envVars,
800+
Env: configInitEnv,
752801
VolumeMounts: configInitMounts,
753802
})
754803

internal/controller/resources_test.go

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,27 @@ func TestConfigHashFromDataIsDeterministicAndSensitive(t *testing.T) {
232232
}
233233
}
234234

235+
func TestValkeyConfigArgQuotesSpecialCharacters(t *testing.T) {
236+
tests := []struct {
237+
name string
238+
value string
239+
want string
240+
}{
241+
{name: "plain", value: "secret", want: `"secret"`},
242+
{name: "double quote", value: `abc"def`, want: `"abc\"def"`},
243+
{name: "space backslash hash", value: `a b\c#d`, want: `"a b\\c#d"`},
244+
{name: "leading hash", value: `#comment`, want: `"#comment"`},
245+
{name: "newline", value: "line\nnext", want: `"line\nnext"`},
246+
}
247+
for _, tc := range tests {
248+
t.Run(tc.name, func(t *testing.T) {
249+
if got := valkeyConfigArg(tc.value); got != tc.want {
250+
t.Errorf("valkeyConfigArg(%q) = %q, want %q", tc.value, got, tc.want)
251+
}
252+
})
253+
}
254+
}
255+
235256
func TestRenderValkeyConfHasProfileDefaults(t *testing.T) {
236257
tests := []struct {
237258
name string
@@ -275,14 +296,17 @@ func TestRenderInitScriptSeedsDefaultUserACL(t *testing.T) {
275296

276297
for _, want := range []string{
277298
"users.acl",
278-
"requirepass", // password is read back from runtime.conf
279-
"user default on >$PW ~* &* +@all", // seeded default user carries the password
299+
`printf '%s' "$VALKEY_PASSWORD"`, // hash is computed from the exact Secret value
300+
"user default on #$PW_HASH ~* &* +@all", // seeded default user carries the password hash
280301
"[ ! -s " + dataMountPath + "/users.acl ]", // only seed when empty (don't clobber ACL SAVE)
281302
} {
282303
if !strings.Contains(script, want) {
283304
t.Errorf("init script missing %q\n%s", want, script)
284305
}
285306
}
307+
if strings.Contains(script, "sed -n 's/^requirepass //p'") {
308+
t.Errorf("init script must not parse the escaped config password back out of runtime.conf\n%s", script)
309+
}
286310
// It must NOT blindly create an empty file in the auth case.
287311
if strings.Contains(script, "touch "+dataMountPath+"/users.acl") {
288312
t.Errorf("init script still touches an empty users.acl (the bug)\n%s", script)
@@ -303,11 +327,11 @@ func TestRenderInitScriptSeedsSentinelACLUser(t *testing.T) {
303327
vc.Spec.Auth = &cachev1beta1.AuthSpec{Enabled: true}
304328
script := renderInitScript(vc)
305329

306-
if !strings.Contains(script, "user sentinel-user on >$PW &* +@all") {
330+
if !strings.Contains(script, "user sentinel-user on #$PW_HASH &* +@all") {
307331
t.Errorf("Sentinel init script must seed the sentinel ACL user\n%s", script)
308332
}
309333
// No key glob (~) for the sentinel user — it must not read/write data.
310-
if strings.Contains(script, "user sentinel-user on >$PW ~* &* +@all") {
334+
if strings.Contains(script, "user sentinel-user on #$PW_HASH ~* &* +@all") {
311335
t.Errorf("sentinel-user must not have key access (~*)\n%s", script)
312336
}
313337
}
@@ -335,6 +359,24 @@ func TestRenderValkeyConfMutualTLS(t *testing.T) {
335359
}
336360
}
337361

362+
func TestRenderValkeyConfEscapesPasswordArguments(t *testing.T) {
363+
vc := minimalCR()
364+
password := `abc" def\#ghi`
365+
conf := renderValkeyConf(vc, password)
366+
quoted := valkeyConfigArg(password)
367+
for _, want := range []string{
368+
"requirepass " + quoted,
369+
"masterauth " + quoted,
370+
} {
371+
if !strings.Contains(conf, want) {
372+
t.Errorf("missing escaped password directive %q\n%s", want, conf)
373+
}
374+
}
375+
if strings.Contains(conf, `requirepass abc"`) {
376+
t.Errorf("password must not be rendered as an unquoted config argument\n%s", conf)
377+
}
378+
}
379+
338380
func TestInternalEndpoint(t *testing.T) {
339381
vc := minimalCR()
340382
vc.Name = "web"
@@ -360,8 +402,8 @@ func TestRenderValkeyConfClusterDirectives(t *testing.T) {
360402
"cluster-config-file " + dataMountPath + "/nodes.conf",
361403
"cluster-node-timeout 5000",
362404
"cluster-require-full-coverage yes", // Durable requires it
363-
"requirepass secret",
364-
"masterauth secret",
405+
`requirepass "secret"`,
406+
`masterauth "secret"`,
365407
} {
366408
if !strings.Contains(conf, want) {
367409
t.Errorf("missing %q\n%s", want, conf)
@@ -464,6 +506,35 @@ func TestBuildExporterAuthSecret(t *testing.T) {
464506
}
465507
}
466508

509+
func TestBuildStatefulSetConfigInitGetsAuthPasswordEnv(t *testing.T) {
510+
passwordSecretRef := func(vc *cachev1beta1.ValkeyCluster) string {
511+
sts := buildStatefulSet(vc, "h", false)
512+
for _, e := range sts.Spec.Template.Spec.InitContainers[0].Env {
513+
if e.Name == envValkeyPassword && e.ValueFrom != nil && e.ValueFrom.SecretKeyRef != nil {
514+
return e.ValueFrom.SecretKeyRef.Name
515+
}
516+
}
517+
return ""
518+
}
519+
520+
gen := minimalCR()
521+
gen.Spec.Auth = &cachev1beta1.AuthSpec{Enabled: true}
522+
if got := passwordSecretRef(gen); got != "test-auth" {
523+
t.Errorf("config-init password secret = %q, want generated test-auth", got)
524+
}
525+
526+
ext := minimalCR()
527+
ext.Spec.Auth = &cachev1beta1.AuthSpec{Enabled: true, ExistingSecret: "my-auth"}
528+
if got := passwordSecretRef(ext); got != "my-auth" {
529+
t.Errorf("config-init password secret = %q, want existingSecret my-auth", got)
530+
}
531+
532+
disabled := minimalCR()
533+
if got := passwordSecretRef(disabled); got != "" {
534+
t.Errorf("config-init should not get VALKEY_PASSWORD when auth is disabled, got secret %q", got)
535+
}
536+
}
537+
467538
func TestBuildHeadlessServiceHasGossipPortOnlyForCluster(t *testing.T) {
468539
vc := minimalCR()
469540
svc := buildHeadlessService(vc)
@@ -630,6 +701,8 @@ func TestSourceCAMergeRendersCombinedBundle(t *testing.T) {
630701
"cat " + tlsMountPath + "/ca.crt " + sourceCAMountPath + "/ca.crt > " + dataMountPath + "/ca-bundle.crt",
631702
"replicaof src-primary.dc2.svc.cluster.local 6380",
632703
"tls-replication yes",
704+
"SOURCE_PASSWORD_ARG=$(printf '%s' \"$SOURCE_PASSWORD\" | valkey_config_arg)",
705+
"masterauth ${SOURCE_PASSWORD_ARG}",
633706
} {
634707
if !strings.Contains(script, want) {
635708
t.Errorf("init script missing %q\n%s", want, script)

internal/controller/sentinel.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,9 @@ sentinel announce-hostnames yes
164164
// Authenticate to the monitored master as the dedicated least-data-exposure
165165
// ACL user (seeded on the data nodes) rather than the default user, and
166166
// keep requirepass for client/inter-sentinel auth on the Sentinel port.
167+
quotedPassword := valkeyConfigArg(password)
167168
conf += fmt.Sprintf("sentinel auth-user %s %s\nsentinel auth-pass %s %s\nrequirepass %s\n",
168-
sentinelMasterName, sentinelACLUser, sentinelMasterName, password, password)
169+
sentinelMasterName, sentinelACLUser, sentinelMasterName, quotedPassword, quotedPassword)
169170
}
170171
if tlsEnabled(vc) {
171172
conf += fmt.Sprintf(`tls-port %d

0 commit comments

Comments
 (0)