• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / stash / 20609857721

31 Dec 2025 01:34AM UTC coverage: 83.562% (-0.08%) from 83.646%
20609857721

Pull #47

github

umputun
fix(store): prioritize ZK encryption over server-side secrets

ZK-encrypted values in secrets paths were being double-encrypted,
causing the $ZK$ prefix to be lost and incorrect UI display.

now the store skips server encryption when value has $ZK$ prefix.
this allows ZK values to be stored in secrets paths while maintaining
both the Secret and ZKEncrypted flags for proper UI indication.

adds unit test for ZK precedence and e2e test for combined display.
Pull Request #47: feat(store): add zero-knowledge client-side encryption

183 of 220 new or added lines in 5 files covered. (83.18%)

64 existing lines in 4 files now uncovered.

3289 of 3936 relevant lines covered (83.56%)

97.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.45
/app/server/api/handler.go
1
// Package api provides HTTP handlers for the KV API.
2
package api
3

4
import (
5
        "context"
6
        "encoding/base64"
7
        "errors"
8
        "io"
9
        "net/http"
10
        "strings"
11
        "time"
12

13
        log "github.com/go-pkgz/lgr"
14
        "github.com/go-pkgz/rest"
15
        "github.com/go-pkgz/routegroup"
16

17
        "github.com/umputun/stash/app/enum"
18
        "github.com/umputun/stash/app/git"
19
        "github.com/umputun/stash/app/server/internal/cookie"
20
        "github.com/umputun/stash/app/store"
21
        "github.com/umputun/stash/lib/stash"
22
)
23

24
//go:generate moq -out mocks/kvstore.go -pkg mocks -skip-ensure -fmt goimports . KVStore
25
//go:generate moq -out mocks/authprovider.go -pkg mocks -skip-ensure -fmt goimports . AuthProvider
26
//go:generate moq -out mocks/formatvalidator.go -pkg mocks -skip-ensure -fmt goimports . FormatValidator
27
//go:generate moq -out mocks/gitservice.go -pkg mocks -skip-ensure -fmt goimports . GitService
28

29
// GitService defines the interface for git operations.
30
type GitService interface {
31
        Commit(req git.CommitRequest) error
32
        Delete(key string, author git.Author) error
33
        History(key string, limit int) ([]git.HistoryEntry, error)
34
        GetRevision(key string, rev string) ([]byte, string, error)
35
}
36

37
// KVStore defines the interface for key-value storage operations.
38
type KVStore interface {
39
        Get(ctx context.Context, key string) ([]byte, error)
40
        GetWithFormat(ctx context.Context, key string) ([]byte, string, error)
41
        Set(ctx context.Context, key string, value []byte, format string) error
42
        Delete(ctx context.Context, key string) error
43
        List(ctx context.Context, filter enum.SecretsFilter) ([]store.KeyInfo, error)
44
        SecretsEnabled() bool
45
}
46

47
// AuthProvider defines the interface for authentication operations.
48
type AuthProvider interface {
49
        Enabled() bool
50
        GetSessionUser(ctx context.Context, token string) (string, bool)
51
        FilterUserKeys(username string, keys []string) []string
52
        FilterTokenKeys(token string, keys []string) []string
53
        FilterPublicKeys(keys []string) []string
54
        HasTokenACL(token string) bool
55
}
56

57
// FormatValidator defines the interface for format validation.
58
type FormatValidator interface {
59
        IsValidFormat(format string) bool
60
}
61

62
// Handler handles API requests for /kv/* endpoints.
63
type Handler struct {
64
        store           KVStore
65
        auth            AuthProvider
66
        formatValidator FormatValidator
67
        git             GitService
68
}
69

70
// New creates a new API handler.
71
func New(st KVStore, auth AuthProvider, fv FormatValidator, gs GitService) *Handler {
39✔
72
        return &Handler{
39✔
73
                store:           st,
39✔
74
                auth:            auth,
39✔
75
                formatValidator: fv,
39✔
76
                git:             gs,
39✔
77
        }
39✔
78
}
39✔
79

80
// Register registers API routes on the given router.
81
func (h *Handler) Register(r *routegroup.Bundle) {
×
82
        r.HandleFunc("GET /{$}", h.handleList)                 // list keys (must be before {key...})
×
83
        r.HandleFunc("GET /history/{key...}", h.handleHistory) // get key history (before generic key)
×
84
        r.HandleFunc("GET /{key...}", h.handleGet)             // get specific key
×
85
        r.HandleFunc("PUT /{key...}", h.handleSet)             // set key
×
86
        r.HandleFunc("DELETE /{key...}", h.handleDelete)
×
87
}
×
88

