mirror of
https://github.com/luxfi/kms.git
synced 2026-07-27 03:38:31 +00:00
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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user