feat(kms): metadata-only secret listing — GET /v1/kms/orgs/{org}/secrets
Add a bare list endpoint so an operator console can browse an org's secret
KEYS without ever exposing a value. Structurally incapable of leaking a value:
the scan iterates ZapDB KEYS ONLY (PrefetchValues=false) under
kms/secrets/<brand/org>/ and the row type has no value field — there is no
code path from this handler to a secret value blob.
Verified against the store: secret path/name/env are PLAINTEXT in the ZapDB
key kms/secrets/{path}/{env}/{name}; only the value blob is at-rest encrypted.
So names are honestly listable; values are never touched.
- Authz: identical authorize()+canActOnOrg() gate as get-one — never weaker.
Structurally confined to the org's brand/<org>/ namespace (stricter: cannot
enumerate another tenant's names). Optional ?prefix= (safePath + org
containment) and ?env= filters.
- Routing: bare GET coexists with the {rest...} wildcard (Go 1.22 ServeMux);
the bare path hits list, .../secrets/<path>/<name> still returns its value.
- updatedTime sourced from a new kms/mtimes/ sibling index (mirrors
versioning.go), written on put/patch, deleted on delete — never from the
value blob. Records predating this carry no mtime (updatedTime omitted),
never faked.
- TestRed3 traversal heuristic tightened: '/secrets/foo/..' path-cleans to
'/secrets' (the list), an authorized empty {secrets:[]} with no value; the
test now asserts on an actual value payload, not the substring 'secret'.
Tests: 9 new TestList_* (no-value-leak, authz fail-closed, routing
precedence, prefix injection-safety, env filter, empty org, mtime RFC3339).
VERSION 2.5.2 -> 0.159.4 (patch, v0.x server line; tag cut on merge, not now).
This commit is contained in:
@@ -606,6 +606,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
|
||||
if verErr != nil {
|
||||
log.Warn("kms: version bump failed after put", "err", verErr)
|
||||
}
|
||||
if mErr := writeMtime(db, req.Path, req.Name, req.Env); mErr != nil {
|
||||
log.Warn("kms: mtime write failed after put", "err", mErr)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "version": newVer})
|
||||
recordAudit(claims, r, req.Path, req.Name, req.Env, http.StatusCreated, newVer)
|
||||
}
|
||||
@@ -710,6 +713,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
|
||||
recordAudit(claims, r, path, name, env, http.StatusInternalServerError, 0)
|
||||
return
|
||||
}
|
||||
if mErr := writeMtime(db, path, name, env); mErr != nil {
|
||||
log.Warn("kms: mtime write failed after patch", "err", mErr)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": newVer})
|
||||
recordAudit(claims, r, path, name, env, http.StatusOK, newVer)
|
||||
}
|
||||
@@ -741,13 +747,84 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
|
||||
recordAudit(claims, r, path, name, env, http.StatusNotFound, 0)
|
||||
return
|
||||
}
|
||||
// Clear version record so a re-create starts from 1 again.
|
||||
// Clear version + mtime records so a re-create starts clean.
|
||||
_ = deleteVersion(db, path, name, env)
|
||||
_ = deleteMtime(db, path, name, env)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
recordAudit(claims, r, path, name, env, http.StatusOK, 0)
|
||||
}
|
||||
|
||||
// list (R-LIST): metadata-only browse of an org's secret KEYS. By
|
||||
// construction it cannot return a value — see listSecretMetadata
|
||||
// (keys-only scan, no value field on the row type). Authorization is the
|
||||
// IDENTICAL authorize()+canActOnOrg() gate the other secret ops use: a
|
||||
// caller who cannot read the org's secrets cannot list them.
|
||||
list := func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := authorize(w, r)
|
||||
if !ok {
|
||||
recordAudit(claims, r, "", "", "", http.StatusUnauthorized, 0)
|
||||
return
|
||||
}
|
||||
orgURL := r.PathValue("org")
|
||||
if !claims.canActOnOrg(orgURL) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"message": "org claim does not match URL"})
|
||||
recordAudit(claims, r, "", "", "", http.StatusForbidden, 0)
|
||||
return
|
||||
}
|
||||
if !safePath(orgURL) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid org"})
|
||||
recordAudit(claims, r, "", "", "", http.StatusBadRequest, 0)
|
||||
return
|
||||
}
|
||||
// Structural org confinement: the scan is rooted at the org's canonical
|
||||
// brand/<org> namespace and cannot escape it. This is STRICTER than
|
||||
// get-one (it can never enumerate another tenant's secret names) while
|
||||
// using the SAME authorize()+canActOnOrg() gate — never weaker.
|
||||
// orgRoot carries no trailing slash; listSecretMetadata appends the
|
||||
// boundary slash so brand/<org> never matches brand/<org>foo.
|
||||
orgRoot := "brand/" + orgURL
|
||||
listPath := orgRoot
|
||||
if qp := r.URL.Query().Get("prefix"); qp != "" {
|
||||
qp = strings.TrimSuffix(qp, "/") // accept the directory-style brand/<org>/ form
|
||||
if qp == "" || !safePath(qp) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid prefix"})
|
||||
recordAudit(claims, r, qp, "", "", http.StatusBadRequest, 0)
|
||||
return
|
||||
}
|
||||
// Containment: the prefix must be the org root or strictly within it.
|
||||
if qp != orgRoot && !strings.HasPrefix(qp, orgRoot+"/") {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"message": "prefix must be within brand/" + orgURL + "/"})
|
||||
recordAudit(claims, r, qp, "", "", http.StatusForbidden, 0)
|
||||
return
|
||||
}
|
||||
listPath = qp
|
||||
}
|
||||
envFilter := r.URL.Query().Get("env")
|
||||
if envFilter != "" && (!safePath(envFilter) || strings.Contains(envFilter, "/")) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid env"})
|
||||
recordAudit(claims, r, listPath, "", envFilter, http.StatusBadRequest, 0)
|
||||
return
|
||||
}
|
||||
rows, truncated := listSecretMetadata(db, listPath, envFilter)
|
||||
resp := map[string]any{"secrets": rows, "count": len(rows)}
|
||||
if truncated {
|
||||
resp["truncated"] = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
recordAudit(claims, r, listPath, "", envFilter, http.StatusOK, 0)
|
||||
}
|
||||
|
||||
// Canonical (lux native).
|
||||
//
|
||||
// Routing precedence (Go 1.22+ net/http ServeMux): the bare pattern
|
||||
// ".../secrets" and the wildcard ".../secrets/{rest...}" do NOT conflict —
|
||||
// no request path matches both (the wildcard requires a literal "/secrets/"
|
||||
// prefix; the bare matches exactly "/secrets"). Registering the explicit
|
||||
// bare GET also suppresses any subtree redirect to ".../secrets/". Net
|
||||
// effect: GET on the bare path hits list; GET on ".../secrets/<path>/<name>"
|
||||
// still hits get-one and returns its value. Confirmed by
|
||||
// TestList_RoutingPrecedence_NoShadowGetOne.
|
||||
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets", list)
|
||||
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets/{rest...}", get)
|
||||
mux.HandleFunc("POST /v1/kms/orgs/{org}/secrets", put)
|
||||
mux.HandleFunc("PATCH /v1/kms/orgs/{org}/secrets/{rest...}", patch)
|
||||
|
||||
+9
-4
@@ -254,11 +254,16 @@ func TestRed3_PathTraversalBlocked(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
if resp.StatusCode != 400 && resp.StatusCode != 404 {
|
||||
// 404 is acceptable when net/http normalizes the path so it
|
||||
// never reaches our handler. What's NOT acceptable is 200.
|
||||
// 404 is acceptable when net/http normalizes the path so it never
|
||||
// reaches our handler. A traversal path may also normalize onto a
|
||||
// legitimate route: "/secrets/foo/.." cleans (path.Clean) to
|
||||
// "/secrets", the metadata list, which returns an empty
|
||||
// {"secrets":[],"count":0} — no value — to the authorized caller.
|
||||
// That is not a leak. The real threat is a get-one VALUE payload
|
||||
// escaping via traversal, so assert no value field is present.
|
||||
body, _ := readBody(resp)
|
||||
if strings.Contains(body, "secret") {
|
||||
t.Fatalf("%s: returned a secret payload, status=%d", p, resp.StatusCode)
|
||||
if strings.Contains(body, `"value"`) || strings.Contains(body, "secretValue") {
|
||||
t.Fatalf("%s: returned a secret value payload, status=%d body=%s", p, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// Metadata-only secret listing (R-LIST) + wall-clock mtime tracking.
|
||||
//
|
||||
// This file owns the read-only "browse an org's secret KEYS" surface that
|
||||
// the operator console needs. It is, by construction, incapable of returning
|
||||
// a secret value:
|
||||
//
|
||||
// - The scan iterates ZapDB KEYS ONLY (PrefetchValues=false). The encrypted
|
||||
// value blob under kms/secrets/{path}/{env}/{name} is never read from the
|
||||
// LSM into the handler. There is no code path from this file to a value.
|
||||
// - The row type (secretMetaRow) has no value/ciphertext field, so even a
|
||||
// future careless edit cannot serialize a value.
|
||||
//
|
||||
// What IS listable, verified against the actual store (github.com/luxfi/kms
|
||||
// pkg/store): secret path/name/env are stored in PLAINTEXT as the ZapDB key
|
||||
// `kms/secrets/{path}/{env}/{name}`. Only the value blob is protected (ZapDB
|
||||
// at-rest encryption). So names/paths are honestly enumerable metadata; the
|
||||
// value is not, and is never touched here.
|
||||
//
|
||||
// Two sibling-key families parallel the secret records, mirroring the
|
||||
// versioning.go pattern (no upstream schema change):
|
||||
//
|
||||
// kms/versions/{path}/{env}/{name} → monotonic int64 (versioning.go)
|
||||
// kms/mtimes/{path}/{env}/{name} → wall-clock UnixNano int64 (this file)
|
||||
//
|
||||
// updatedTime is sourced from kms/mtimes — NOT from the value blob (whose
|
||||
// UpdatedAt the Hanzo write path never set) and NOT from ZapDB's commit
|
||||
// counter (a logical version, not wall-clock). A record written before this
|
||||
// change carries no mtime row and is reported with updatedTime omitted —
|
||||
// honestly "unknown", never faked.
|
||||
package kms
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
badger "github.com/luxfi/zapdb"
|
||||
)
|
||||
|
||||
// maxListRows caps a single metadata listing so a pathological namespace
|
||||
// cannot exhaust server memory or produce an unbounded response. The scan is
|
||||
// keys-only and cheap, but the response is still bounded. A metadata row is a
|
||||
// few hundred bytes, so 10k rows is well under a MiB.
|
||||
const maxListRows = 10000
|
||||
|
||||
// secretMetaRow is one row of the metadata listing. It deliberately has NO
|
||||
// value/ciphertext field — the list endpoint is structurally incapable of
|
||||
// emitting a secret value.
|
||||
type secretMetaRow struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Env string `json:"env"`
|
||||
Version int64 `json:"version"`
|
||||
UpdatedTime string `json:"updatedTime,omitempty"` // RFC3339; omitted when unknown
|
||||
}
|
||||
|
||||
// listSecretMetadata returns metadata rows for every secret whose key falls
|
||||
// under kms/secrets/<listPath>/, optionally filtered to envFilter. listPath
|
||||
// carries no trailing slash; the boundary slash is appended here so a prefix
|
||||
// of "brand/hanzo" matches "brand/hanzo/..." but never "brand/hanzofoo/...".
|
||||
// It NEVER reads a secret value: phase 1 iterates keys only; phase 2 reads the
|
||||
// version and mtime sibling counters (not the value). Returns (rows, truncated).
|
||||
func listSecretMetadata(db *badger.DB, listPath, envFilter string) (rows []secretMetaRow, truncated bool) {
|
||||
// Mirror store.secretKey's layout: kms/secrets/{path}/{env}/{name}.
|
||||
dbPrefix := []byte("kms/secrets/" + listPath + "/")
|
||||
|
||||
type ref struct{ path, name, env string }
|
||||
var refs []ref
|
||||
|
||||
_ = db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.PrefetchValues = false // KEYS ONLY — the value blob is never loaded.
|
||||
opts.Prefix = dbPrefix
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
// KeyCopy: Item.Key() is only valid for the current iteration step.
|
||||
key := string(it.Item().KeyCopy(nil))
|
||||
rel := strings.TrimPrefix(key, "kms/secrets/")
|
||||
p, e, n, ok := parseSecretRelKey(rel)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if envFilter != "" && e != envFilter {
|
||||
continue
|
||||
}
|
||||
if len(refs) >= maxListRows {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
refs = append(refs, ref{path: p, name: n, env: e})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Phase 2: enrich with version + mtime (sibling counters, NOT the value).
|
||||
rows = make([]secretMetaRow, 0, len(refs))
|
||||
for _, rf := range refs {
|
||||
ver, _ := readVersion(db, rf.path, rf.name, rf.env)
|
||||
row := secretMetaRow{Path: rf.path, Name: rf.name, Env: rf.env, Version: ver}
|
||||
if ns, _ := readMtime(db, rf.path, rf.name, rf.env); ns > 0 {
|
||||
row.UpdatedTime = time.Unix(0, ns).UTC().Format(time.RFC3339)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, truncated
|
||||
}
|
||||
|
||||
// parseSecretRelKey inverts store.secretKey's "{path}/{env}/{name}" layout
|
||||
// (the portion after the kms/secrets/ prefix). name is the final segment,
|
||||
// env the penultimate, path everything before. Returns ok=false for any key
|
||||
// that does not carry a full path/env/name triple.
|
||||
func parseSecretRelKey(rel string) (path, env, name string, ok bool) {
|
||||
ls := strings.LastIndex(rel, "/")
|
||||
if ls < 0 {
|
||||
return "", "", "", false
|
||||
}
|
||||
name = rel[ls+1:]
|
||||
head := rel[:ls] // {path}/{env}
|
||||
es := strings.LastIndex(head, "/")
|
||||
if es < 0 {
|
||||
return "", "", "", false
|
||||
}
|
||||
env = head[es+1:]
|
||||
path = head[:es]
|
||||
if path == "" || env == "" || name == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
return path, env, name, true
|
||||
}
|
||||
|
||||
// --- mtime sibling index (parallel to versioning.go) ---
|
||||
|
||||
// mtimeKey returns the ZapDB key for a secret's wall-clock update time.
|
||||
// Kept parallel to store.secretKey / versionKey so a prefix scan still works.
|
||||
func mtimeKey(path, name, env string) []byte {
|
||||
return []byte(fmt.Sprintf("kms/mtimes/%s/%s/%s", path, env, name))
|
||||
}
|
||||
|
||||
// writeMtime records the current wall-clock time (UnixNano, big-endian int64)
|
||||
// for a secret. Called from the write handlers after a successful Put. Best
|
||||
// effort: a failure is logged by the caller, never fatal to the write.
|
||||
func writeMtime(db *badger.DB, path, name, env string) error {
|
||||
buf := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(buf, uint64(time.Now().UnixNano()))
|
||||
return db.Update(func(txn *badger.Txn) error {
|
||||
return txn.Set(mtimeKey(path, name, env), buf)
|
||||
})
|
||||
}
|
||||
|
||||
// readMtime returns the recorded UnixNano update time, or 0 if none was
|
||||
// recorded (the secret predates mtime tracking — honestly "unknown").
|
||||
func readMtime(db *badger.DB, path, name, env string) (int64, error) {
|
||||
var ns int64
|
||||
err := db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get(mtimeKey(path, name, env))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return nil // ns stays 0
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return item.Value(func(b []byte) error {
|
||||
if len(b) != 8 {
|
||||
return fmt.Errorf("kms: malformed mtime record (len=%d)", len(b))
|
||||
}
|
||||
ns = int64(binary.BigEndian.Uint64(b))
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return ns, err
|
||||
}
|
||||
|
||||
// deleteMtime removes the mtime record when a secret is deleted, so a later
|
||||
// re-create does not surface a stale update time.
|
||||
func deleteMtime(db *badger.DB, path, name, env string) error {
|
||||
return db.Update(func(txn *badger.Txn) error {
|
||||
err := txn.Delete(mtimeKey(path, name, env))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// Tests for the metadata-only secret listing endpoint
|
||||
// GET /v1/kms/orgs/{org}/secrets.
|
||||
//
|
||||
// The contract under test, in order of importance:
|
||||
// 1. No value can ever appear in the response (structural no-leak).
|
||||
// 2. Authorization is identical to get-one (authorize()+canActOnOrg()) —
|
||||
// never weaker. A caller who cannot read the org cannot list it.
|
||||
// 3. The bare GET does not shadow get-one (Go 1.22 routing precedence).
|
||||
// 4. prefix/env filters work and are injection-safe (safePath + org
|
||||
// confinement).
|
||||
package kms
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// seedSecret POSTs a secret through the real handler (which also writes the
|
||||
// version + mtime sibling records). org is the URL org segment; tok must be
|
||||
// authorized for it.
|
||||
func seedSecret(t *testing.T, srvURL, tok, org, path, name, env, value string) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{"path": path, "name": name, "env": env, "value": value})
|
||||
req, _ := http.NewRequest("POST", srvURL+"/v1/kms/orgs/"+org+"/secrets", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("seed %s/%s: %v", path, name, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 201 {
|
||||
t.Fatalf("seed %s/%s: want 201, got %d", path, name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// listRaw performs the bare GET list and returns (status, rawBody).
|
||||
func listRaw(t *testing.T, srvURL, tok, org, query string) (int, string) {
|
||||
t.Helper()
|
||||
url := srvURL + "/v1/kms/orgs/" + org + "/secrets"
|
||||
if query != "" {
|
||||
url += "?" + query
|
||||
}
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
type listResponse struct {
|
||||
Secrets []map[string]json.RawMessage `json:"secrets"`
|
||||
Count int `json:"count"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
func parseList(t *testing.T, raw string) listResponse {
|
||||
t.Helper()
|
||||
var lr listResponse
|
||||
if err := json.Unmarshal([]byte(raw), &lr); err != nil {
|
||||
t.Fatalf("parse list response %q: %v", raw, err)
|
||||
}
|
||||
return lr
|
||||
}
|
||||
|
||||
// allowedRowKeys is the exact set of keys a metadata row may carry. Anything
|
||||
// else (especially "value"/"ciphertext") is a leak.
|
||||
var allowedRowKeys = map[string]bool{
|
||||
"path": true, "name": true, "env": true, "version": true, "updatedTime": true,
|
||||
}
|
||||
|
||||
// assertNoValueLeak fails if any value-bearing key or the sentinel value
|
||||
// appears anywhere in the response.
|
||||
func assertNoValueLeak(t *testing.T, raw, sentinel string, lr listResponse) {
|
||||
t.Helper()
|
||||
for _, bad := range []string{`"value"`, `"ciphertext"`, `"wrapped_dek"`, `"secretValue"`, `"secretKey"`, sentinel} {
|
||||
if bad != "" && strings.Contains(raw, bad) {
|
||||
t.Fatalf("value leak: response contains %q\nbody=%s", bad, raw)
|
||||
}
|
||||
}
|
||||
for i, row := range lr.Secrets {
|
||||
for k := range row {
|
||||
if !allowedRowKeys[k] {
|
||||
t.Fatalf("row %d has disallowed key %q (possible leak)\nbody=%s", i, k, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (a) authorized list returns metadata rows.
|
||||
func TestList_AuthorizedReturnsMetadata(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t)
|
||||
defer cleanup()
|
||||
tok := mintToken(t, "hanzo", "user-1")
|
||||
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", "S1")
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/stripe", "LIVE_KEY", "default", "S2")
|
||||
|
||||
status, raw := listRaw(t, srv.URL, tok, "hanzo", "")
|
||||
if status != 200 {
|
||||
t.Fatalf("want 200, got %d: %s", status, raw)
|
||||
}
|
||||
lr := parseList(t, raw)
|
||||
if lr.Count != 2 || len(lr.Secrets) != 2 {
|
||||
t.Fatalf("want count 2, got %d: %s", lr.Count, raw)
|
||||
}
|
||||
// Every row carries the metadata fields; version >= 1; paths are present.
|
||||
seenPlivo, seenStripe := false, false
|
||||
for _, row := range lr.Secrets {
|
||||
var path, name string
|
||||
var version int64
|
||||
json.Unmarshal(row["path"], &path)
|
||||
json.Unmarshal(row["name"], &name)
|
||||
if _, ok := row["version"]; !ok {
|
||||
t.Fatalf("row missing version: %v", row)
|
||||
}
|
||||
json.Unmarshal(row["version"], &version)
|
||||
if version < 1 {
|
||||
t.Fatalf("want version >= 1, got %d", version)
|
||||
}
|
||||
if path == "brand/hanzo/plivo" && name == "AUTH_TOKEN" {
|
||||
seenPlivo = true
|
||||
}
|
||||
if path == "brand/hanzo/stripe" && name == "LIVE_KEY" {
|
||||
seenStripe = true
|
||||
}
|
||||
}
|
||||
if !seenPlivo || !seenStripe {
|
||||
t.Fatalf("missing expected rows: plivo=%v stripe=%v\n%s", seenPlivo, seenStripe, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// (b) unauthorized: wrong owner → 403; no token → 401. The list must be no
|
||||
// weaker than get-one.
|
||||
func TestList_AuthzFailClosed(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed as hanzo so there IS data to (not) leak.
|
||||
hanzoTok := mintToken(t, "hanzo", "user-1")
|
||||
seedSecret(t, srv.URL, hanzoTok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", "TOPSECRET")
|
||||
|
||||
// Wrong owner, no role → 403.
|
||||
evil := mintToken(t, "evil-org", "attacker")
|
||||
status, raw := listRaw(t, srv.URL, evil, "hanzo", "")
|
||||
if status != 403 {
|
||||
t.Fatalf("wrong-owner list: want 403, got %d: %s", status, raw)
|
||||
}
|
||||
if strings.Contains(raw, "TOPSECRET") || strings.Contains(raw, "AUTH_TOKEN") {
|
||||
t.Fatalf("403 response leaked metadata/value: %s", raw)
|
||||
}
|
||||
|
||||
// No token → 401.
|
||||
status, _ = listRaw(t, srv.URL, "", "hanzo", "")
|
||||
if status != 401 {
|
||||
t.Fatalf("no-token list: want 401, got %d", status)
|
||||
}
|
||||
|
||||
// Admin (role, different owner) may list via canActOnOrg → 200.
|
||||
admin := mintToken(t, "ops", "admin-1", "superadmin")
|
||||
status, _ = listRaw(t, srv.URL, admin, "hanzo", "")
|
||||
if status != 200 {
|
||||
t.Fatalf("admin list: want 200, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// (c) response JSON contains NO value field — parse and assert, and the
|
||||
// sentinel value never appears.
|
||||
func TestList_NoValueLeak(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t)
|
||||
defer cleanup()
|
||||
tok := mintToken(t, "hanzo", "user-1")
|
||||
|
||||
const sentinel = "PLIVO_AUTH_TOKEN_5551234_DO_NOT_LEAK"
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", sentinel)
|
||||
|
||||
status, raw := listRaw(t, srv.URL, tok, "hanzo", "")
|
||||
if status != 200 {
|
||||
t.Fatalf("want 200, got %d: %s", status, raw)
|
||||
}
|
||||
lr := parseList(t, raw)
|
||||
if lr.Count != 1 {
|
||||
t.Fatalf("want 1 row, got %d", lr.Count)
|
||||
}
|
||||
assertNoValueLeak(t, raw, sentinel, lr)
|
||||
}
|
||||
|
||||
// (d) prefix filter works and is injection-safe.
|
||||
func TestList_PrefixFilterAndInjection(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t)
|
||||
defer cleanup()
|
||||
tok := mintToken(t, "hanzo", "user-1")
|
||||
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", "S1")
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "ACCOUNT_SID", "default", "S2")
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/stripe", "LIVE_KEY", "default", "S3")
|
||||
|
||||
// Narrow to plivo only.
|
||||
status, raw := listRaw(t, srv.URL, tok, "hanzo", "prefix=brand/hanzo/plivo/")
|
||||
if status != 200 {
|
||||
t.Fatalf("prefix list: want 200, got %d: %s", status, raw)
|
||||
}
|
||||
lr := parseList(t, raw)
|
||||
if lr.Count != 2 {
|
||||
t.Fatalf("prefix=plivo: want 2 rows, got %d: %s", lr.Count, raw)
|
||||
}
|
||||
for _, row := range lr.Secrets {
|
||||
var path string
|
||||
json.Unmarshal(row["path"], &path)
|
||||
if !strings.HasPrefix(path, "brand/hanzo/plivo") {
|
||||
t.Fatalf("prefix filter bleed: got path %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Injection-safe: traversal, double-slash, null byte → 400; cross-org
|
||||
// prefix → 403. None may return 200.
|
||||
injection := map[string]int{
|
||||
"prefix=brand/hanzo/../lux/": 400, // ".." segment rejected by safePath
|
||||
"prefix=brand/hanzo//x/": 400, // "//" rejected by safePath
|
||||
"prefix=brand/hanzo/%00": 400, // null byte rejected by safePath
|
||||
"prefix=brand/lux/": 403, // valid path but outside org namespace
|
||||
"prefix=../../etc/": 400, // traversal
|
||||
}
|
||||
for q, want := range injection {
|
||||
status, raw := listRaw(t, srv.URL, tok, "hanzo", q)
|
||||
if status != want {
|
||||
t.Fatalf("injection %q: want %d, got %d: %s", q, want, status, raw)
|
||||
}
|
||||
if status == 200 {
|
||||
t.Fatalf("injection %q must not return 200", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// env filter narrows to one environment.
|
||||
func TestList_EnvFilter(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t)
|
||||
defer cleanup()
|
||||
tok := mintToken(t, "hanzo", "user-1")
|
||||
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "dev", "D")
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "prod", "P")
|
||||
|
||||
status, raw := listRaw(t, srv.URL, tok, "hanzo", "env=dev")
|
||||
if status != 200 {
|
||||
t.Fatalf("env list: want 200, got %d", status)
|
||||
}
|
||||
lr := parseList(t, raw)
|
||||
if lr.Count != 1 {
|
||||
t.Fatalf("env=dev: want 1 row, got %d: %s", lr.Count, raw)
|
||||
}
|
||||
var env string
|
||||
json.Unmarshal(lr.Secrets[0]["env"], &env)
|
||||
if env != "dev" {
|
||||
t.Fatalf("env filter: want dev, got %q", env)
|
||||
}
|
||||
}
|
||||
|
||||
// (e) empty org → empty list, count 0 (and an empty [], not null).
|
||||
func TestList_EmptyOrg(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t)
|
||||
defer cleanup()
|
||||
tok := mintToken(t, "hanzo", "user-1")
|
||||
|
||||
status, raw := listRaw(t, srv.URL, tok, "hanzo", "")
|
||||
if status != 200 {
|
||||
t.Fatalf("empty list: want 200, got %d", status)
|
||||
}
|
||||
lr := parseList(t, raw)
|
||||
if lr.Count != 0 || len(lr.Secrets) != 0 {
|
||||
t.Fatalf("empty org: want count 0, got %d: %s", lr.Count, raw)
|
||||
}
|
||||
if !strings.Contains(raw, `"secrets":[]`) {
|
||||
t.Fatalf("empty list should serialize secrets as [], got: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// (f) the bare GET does not shadow get-one: a real get-one still returns its
|
||||
// value to an authorized caller, while the bare GET returns the metadata list.
|
||||
func TestList_RoutingPrecedence_NoShadowGetOne(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t)
|
||||
defer cleanup()
|
||||
tok := mintToken(t, "hanzo", "user-1")
|
||||
|
||||
const sentinel = "GET_ONE_STILL_WORKS_42"
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", sentinel)
|
||||
|
||||
// get-one: the wildcard route still resolves and returns the value.
|
||||
req, _ := http.NewRequest("GET",
|
||||
srv.URL+"/v1/kms/orgs/hanzo/secrets/brand/hanzo/plivo/AUTH_TOKEN?env=default", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&got)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("get-one: want 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if got["secret"]["value"] != sentinel {
|
||||
t.Fatalf("get-one shadowed: want value %q, got %q", sentinel, got["secret"]["value"])
|
||||
}
|
||||
|
||||
// bare GET: hits list, returns metadata (no value), count 1.
|
||||
status, raw := listRaw(t, srv.URL, tok, "hanzo", "")
|
||||
if status != 200 {
|
||||
t.Fatalf("bare list: want 200, got %d: %s", status, raw)
|
||||
}
|
||||
if !strings.Contains(raw, `"count"`) {
|
||||
t.Fatalf("bare GET did not hit list handler (no count field): %s", raw)
|
||||
}
|
||||
lr := parseList(t, raw)
|
||||
if lr.Count != 1 {
|
||||
t.Fatalf("bare list: want 1 row, got %d", lr.Count)
|
||||
}
|
||||
assertNoValueLeak(t, raw, sentinel, lr)
|
||||
}
|
||||
|
||||
// updatedTime is populated by the write path (mtime sibling index) and is
|
||||
// valid RFC3339. Proves the metadata mtime tracking is real, not faked.
|
||||
func TestList_UpdatedTimeIsRFC3339(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t)
|
||||
defer cleanup()
|
||||
tok := mintToken(t, "hanzo", "user-1")
|
||||
|
||||
before := time.Now().Add(-2 * time.Second)
|
||||
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", "S1")
|
||||
|
||||
_, raw := listRaw(t, srv.URL, tok, "hanzo", "")
|
||||
lr := parseList(t, raw)
|
||||
if lr.Count != 1 {
|
||||
t.Fatalf("want 1 row, got %d", lr.Count)
|
||||
}
|
||||
rawTime, ok := lr.Secrets[0]["updatedTime"]
|
||||
if !ok {
|
||||
t.Fatalf("updatedTime missing for a freshly written secret: %s", raw)
|
||||
}
|
||||
var ts string
|
||||
json.Unmarshal(rawTime, &ts)
|
||||
parsed, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
t.Fatalf("updatedTime %q not RFC3339: %v", ts, err)
|
||||
}
|
||||
if parsed.Before(before) {
|
||||
t.Fatalf("updatedTime %v is before the write started %v", parsed, before)
|
||||
}
|
||||
}
|
||||
|
||||
// Admin is confined to the org namespace on the prefix just like everyone
|
||||
// else: an admin under /orgs/hanzo cannot enumerate brand/lux/ via prefix.
|
||||
// (Cross-org browse is done by addressing the other org's URL, not by
|
||||
// escaping the prefix.)
|
||||
func TestList_PrefixConfinementUniformForAdmin(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t)
|
||||
defer cleanup()
|
||||
admin := mintToken(t, "ops", "admin-1", "superadmin")
|
||||
|
||||
status, raw := listRaw(t, srv.URL, admin, "hanzo", "prefix=brand/lux/")
|
||||
if status != 403 {
|
||||
t.Fatalf("admin cross-namespace prefix: want 403, got %d: %s", status, raw)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user