89
// handleList returns all keys the caller has read access to.
90
// GET /kv?prefix=app/config (filter by prefix)
91
// GET /kv?filter=secrets (filter to secrets only)
92
// GET /kv?filter=keys (filter to non-secrets only)
93
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
6✔
94
        // parse secrets filter query param
6✔
95
        filter := enum.SecretsFilterAll
6✔
96
        if filterParam := r.URL.Query().Get("filter"); filterParam != "" {
9✔
97
                parsed, err := enum.ParseSecretsFilter(filterParam)
3✔
98
                if err != nil {
4✔
99
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "invalid filter parameter")
1✔
100
                        return
1✔
101
                }
1✔
102
                filter = parsed
2✔
103
        }
104

105
        keys, err := h.store.List(r.Context(), filter)
5✔
106
        if err != nil {
6✔
107
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to list keys")
1✔
108
                return
1✔
109
        }
1✔
110

111
        // extract key names for filtering
112
        keyNames := make([]string, len(keys))
4✔
113
        for i, k := range keys {
11✔
114
                keyNames[i] = k.Key
7✔
115
        }
7✔
116

117
        // filter by auth permissions
118
        filteredNames := h.filterKeysByAuth(r, keyNames)
4✔
119
        if filteredNames == nil {
4✔
120
                // no valid auth, but this shouldn't happen since tokenAuth middleware already checked
×
121
                rest.SendErrorJSON(w, r, log.Default(), http.StatusUnauthorized, nil, "unauthorized")
×
122
                return
×
123
        }
×
124

125
        // convert filtered names back to KeyInfo slice
126
        nameSet := make(map[string]bool, len(filteredNames))
4✔
127
        for _, name := range filteredNames {
11✔
128
                nameSet[name] = true
7✔
129
        }
7✔
130
        filtered := make([]store.KeyInfo, 0, len(filteredNames))
4✔
131
        for _, k := range keys {
11✔
132
                if nameSet[k.Key] {
14✔
133
                        filtered = append(filtered, k)
7✔
134
                }
7✔
135
        }
136

137
        // filter by prefix if specified
138
        prefix := r.URL.Query().Get("prefix")
4✔
139
        if prefix != "" {
5✔
140
                var prefixed []store.KeyInfo
1✔
141
                for _, k := range filtered {
4✔
142
                        if strings.HasPrefix(k.Key, prefix) {
5✔
143
                                prefixed = append(prefixed, k)
2✔
144
                        }
2✔
145
                }
146
                filtered = prefixed
1✔
147
        }
148

149
        log.Printf("[DEBUG] list keys: %d found, %d after auth filter", len(keys), len(filtered))
4✔
150
        rest.RenderJSON(w, filtered)
4✔
151
}
152

153
// filterKeysByAuth filters keys based on the caller's auth credentials.
154
// returns nil if auth is required but caller has no valid credentials.
155
// priority: session cookie > Bearer token > public ACL
156
func (h *Handler) filterKeysByAuth(r *http.Request, keys []string) []string {
13✔
157
        // no auth = return all keys
13✔
158
        if h.auth == nil || !h.auth.Enabled() {
21✔
159
                return keys
8✔
160
        }
8✔
161

162
        // check session cookie first (authenticated user has priority over public)
163
        for _, cookieName := range cookie.SessionCookieNames {
15✔
164
                c, err := r.Cookie(cookieName)
10✔
165
                if err != nil {
19✔
166
                        continue
9✔
167
                }
168
                username, valid := h.auth.GetSessionUser(r.Context(), c.Value)
1✔
169
                if valid {
2✔
170
                        return h.auth.FilterUserKeys(username, keys)
1✔
171
                }
1✔
172
        }
173

174
        // check Bearer token (authenticated token has priority over public)
175
        authHeader := r.Header.Get("Authorization")
4✔
176
        if token, found := strings.CutPrefix(authHeader, "Bearer "); found {
5✔
177
                if filtered := h.auth.FilterTokenKeys(token, keys); filtered != nil {
2✔
178
                        return filtered
1✔
179
                }
1✔
180
        }
181

182
        // fall back to public access for unauthenticated requests
183
        if filtered := h.auth.FilterPublicKeys(keys); filtered != nil {
4✔
184
                return filtered
1✔
185
        }
1✔
186

187
        return nil // no valid auth
2✔
188
}
189

190
// handleGet retrieves the value for a key.
191
// GET /kv/{key...}
192
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
5✔
193
        key := store.NormalizeKey(r.PathValue("key"))
5✔
194
        if key == "" {
6✔
195
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
196
                return
1✔
197
        }
1✔
198

199
        value, format, err := h.store.GetWithFormat(r.Context(), key)
4✔
200
        if errors.Is(err, store.ErrSecretsNotConfigured) {
5✔
201
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "secrets not configured")
1✔
202
                return
