Skip to content

Commit f72d11f

Browse files
committed
Address review feedback: TLS ServerName/CA, Find nil-check, config fail-fast
Addresses external review comments on the valkey-store backend: - StoresFind now rejects a nil/empty query Key before dereferencing it, so a malformed gRPC request can no longer panic the backend. - TLS: derive ServerName (SNI) from the VALKEY_ADDR host so certificate verification works for IP-addressed endpoints, and add VALKEY_TLS_CA_CERT (custom CA bundle) and VALKEY_TLS_SKIP_VERIFY (testing-only) knobs. - Config integer parsing now fails fast on a malformed value (e.g. VALKEY_HNSW_M=1x6) instead of silently defaulting, matching the fail-fast behaviour of the index-algo/distance-metric validation. - Add VALKEY_DB (SELECT n) support for logical-DB isolation. - Cap the human-readable part of a namespace token at 64 chars so a very long model name cannot produce an unbounded key prefix / index name (the appended short hash keeps distinct namespaces collision-free). - Document the KNN-query injection-safety invariant (fields are constants) and why StoresGet uses a single aggregate DoMulti deadline for reads. - Unit tests for the Find nil/empty-key guard, fail-fast HNSW parsing, and VALKEY_DB parsing/validation; docs + .env updated for the new vars. Assisted-by: Kiro:claude-opus-4.8 golangci-lint
1 parent e29eb79 commit f72d11f

5 files changed

Lines changed: 161 additions & 21 deletions

File tree

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@
102102
# VALKEY_USERNAME=
103103
# VALKEY_PASSWORD=
104104
# VALKEY_TLS=false
105+
# VALKEY_TLS_CA_CERT=
106+
# VALKEY_TLS_SKIP_VERIFY=false
105107
# VALKEY_CLIENT_NAME=localai-valkey-store
108+
# VALKEY_DB=0
106109
# VALKEY_INDEX_ALGO=FLAT
107110
# VALKEY_DISTANCE_METRIC=COSINE
108111
# VALKEY_HNSW_M=16

