kms: HTTP secret list — surface parity with the ZAP wire

The ZAP wire has carried OpSecretList (0x0042, {path, env} -> {names}) since it
was written. HTTP had get/put/delete and no list, so the two framings of the
same store disagreed about what you could ask it: a client that wanted to
enumerate had to open a ZAP connection or give up. The hanzo browser extension
gave up and pinned itself to Infisical /api/v3 paths instead.

  GET /v1/kms/orgs/{org}/secrets?path=&env=  ->  {"names": [...]}

Same handler shape and same envelope the ZAP op returns — one store, two
framings, now the same question set.

The pattern has no trailing segment, so ServeMux routes the bare collection here
and anything below it to the existing {rest...} handler. That split is the whole
risk in this change: invert it and list swallows every get, or get 400s on the
bare collection for want of a slash to split. TestSecretRoutes_ListAndGetDoNotShadow
asserts both directions, plus that an empty result is [] and never null.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-26 01:04:30 -07:00
co-authored by Hanzo Dev
parent baecefad0d
commit c199becfe4
3 changed files with 91 additions and 1 deletions
+1 -1
View File
@@ -1 +1 @@
1.12.8 1.12.9
+61
View File
@@ -152,3 +152,64 @@ func TestSecretRoutes_UnauthOrgReadRejected(t *testing.T) {
t.Fatalf("unauth org read: got %d want 401", resp.StatusCode) t.Fatalf("unauth org read: got %d want 401", resp.StatusCode)
} }
} }
// TestSecretRoutes_ListAndGetDoNotShadow pins the two GET patterns apart.
//
// `GET .../secrets` (the collection) and `GET .../secrets/{rest...}` (one
// secret) are registered on the same mux. ServeMux routes the bare collection
// to the first and anything below it to the second; if that ever inverts, list
// would swallow every get, or get would 400 on the bare collection because
// {rest...} has no slash to split. Both directions are asserted here.
func TestSecretRoutes_ListAndGetDoNotShadow(t *testing.T) {
auth, bearer, cleanup := newTestKeyAuth(t, roleKMSAdmin)
defer cleanup()
secStore := newTestSecretStore(t)
if err := secStore.Put(&store.Secret{
Path: "gateway", Name: "routes", Env: "default", Ciphertext: []byte("yaml: here"),
}); err != nil {
t.Fatalf("seed: %v", err)
}
mux := http.NewServeMux()
registerSecretRoutes(mux, auth, secStore)
srv := httptest.NewServer(mux)
defer srv.Close()
get := func(path string) (int, string) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+bearer)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(body)
}
// The collection lists names — it must NOT fall through to the {rest...}
// handler, which would 400 for want of a slash.
code, body := get("/v1/kms/orgs/hanzo/secrets?path=gateway&env=default")
if code != http.StatusOK {
t.Fatalf("list: status %d, body %s", code, body)
}
if !strings.Contains(body, `"names"`) || !strings.Contains(body, "routes") {
t.Errorf("list body = %s, want a names array containing the seeded secret", body)
}
// A path below the collection still reaches the single-secret handler.
code, body = get("/v1/kms/orgs/hanzo/secrets/gateway/routes?env=default")
if code != http.StatusOK {
t.Fatalf("get: status %d, body %s", code, body)
}
if !strings.Contains(body, "yaml: here") {
t.Errorf("get body = %s, want the secret value", body)
}
// Listing an empty path is an empty array, never null — clients range over it.
code, body = get("/v1/kms/orgs/hanzo/secrets?path=nothing-here&env=default")
if code != http.StatusOK || !strings.Contains(body, `"names":[]`) {
t.Errorf("empty list = %d %s, want 200 with an empty array", code, body)
}
}
+29
View File
@@ -399,6 +399,35 @@ func main() {
// process env is never a secret-fetch source. Regression that keeps it gone: // process env is never a secret-fetch source. Regression that keeps it gone:
// TestSecretRoutes_NoEnvVarLeak. // TestSecretRoutes_NoEnvVarLeak.
func registerSecretRoutes(mux *http.ServeMux, auth *orgJWTAuth, secStore *store.SecretStore) { func registerSecretRoutes(mux *http.ServeMux, auth *orgJWTAuth, secStore *store.SecretStore) {
// GET /v1/kms/orgs/{org}/secrets?path=&env= — list names under a path.
//
// The ZAP wire has carried this since it was written (OpSecretList 0x0042,
// {path, env} -> {names}); HTTP had get/put/delete and no list, so the two
// framings of the same store disagreed about what you could ask it. A client
// that wanted to enumerate had to either open a ZAP connection or give up —
// the hanzo browser extension gave up. Same handler shape, same envelope as
// ZAP returns.
//
// Distinct from the {rest...} pattern below: this one has no trailing
// segment, so ServeMux routes the bare collection here and any path under it
// there. Pinned by TestSecretRoutes_ListAndGetDoNotShadow.
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) {
env := r.URL.Query().Get("env")
if env == "" {
env = "default"
}
secs, err := secStore.List(r.URL.Query().Get("path"), env)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"message": "list failed"})
return
}
names := make([]string, 0, len(secs))
for _, sec := range secs {
names = append(names, sec.Name)
}
writeJSON(w, http.StatusOK, map[string]any{"names": names})
}))
// GET /v1/kms/orgs/{org}/secrets/{path...}/{name} // GET /v1/kms/orgs/{org}/secrets/{path...}/{name}
// Matches the ATS kmsclient.Get() URL pattern. // Matches the ATS kmsclient.Get() URL pattern.
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets/{rest...}", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets/{rest...}", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) {