1✔
203
        }
1✔
204
        if errors.Is(err, store.ErrNotFound) {
4✔
205
                rest.SendErrorJSON(w, r, log.Default(), http.StatusNotFound, err, "key not found")
1✔
206
                return
1✔
207
        }
1✔
208
        if err != nil {
2✔
209
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to get key")
×
210
                return
×
211
        }
×
212

213
        log.Printf("[DEBUG] get %s (%d bytes, format=%s)", key, len(value), format)
2✔
214

2✔
215
        w.Header().Set("Content-Type", h.formatToContentType(format))
2✔
216
        w.WriteHeader(http.StatusOK)
2✔
217
        if _, err := w.Write(value); err != nil {
2✔
218
                log.Printf("[WARN] failed to write response: %v", err)
×
219
        }
×
220
}
221

222
// formatToContentType maps storage format to HTTP Content-Type
223
func (h *Handler) formatToContentType(format string) string {
11✔
224
        if f, err := stash.ParseFormat(format); err == nil {
21✔
225
                return f.ContentType()
10✔
226
        }
10✔
227
        return "application/octet-stream"
1✔
228
}
229

230
// handleSet stores a value for a key.
231
// PUT /kv/{key...}
232
// accepts format via X-Stash-Format header or ?format= query param (defaults to "text")
233
func (h *Handler) handleSet(w http.ResponseWriter, r *http.Request) {
8✔
234
        key := store.NormalizeKey(r.PathValue("key"))
8✔
235
        if key == "" {
9✔
236
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
237
                return
1✔
238
        }
1✔
239

240
        value, err := io.ReadAll(r.Body)
7✔
241
        if err != nil {
7✔
242
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "failed to read body")
×
243
                return
×
244
        }
×
245

246
        // get format from header or query param, default to "text"
247
        format := r.Header.Get("X-Stash-Format")
7✔
248
        if format == "" {
12✔
249
                format = r.URL.Query().Get("format")
5✔
250
        }
5✔
251
        if !h.formatValidator.IsValidFormat(format) {
12✔
252
                format = "text"
5✔
253
        }
5✔
254

255
        if err := h.store.Set(r.Context(), key, value, format); err != nil {
9✔
256
                if errors.Is(err, store.ErrSecretsNotConfigured) {
3✔
257
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "secrets not configured")
1✔
258
                        return
1✔
259
                }
1✔
260
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to set key")
1✔
261
                return
1✔
262
        }
263

264
        log.Printf("[INFO] set %q (%d bytes, format=%s) by %s", key, len(value), format, h.getIdentityForLog(r))
5✔
265

5✔
266
        // commit to git if enabled
5✔
267
        if h.git != nil {
6✔
268
                req := git.CommitRequest{Key: key, Value: value, Operation: "set", Format: format, Author: h.getAuthorFromRequest(r)}
1✔
269
                if err := h.git.Commit(req); err != nil {
1✔
UNCOV
270
                        log.Printf("[WARN] git commit failed for %s: %v", key, err)
×
UNCOV
271
                }
×
272
        }
273

274
        w.WriteHeader(http.StatusOK)
5✔
275
}
276

277
// handleDelete removes a key from the store.
278
// DELETE /kv/{key...}
279
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
5✔
280
        key := store.NormalizeKey(r.PathValue("key"))
5✔
281
        if key == "" {
6✔
282
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
283
                return
1✔
284
        }
1✔
285

286
        err := h.store.Delete(r.Context(), key)
4✔
287
        if errors.Is(err, store.ErrNotFound) {
5✔
288
                rest.SendErrorJSON(w, r, log.Default(), http.StatusNotFound, err, "key not found")
1✔
289
                return
1✔
290
        }
1✔
291
        if err != nil {
4✔
292
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to delete key")
1✔
293
                return
1✔
294
        }
1✔
295

296
        log.Printf("[INFO] delete %q by %s", key, h.getIdentityForLog(r))
2✔
297

2✔
298
        // delete from git if enabled
2✔
299
        if h.git != nil {
3✔
300
                if err := h.git.Delete(key, h.getAuthorFromRequest(r)); err != nil {
1✔
UNCOV
301
                        log.Printf("[WARN] git delete failed for %s: %v", key, err)
×
UNCOV
302
                }
×
303
        }
304

305
        w.WriteHeader(http.StatusNoContent)
2✔
306
}
307

308
// historyResponse represents a single entry in the history response.
309
type historyResponse struct {
310
        Hash      string `json:"hash"`
311
        Timestamp string `json:"timestamp"`
312
        Author    string `json:"author"`
313
        Operation string `json:"operation"`
314
        Format    string `json:"format"`
315
        Value     string `json:"value"` // base64 encoded
316
}
317