backend/go/valkey-store/config.go

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ type Config struct {
7373
Username string
7474
Password string
7575
UseTLS bool
76+
TLSSkipVerify bool
77+
TLSCACert string
7678
ClientName string
79+
DB int
7780
IndexAlgo string
7881
DistanceMetric string
7982
HNSW hnswParams
@@ -84,20 +87,47 @@ type Config struct {
8487
// It fails fast on an unknown index algorithm or distance metric so a
8588
// misconfiguration surfaces at Load() rather than silently degrading search.
8689
func loadConfig() (Config, error) {
90+
// intOr parses an integer env var, failing fast on a malformed value the
91+
// same way an invalid index algo or distance metric does. A typo like
92+
// VALKEY_HNSW_M=1x6 must surface at Load() rather than silently degrading
93+
// to the default and producing subtly wrong (and hard-to-diagnose) index
94+
// behaviour. The first parse error wins and is returned below.
95+
var parseErr error
96+
intOr := func(key string, fallback int) int {
97+
v := os.Getenv(key)
98+
if v == "" {
99+
return fallback
100+
}
101+
n, err := strconv.Atoi(v)
102+
if err != nil {
103+
if parseErr == nil {
104+
parseErr = fmt.Errorf("valkey-store: invalid %s %q: %w", key, v, err)
105+
}
106+
return fallback
107+
}
108+
return n
109+
}
110+
87111
cfg := Config{
88112
Addr: envOr("VALKEY_ADDR", _defaultAddr),
89113
Username: os.Getenv("VALKEY_USERNAME"),
90114
Password: os.Getenv("VALKEY_PASSWORD"),
91115
UseTLS: envBool("VALKEY_TLS", false),
116+
TLSSkipVerify: envBool("VALKEY_TLS_SKIP_VERIFY", false),
117+
TLSCACert: os.Getenv("VALKEY_TLS_CA_CERT"),
92118
ClientName: envOr("VALKEY_CLIENT_NAME", _defaultClientName),
119+
DB: intOr("VALKEY_DB", 0),
93120
IndexAlgo: strings.ToUpper(envOr("VALKEY_INDEX_ALGO", _defaultIndexAlgo)),
94121
DistanceMetric: strings.ToUpper(envOr("VALKEY_DISTANCE_METRIC", _defaultDistanceMetric)),
95122
HNSW: hnswParams{
96-
M: envInt("VALKEY_HNSW_M", _defaultHNSWM),
97-
EFConstruction: envInt("VALKEY_HNSW_EF_CONSTRUCTION", _defaultHNSWEFConstruction),
98-
EFRuntime: envInt("VALKEY_HNSW_EF_RUNTIME", _defaultHNSWEFRuntime),
123+
M: intOr("VALKEY_HNSW_M", _defaultHNSWM),
124+
EFConstruction: intOr("VALKEY_HNSW_EF_CONSTRUCTION", _defaultHNSWEFConstruction),
125+
EFRuntime: intOr("VALKEY_HNSW_EF_RUNTIME", _defaultHNSWEFRuntime),
99126
},
100-
RequestTimeout: time.Duration(envInt("VALKEY_REQUEST_TIMEOUT_MS", _defaultRequestTimeoutMS)) * time.Millisecond,
127+
RequestTimeout: time.Duration(intOr("VALKEY_REQUEST_TIMEOUT_MS", _defaultRequestTimeoutMS)) * time.Millisecond,
128+
}
129+
if parseErr != nil {
130+
return Config{}, parseErr
101131
}
102132

103133
// ClientName is mandatory. Restore the default if the operator blanked it,
@@ -106,6 +136,10 @@ func loadConfig() (Config, error) {
106136
cfg.ClientName = _defaultClientName
107137
}
108138

139+
if cfg.DB < 0 {
140+
return Config{}, fmt.Errorf("valkey-store: invalid VALKEY_DB %d (must be >= 0)", cfg.DB)
141+
}
142+
109143
switch cfg.IndexAlgo {
110144
case indexAlgoFlat, indexAlgoHNSW:
111145
default:
@@ -146,16 +180,3 @@ func envBool(key string, fallback bool) bool {
146180
}
147181
return b
148182
}
149-
150-
func envInt(key string, fallback int) int {
151-
v := os.Getenv(key)
152-
if v == "" {
153-
return fallback
154-
}
155-
n, err := strconv.Atoi(v)
156-
if err != nil {
157-
xlog.Warn("valkey-store: ignoring unparseable env var, using default", "key", key, "value", v, "default", fallback)
158-
return fallback
159-
}
160-
return n
161-
}

backend/go/valkey-store/store.go

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ import (
2828
"context"
2929
"crypto/sha256"
3030
"crypto/tls"
31+
"crypto/x509"
3132
"encoding/hex"
3233
"fmt"
34+
"net"
35+
"os"
3336
"strconv"
3437
"strings"
3538

@@ -59,6 +62,12 @@ const (
5962
// it is in-memory; a networked backend wants a guard. Callers asking for
6063
// more than this get the top _maxTopK results.
6164
_maxTopK = 10000
65+
66+
// _maxNsTokenLen bounds the human-readable portion of a namespace token so
67+
// a very long model name cannot produce an unbounded key prefix / index
68+
// name. The appended short hash keeps distinct namespaces collision-free
69+
// even when their sanitized prefixes are truncated to the same value.
70+
_maxNsTokenLen = 64
6271
)
6372

6473
// ValkeyStore implements the gRPC store Backend against Valkey Search.
@@ -122,12 +131,20 @@ func (s *ValkeyStore) Load(opts *pb.ModelOptions) error {
122131
Username: cfg.Username,
123132
Password: cfg.Password,
124133
ClientName: cfg.ClientName,
134+
// SelectDB picks a logical Valkey DB (SELECT n) for deployments that use
135+
// numbered DBs for isolation. Defaults to 0; namespace prefixing already
136+
// isolates keyspaces on a shared DB.
137+
SelectDB: cfg.DB,
125138
// Disable client-side caching: values are opaque blobs written once and
126139
// read rarely, so tracking invalidations would only add overhead.
127140
DisableCache: true,
128141
}
129142
if cfg.UseTLS {
130-
clientOpt.TLSConfig = &tls.Config{}
143+
tlsCfg, err := buildTLSConfig(cfg)
144+
if err != nil {
145+
return err
146+
}
147+
clientOpt.TLSConfig = tlsCfg
131148
}
132149

133150
// Close any client from a previous Load so a re-entrant Load does not leak
@@ -255,6 +272,35 @@ func (s *ValkeyStore) Free() error {
255272
return nil
256273
}
257274

275+
// buildTLSConfig assembles the tls.Config for a VALKEY_TLS=true connection.
276+
// Go only auto-derives ServerName (SNI) from the dial address for hostnames;
277+
// for an IP-addressed endpoint (e.g. 10.0.0.5:6379) SNI is left empty and the
278+
// certificate's SANs won't match the raw IP, so verification fails. We set it
279+
// explicitly from the configured host so both hostname and IP endpoints verify.
280+
// A custom CA bundle (VALKEY_TLS_CA_CERT) and an explicit insecure-skip escape
281+
// hatch (VALKEY_TLS_SKIP_VERIFY) are supported for enterprise/self-signed setups.
282+
func buildTLSConfig(cfg Config) (*tls.Config, error) {
283+
tlsCfg := &tls.Config{}
284+
if host, _, err := net.SplitHostPort(cfg.Addr); err == nil && host != "" {
285+
tlsCfg.ServerName = host
286+
}
287+
if cfg.TLSSkipVerify {
288+
tlsCfg.InsecureSkipVerify = true
289+
}
290+
if cfg.TLSCACert != "" {
291+
pem, err := os.ReadFile(cfg.TLSCACert)
292+
if err != nil {
293+
return nil, fmt.Errorf("valkey-store: read VALKEY_TLS_CA_CERT %q: %w", cfg.TLSCACert, err)
294+
}
295+
pool := x509.NewCertPool()
296+
if !pool.AppendCertsFromPEM(pem) {
297+
return nil, fmt.Errorf("valkey-store: VALKEY_TLS_CA_CERT %q: no valid certificate found", cfg.TLSCACert)
298+
}
299+
tlsCfg.RootCAs = pool
300+
}
301+
return tlsCfg, nil
302+
}
303+
258304
// ctx returns a request-scoped context bounded by the configured timeout. We
259305
// never rely on the client's built-in write timeout because index back-fill
260306
// and large KNN queries can legitimately exceed a short default.
@@ -332,6 +378,13 @@ func (s *ValkeyStore) StoresGet(opts *pb.StoresGetOptions) (pb.StoresGetResult,
332378
return pb.StoresGetResult{}, err
333379
}
334380

381+
// Reads pipeline the whole batch via DoMulti under ONE aggregate deadline,
382+
// unlike Set/Delete which use a per-command timeout. That asymmetry is
383+
// deliberate: HGET is non-mutating, so exhausting the deadline mid-batch
384+
// only truncates the result (surfaced as an error) — it can never leave a
385+
// partial write behind, which is the specific hazard the per-command timeout
386+
// guards against for Set/Delete. Pipelining is also safe here because these
387+
// are non-indexed reads (the indexed-DoMulti deadlock only affects writes).
335388
ctx, cancel := s.ctx()
336389
defer cancel()
337390

@@ -394,6 +447,11 @@ func (s *ValkeyStore) StoresDelete(opts *pb.StoresDeleteOptions) error {
394447
// metric, ordered most-similar first. An empty/uncreated index returns empty
395448
// slices and no error, matching local-store's empty-store behaviour.
396449
func (s *ValkeyStore) StoresFind(opts *pb.StoresFindOptions) (pb.StoresFindResult, error) {
450+
// Guard against a malformed gRPC request with a nil/empty Key before
451+
// dereferencing it — a nil opts.Key would otherwise panic the backend.
452+
if opts.Key == nil || len(opts.Key.Floats) == 0 {
453+
return pb.StoresFindResult{}, fmt.Errorf("valkey-store: Find: query key is empty")
454+
}
397455
query := opts.Key.Floats
398456
topK := int(opts.TopK)
399457
if topK < 1 {
@@ -428,6 +486,12 @@ func (s *ValkeyStore) StoresFind(opts *pb.StoresFindOptions) (pb.StoresFindResul
428486
// SORTABLE schema attribute). LIMIT 0 topK caps the result and DIALECT 2 is
429487
// required for the =>[KNN ...] vector syntax. The __score field is still
430488
// returned in each document and read back for the similarity conversion.
489+
//
490+
// Injection-safety: the only caller-controlled value interpolated here is
491+
// topK (an int, already bounded above). _vecField and _scoreField are
492+
// compile-time constants, so this Sprintf cannot be used to inject query
493+
// syntax. Do NOT make those fields operator-configurable without sanitizing
494+
// them first — the KNN query string is otherwise built only from constants.
431495
q := fmt.Sprintf("*=>[KNN %d @%s $q AS %s]", topK, _vecField, _scoreField)
432496
cmd := s.client.B().FtSearch().Index(s.indexName).Query(q).
433497
Return("3").Identifier(_vecField).Identifier(_valField).Identifier(_scoreField).
@@ -586,7 +650,15 @@ func indexName(namespace string) string {
586650
// persisted index is found again after a restart.
587651
func nsToken(namespace string) string {
588652
sum := sha256.Sum256([]byte(namespace))
589-
return sanitize(namespace) + "-" + hex.EncodeToString(sum[:])[:8]
653+
// Cap the human-readable part so a pathologically long model name can't
654+
// produce an unbounded key prefix / index name (which would degrade Valkey
655+
// performance). The 8-char hash suffix below already guarantees collision
656+
// resistance regardless of truncation, so trimming the readable part is safe.
657+
readable := sanitize(namespace)
658+
if len(readable) > _maxNsTokenLen {
659+
readable = readable[:_maxNsTokenLen]
660+
}
661+
return readable + "-" + hex.EncodeToString(sum[:])[:8]
590662
}
591663

592664
// sanitize maps a namespace to a safe token for keys/index names: alphanumeric,

backend/go/valkey-store/store_test.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ var _ = Describe("loadConfig", func() {
4747
// nothing, so there's no error to check).
4848
envKeys := []string{
4949
"VALKEY_ADDR", "VALKEY_CLIENT_NAME", "VALKEY_INDEX_ALGO", "VALKEY_DISTANCE_METRIC",
50-
"VALKEY_REQUEST_TIMEOUT_MS", "VALKEY_TLS", "VALKEY_HNSW_M",
50+
"VALKEY_REQUEST_TIMEOUT_MS", "VALKEY_TLS", "VALKEY_TLS_SKIP_VERIFY", "VALKEY_TLS_CA_CERT",
51+
"VALKEY_DB", "VALKEY_HNSW_M", "VALKEY_HNSW_EF_CONSTRUCTION", "VALKEY_HNSW_EF_RUNTIME",
5152
}
5253

5354
BeforeEach(func() {
@@ -97,6 +98,26 @@ var _ = Describe("loadConfig", func() {
9798
_, err := loadConfig()
9899
Expect(err).To(HaveOccurred())
99100
})
101+
102+
It("fails fast on a malformed HNSW integer instead of silently defaulting", func() {
103+
GinkgoT().Setenv("VALKEY_HNSW_M", "1x6")
104+
_, err := loadConfig()
105+
Expect(err).To(HaveOccurred())
106+
Expect(err.Error()).To(ContainSubstring("VALKEY_HNSW_M"))
107+
})
108+
109+
It("honours a valid VALKEY_DB override", func() {
110+
GinkgoT().Setenv("VALKEY_DB", "3")
111+
cfg, err := loadConfig()
112+
Expect(err).NotTo(HaveOccurred())
113+
Expect(cfg.DB).To(Equal(3))
114+
})
115+
116+
It("rejects a negative VALKEY_DB", func() {
117+
GinkgoT().Setenv("VALKEY_DB", "-1")
118+
_, err := loadConfig()
119+
Expect(err).To(HaveOccurred())
120+
})
100121
})
101122

102123
var _ = Describe("StoresSet", func() {
@@ -254,6 +275,22 @@ var _ = Describe("StoresFind", func() {
254275
Expect(err).To(HaveOccurred())
255276
})
256277

278+
It("rejects a nil Key without panicking", func() {
279+
s, _ := newMockStore(testCfg())
280+
s.keyLen = 3
281+
s.indexCreated = true
282+
_, err := s.StoresFind(&pb.StoresFindOptions{TopK: 5})
283+
Expect(err).To(HaveOccurred())
284+
})
285+
286+
It("rejects an empty query vector", func() {
287+
s, _ := newMockStore(testCfg())
288+
s.keyLen = 3
289+
s.indexCreated = true
290+
_, err := s.StoresFind(&pb.StoresFindOptions{Key: &pb.StoresKey{Floats: []float32{}}, TopK: 5})
291+
Expect(err).To(HaveOccurred())
292+
})
293+
257294
It("rejects query dimension mismatch", func() {
258295
s, _ := newMockStore(testCfg())
259296
s.keyLen = 3

docs/content/features/stores.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ The connection and index are configured through environment variables read by th
8080
| `VALKEY_USERNAME` | *(empty)* | Optional ACL username. |
8181
| `VALKEY_PASSWORD` | *(empty)* | Optional password / ACL secret. |
8282
| `VALKEY_TLS` | `false` | Enable TLS (required by many managed deployments). |
83+
| `VALKEY_TLS_CA_CERT` | *(empty)* | Path to a PEM CA bundle used to verify the server certificate (self-signed / private CA). |
84+
| `VALKEY_TLS_SKIP_VERIFY` | `false` | Skip TLS certificate verification. Insecure — for testing only. |
8385
| `VALKEY_CLIENT_NAME` | `localai-valkey-store` | Connection name reported by `CLIENT LIST`. Always set. |
86+
| `VALKEY_DB` | `0` | Logical Valkey DB index (`SELECT n`). Namespace prefixing already isolates keyspaces on a shared DB. |
8487
| `VALKEY_INDEX_ALGO` | `FLAT` | `FLAT` (exact, default) or `HNSW` (approximate ANN for large corpora). |
8588
| `VALKEY_HNSW_M` | `16` | HNSW graph degree (only when `VALKEY_INDEX_ALGO=HNSW`). |
8689
| `VALKEY_HNSW_EF_CONSTRUCTION` | `200` | HNSW build-time candidate list (HNSW only). |
@@ -109,7 +112,11 @@ backend.
109112
{{% notice warning %}}
110113
`VALKEY_TLS` defaults to `false` (plaintext). Set `VALKEY_TLS=true` whenever the Valkey server is
111114
not on `localhost` or a `VALKEY_PASSWORD`/`VALKEY_USERNAME` is configured, otherwise the credentials
112-
and the stored vectors travel the network unencrypted.
115+
and the stored vectors travel the network unencrypted. The TLS `ServerName` (SNI) is derived from
116+
the host portion of `VALKEY_ADDR`, so certificate verification works for both hostname and
117+
IP-addressed endpoints. For a self-signed / private CA, point `VALKEY_TLS_CA_CERT` at the PEM
118+
bundle; `VALKEY_TLS_SKIP_VERIFY=true` disables verification entirely and should only be used for
119+
local testing.
113120
{{% /notice %}}
114121

115122
## Set

0 commit comments

Comments
 (0)