318
// handleHistory returns the commit history for a key.
319
// GET /kv/history/{key...}
320
func (h *Handler) handleHistory(w http.ResponseWriter, r *http.Request) {
6✔
321
        if h.git == nil {
7✔
322
                rest.SendErrorJSON(w, r, log.Default(), http.StatusServiceUnavailable, nil, "git integration not enabled")
1✔
323
                return
1✔
324
        }
1✔
325

326
        key := store.NormalizeKey(r.PathValue("key"))
5✔
327
        if key == "" {
6✔
328
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
329
                return
1✔
330
        }
1✔
331

332
        // check read permission
333
        filtered := h.filterKeysByAuth(r, []string{key})
4✔
334
        if len(filtered) == 0 {
5✔
335
                rest.SendErrorJSON(w, r, log.Default(), http.StatusForbidden, nil, "access denied")
1✔
336
                return
1✔
337
        }
1✔
338

339
        history, err := h.git.History(key, 50)
3✔
340
        if err != nil {
4✔
341
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to get history")
1✔
342
                return
1✔
343
        }
1✔
344

345
        // base64-encode values to safely transmit arbitrary binary data in JSON
346
        resp := make([]historyResponse, len(history))
2✔
347
        for i, entry := range history {
4✔
348
                resp[i] = historyResponse{
2✔
349
                        Hash:      entry.Hash,
2✔
350
                        Timestamp: entry.Timestamp.UTC().Format(time.RFC3339),
2✔
351
                        Author:    entry.Author,
2✔
352
                        Operation: entry.Operation,
2✔
353
                        Format:    entry.Format,
2✔
354
                        Value:     base64.StdEncoding.EncodeToString(entry.Value),
2✔
355
                }
2✔
356
        }
2✔
357

358
        rest.RenderJSON(w, resp)
2✔
359
}
360

361
// identityType represents the type of identity detected from a request.
362
type identityType int
363

364
const (
365
        identityAnonymous identityType = iota
366
        identityUser
367
        identityToken
368
)
369

370
// identity holds information about who made a request.
371
type identity struct {
372
        typ  identityType
373
        name string // username or token prefix
374
}
375

376
// getIdentity extracts identity from request context.
377
// returns user identity from session cookie, token identity from Authorization header, or anonymous.
378
func (h *Handler) getIdentity(r *http.Request) identity {
17✔
379
        if h.auth == nil {
19✔
380
                return identity{typ: identityAnonymous}
2✔
381
        }
2✔
382

383
        // check session cookie for web UI users
384
        for _, cookieName := range cookie.SessionCookieNames {
45✔
385
                c, err := r.Cookie(cookieName)
30✔
386
                if err != nil {
57✔
387
                        continue
27✔
388
                }
389
                if username, valid := h.auth.GetSessionUser(r.Context(), c.Value); valid && username != "" {
6✔
390
                        return identity{typ: identityUser, name: username}
3✔
391
                }
3✔
392
        }
393

394
        // check API token from Authorization header
395
        if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
14✔
396
                token := strings.TrimPrefix(authHeader, "Bearer ")
2✔
397
                if h.auth.HasTokenACL(token) {
4✔
398
                        prefix := token
2✔
399
                        if len(prefix) > 8 {
4✔
400
                                prefix = prefix[:8]
2✔
401
                        }
2✔
402
                        return identity{typ: identityToken, name: "token:" + prefix}
2✔
403
                }
404
        }
405

406
        return identity{typ: identityAnonymous}
10✔
407
}
408

409
// getAuthorFromRequest extracts the git author from request context.
410
// returns username from session cookie for web UI users, token prefix for API tokens, default author otherwise.
411
func (h *Handler) getAuthorFromRequest(r *http.Request) git.Author {
5✔
412
        id := h.getIdentity(r)
5✔
413
        switch id.typ {
5✔
414
        case identityUser, identityToken:
2✔
415
                return git.Author{Name: id.name, Email: id.name + "@stash"}
2✔
416
        default:
3✔
417
                return git.DefaultAuthor()
3✔
418
        }
419
}
420

421
// getIdentityForLog returns identity string for audit logging.
422
// returns "user:xxx" for web UI users, "token:xxx" for API tokens, "anonymous" otherwise.
423
func (h *Handler) getIdentityForLog(r *http.Request) string {
9✔
424
        id := h.getIdentity(r)
9✔
425
        switch id.typ {
9✔
426
        case identityUser:
1✔
427
                return "user:" + id.name
1✔
UNCOV
428
        case identityToken:
×
UNCOV
429
                return id.name // already has "token:" prefix
×
430
        default:
8✔
431
                return "anonymous"
8✔
432
        }
433
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc