diff --git a/acls.go b/acls.go index 95273ec..6bd9f6b 100644 --- a/acls.go +++ b/acls.go @@ -131,7 +131,7 @@ type ACLsService struct{ client *Client } // "clients", "groups", "containers", "cookbooks", "data", "environments", // "policies", "policy_groups", "roles", ...). func (s *ACLsService) Get(ctx context.Context, objectType, name string) (*ACL, *Response, error) { - return s.getACL(ctx, s.client.orgPath(objectType+"/"+name)) + return s.getACL(ctx, s.client.orgPath(esc(objectType)+"/"+esc(name))) } // SetPermission rewrites one permission's ACE on one object. The Chef API @@ -145,13 +145,13 @@ func (s *ACLsService) Get(ctx context.Context, objectType, name string) (*ACL, * // Nil Actors/Groups slices are coerced to empty arrays so the server does // not reject the request for a null member list. func (s *ACLsService) SetPermission(ctx context.Context, objectType, name, perm string, ace *ACE) error { - return s.setACL(ctx, s.client.orgPath(objectType+"/"+name), perm, ace) + return s.setACL(ctx, s.client.orgPath(esc(objectType)+"/"+esc(name)), perm, ace) } // GetOrg returns the ACL of the organization object itself, served at // /organizations/ORG/_acl (no object-type segment). func (s *ACLsService) GetOrg(ctx context.Context) (*ACL, *Response, error) { - return s.getACL(ctx, "/organizations/"+s.client.org) + return s.getACL(ctx, "/organizations/"+esc(s.client.org)) } // SetOrgPermission rewrites one permission's ACE on the organization object. @@ -162,12 +162,12 @@ func (s *ACLsService) SetOrgPermission(ctx context.Context, perm string, ace *AC // GetUser returns the ACL of a global user object. User ACLs are top-level // (/users/USER/_acl), not org-scoped. func (s *ACLsService) GetUser(ctx context.Context, name string) (*ACL, *Response, error) { - return s.getACL(ctx, "/users/"+name) + return s.getACL(ctx, "/users/"+esc(name)) } // SetUserPermission rewrites one permission's ACE on a global user object. func (s *ACLsService) SetUserPermission(ctx context.Context, name, perm string, ace *ACE) error { - return s.setACL(ctx, "/users/"+name, perm, ace) + return s.setACL(ctx, "/users/"+esc(name), perm, ace) } // getACL fetches the full ACL for the object whose path is base (without the @@ -188,6 +188,6 @@ func (s *ACLsService) setACL(ctx context.Context, base, perm string, ace *ACE) e "groups": nonNil(ace.Groups), }, } - _, _, err := do[map[string]any](ctx, s.client, "PUT", base+"/_acl/"+perm, body) + _, _, err := do[map[string]any](ctx, s.client, "PUT", base+"/_acl/"+esc(perm), body) return err } diff --git a/associations.go b/associations.go index b41f17a..13350ac 100644 --- a/associations.go +++ b/associations.go @@ -55,7 +55,7 @@ func (s *AssociationsService) ListMembers(ctx context.Context) ([]string, *Respo // GetMember returns one organization member's record. func (s *AssociationsService) GetMember(ctx context.Context, name string) (*OrgUser, *Response, error) { - u, resp, err := do[OrgUser](ctx, s.client, "GET", s.client.orgPath("/users/"+name), nil) + u, resp, err := do[OrgUser](ctx, s.client, "GET", s.client.orgPath("/users/"+esc(name)), nil) return ptrOrNil(u, err), resp, err } @@ -70,7 +70,7 @@ func (s *AssociationsService) AddMember(ctx context.Context, username string) (* // RemoveMember removes a user's association with the organization and returns // the user's end state. func (s *AssociationsService) RemoveMember(ctx context.Context, name string) (*OrgUser, *Response, error) { - u, resp, err := do[OrgUser](ctx, s.client, "DELETE", s.client.orgPath("/users/"+name), nil) + u, resp, err := do[OrgUser](ctx, s.client, "DELETE", s.client.orgPath("/users/"+esc(name)), nil) return ptrOrNil(u, err), resp, err } @@ -89,7 +89,7 @@ func (s *AssociationsService) Invite(ctx context.Context, username string) (*Inv // RescindInvite cancels a pending organization invitation by its ID. func (s *AssociationsService) RescindInvite(ctx context.Context, id string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/association_requests/"+id), nil) + s.client.orgPath("/association_requests/"+esc(id)), nil) return resp, err } @@ -97,14 +97,14 @@ func (s *AssociationsService) RescindInvite(ctx context.Context, id string) (*Re // This is the user-side view at /users/USER/association_requests, so the // invitations carry OrgName rather than Username. func (s *AssociationsService) ListUserInvites(ctx context.Context, username string) ([]Invitation, *Response, error) { - return do[[]Invitation](ctx, s.client, "GET", "/users/"+username+"/association_requests", nil) + return do[[]Invitation](ctx, s.client, "GET", "/users/"+esc(username)+"/association_requests", nil) } // UserInviteCount returns the number of invitations pending for the user. func (s *AssociationsService) UserInviteCount(ctx context.Context, username string) (int, *Response, error) { v, resp, err := do[struct { Value int `json:"value"` - }](ctx, s.client, "GET", "/users/"+username+"/association_requests/count", nil) + }](ctx, s.client, "GET", "/users/"+esc(username)+"/association_requests/count", nil) return v.Value, resp, err } @@ -116,7 +116,7 @@ func (s *AssociationsService) RespondInvite(ctx context.Context, username, id st response = "accept" } _, resp, err := do[map[string]any](ctx, s.client, "PUT", - "/users/"+username+"/association_requests/"+id, map[string]string{"response": response}) + "/users/"+esc(username)+"/association_requests/"+esc(id), map[string]string{"response": response}) return resp, err } @@ -127,7 +127,7 @@ type userOrg struct { // ListUserOrgs returns the organizations the named global user belongs to. func (s *AssociationsService) ListUserOrgs(ctx context.Context, username string) ([]Org, *Response, error) { - wrapped, resp, err := do[[]userOrg](ctx, s.client, "GET", "/users/"+username+"/organizations", nil) + wrapped, resp, err := do[[]userOrg](ctx, s.client, "GET", "/users/"+esc(username)+"/organizations", nil) if err != nil { return nil, resp, err } diff --git a/client.go b/client.go index db702f8..8878305 100644 --- a/client.go +++ b/client.go @@ -123,7 +123,7 @@ func cloneTransportSkipVerify(base http.RoundTripper) *http.Transport { // orgPath prefixes p with /organizations/. func (c *Client) orgPath(p string) string { - return "/organizations/" + c.org + "/" + strings.TrimLeft(p, "/") + return "/organizations/" + esc(c.org) + "/" + strings.TrimLeft(p, "/") } // sleepCtx waits for d, reporting false if ctx ended first. diff --git a/containers.go b/containers.go index 2e6c750..81f59ed 100644 --- a/containers.go +++ b/containers.go @@ -23,7 +23,7 @@ func (s *ContainersService) List(ctx context.Context) (map[string]string, *Respo // Get retrieves a single container by name. func (s *ContainersService) Get(ctx context.Context, name string) (*Container, *Response, error) { cn, resp, err := do[Container](ctx, s.client, "GET", - s.client.orgPath("/containers/"+name), nil) + s.client.orgPath("/containers/"+esc(name)), nil) return ptrOrNil(cn, err), resp, err } @@ -39,6 +39,6 @@ func (s *ContainersService) Create(ctx context.Context, name string) (*Response, // Delete removes a container by name. func (s *ContainersService) Delete(ctx context.Context, name string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/containers/"+name), nil) + s.client.orgPath("/containers/"+esc(name)), nil) return resp, err } diff --git a/cookbook_artifacts.go b/cookbook_artifacts.go index 368a8c4..d2e2def 100644 --- a/cookbook_artifacts.go +++ b/cookbook_artifacts.go @@ -32,7 +32,7 @@ func (s *CookbookArtifactsService) List(ctx context.Context) (map[string]Cookboo // {name: {url, versions}} envelope. func (s *CookbookArtifactsService) GetVersions(ctx context.Context, name string) (*CookbookArtifactListEntry, *Response, error) { m, resp, err := do[map[string]CookbookArtifactListEntry](ctx, s.client, "GET", - s.client.orgPath("/cookbook_artifacts/"+name), nil) + s.client.orgPath("/cookbook_artifacts/"+esc(name)), nil) if err != nil { return nil, resp, err } @@ -46,14 +46,14 @@ func (s *CookbookArtifactsService) GetVersions(ctx context.Context, name string) // Get retrieves a single cookbook artifact by name and identifier. func (s *CookbookArtifactsService) Get(ctx context.Context, name, identifier string) (*Cookbook, *Response, error) { cb, resp, err := do[Cookbook](ctx, s.client, "GET", - s.client.orgPath("/cookbook_artifacts/"+name+"/"+identifier), nil) + s.client.orgPath("/cookbook_artifacts/"+esc(name)+"/"+esc(identifier)), nil) return ptrOrNil(cb, err), resp, err } // Delete removes a single cookbook artifact. func (s *CookbookArtifactsService) Delete(ctx context.Context, name, identifier string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/cookbook_artifacts/"+name+"/"+identifier), nil) + s.client.orgPath("/cookbook_artifacts/"+esc(name)+"/"+esc(identifier)), nil) return resp, err } diff --git a/cookbooks.go b/cookbooks.go index b5c34c2..3c2e94e 100644 --- a/cookbooks.go +++ b/cookbooks.go @@ -165,7 +165,7 @@ func (s *CookbooksService) ListRecipes(ctx context.Context) ([]string, *Response // ("" for the server default of one, "all" for every version, or "n"); // versions come back newest-first. func (s *CookbooksService) GetVersions(ctx context.Context, name, numVersions string) (*CookbookListEntry, *Response, error) { - path := s.client.orgPath("/cookbooks/" + name) + path := s.client.orgPath("/cookbooks/" + esc(name)) if numVersions != "" { path += "?num_versions=" + url.QueryEscape(numVersions) } @@ -183,14 +183,14 @@ func (s *CookbooksService) GetVersions(ctx context.Context, name, numVersions st // Get retrieves a single cookbook version manifest. func (s *CookbooksService) Get(ctx context.Context, name, version string) (*Cookbook, *Response, error) { cb, resp, err := do[Cookbook](ctx, s.client, "GET", - s.client.orgPath("/cookbooks/"+name+"/"+version), nil) + s.client.orgPath("/cookbooks/"+esc(name)+"/"+esc(version)), nil) return ptrOrNil(cb, err), resp, err } // Delete removes a single cookbook version. func (s *CookbooksService) Delete(ctx context.Context, name, version string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/cookbooks/"+name+"/"+version), nil) + s.client.orgPath("/cookbooks/"+esc(name)+"/"+esc(version)), nil) return resp, err } @@ -304,7 +304,7 @@ func uploadCookbook(ctx context.Context, c *Client, base string, cb *LocalCookbo slug = cb.Identifier } _, _, err = do[map[string]any](ctx, c, "PUT", - c.orgPath(base+"/"+cb.Name+"/"+slug), manifest) + c.orgPath(base+"/"+esc(cb.Name)+"/"+esc(slug)), manifest) if err != nil { return fmt.Errorf("cinc: put cookbook manifest: %w", err) } diff --git a/crud.go b/crud.go index a388198..c81122a 100644 --- a/crud.go +++ b/crud.go @@ -9,7 +9,7 @@ type crud[T any] struct { path string // resource collection path, e.g. "/nodes" } -func (r crud[T]) item(name string) string { return r.client.orgPath(r.path + "/" + name) } +func (r crud[T]) item(name string) string { return r.client.orgPath(r.path + "/" + esc(name)) } func (r crud[T]) coll() string { return r.client.orgPath(r.path) } func (r crud[T]) get(ctx context.Context, name string) (T, *Response, error) { diff --git a/databags.go b/databags.go index ed9a391..5c7866f 100644 --- a/databags.go +++ b/databags.go @@ -33,7 +33,7 @@ func (s *DataBagsService) Create(ctx context.Context, name string) (*Response, e // Delete removes a data bag and all its items. func (s *DataBagsService) Delete(ctx context.Context, name string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/data/"+name), nil) + s.client.orgPath("/data/"+esc(name)), nil) return resp, err } @@ -48,9 +48,9 @@ type DataBagItemsService struct { bag string } -func (s *DataBagItemsService) coll() string { return s.client.orgPath("/data/" + s.bag) } +func (s *DataBagItemsService) coll() string { return s.client.orgPath("/data/" + esc(s.bag)) } func (s *DataBagItemsService) item(id string) string { - return s.client.orgPath("/data/" + s.bag + "/" + id) + return s.client.orgPath("/data/" + esc(s.bag) + "/" + esc(id)) } // List returns the item id->URL index for the bag. diff --git a/environments.go b/environments.go index b6ea4a2..c17e9b0 100644 --- a/environments.go +++ b/environments.go @@ -52,7 +52,7 @@ func (s *EnvironmentsService) List(ctx context.Context) (map[string]string, *Res // envCookbookQuery builds an environment cookbook path, appending the optional // num_versions query parameter when non-empty. func (s *EnvironmentsService) envPath(env, suffix, numVersions string) string { - p := s.client.orgPath("/environments/" + env + suffix) + p := s.client.orgPath("/environments/" + esc(env) + suffix) if numVersions != "" { p += "?num_versions=" + url.QueryEscape(numVersions) } @@ -71,7 +71,7 @@ func (s *EnvironmentsService) ListCookbooks(ctx context.Context, env, numVersion // environment, filtered by the environment's version constraints. func (s *EnvironmentsService) GetCookbook(ctx context.Context, env, name, numVersions string) (map[string]CookbookListEntry, *Response, error) { return do[map[string]CookbookListEntry](ctx, s.client, "GET", - s.envPath(env, "/cookbooks/"+name, numVersions), nil) + s.envPath(env, "/cookbooks/"+esc(name), numVersions), nil) } // CookbookVersions solves the given run list against the environment and @@ -79,20 +79,20 @@ func (s *EnvironmentsService) GetCookbook(ctx context.Context, env, name, numVer // it, keyed by cookbook name. func (s *EnvironmentsService) CookbookVersions(ctx context.Context, env string, runList []string) (map[string]Cookbook, *Response, error) { return do[map[string]Cookbook](ctx, s.client, "POST", - s.client.orgPath("/environments/"+env+"/cookbook_versions"), + s.client.orgPath("/environments/"+esc(env)+"/cookbook_versions"), map[string][]string{"run_list": runList}) } // ListNodes returns the name->URL index of nodes in the environment. func (s *EnvironmentsService) ListNodes(ctx context.Context, env string) (map[string]string, *Response, error) { return do[map[string]string](ctx, s.client, "GET", - s.client.orgPath("/environments/"+env+"/nodes"), nil) + s.client.orgPath("/environments/"+esc(env)+"/nodes"), nil) } // ListRecipes returns the recipes available to the environment. func (s *EnvironmentsService) ListRecipes(ctx context.Context, env string) ([]string, *Response, error) { return do[[]string](ctx, s.client, "GET", - s.client.orgPath("/environments/"+env+"/recipes"), nil) + s.client.orgPath("/environments/"+esc(env)+"/recipes"), nil) } // RoleRunList returns the role's run list as scoped to the environment: the @@ -100,7 +100,7 @@ func (s *EnvironmentsService) ListRecipes(ctx context.Context, env string) ([]st // environment. func (s *EnvironmentsService) RoleRunList(ctx context.Context, env, role string) ([]string, *Response, error) { rl, resp, err := do[runListBody](ctx, s.client, "GET", - s.client.orgPath("/environments/"+env+"/roles/"+role), nil) + s.client.orgPath("/environments/"+esc(env)+"/roles/"+esc(role)), nil) return rl.RunList, resp, err } diff --git a/groups.go b/groups.go index d56a63e..535329b 100644 --- a/groups.go +++ b/groups.go @@ -28,7 +28,7 @@ func (s *GroupsService) List(ctx context.Context) (map[string]string, *Response, // Get retrieves a single group by name, including its members. func (s *GroupsService) Get(ctx context.Context, name string) (*Group, *Response, error) { g, resp, err := do[Group](ctx, s.client, "GET", - s.client.orgPath("/groups/"+name), nil) + s.client.orgPath("/groups/"+esc(name)), nil) return ptrOrNil(g, err), resp, err } @@ -62,7 +62,7 @@ func (s *GroupsService) Update(ctx context.Context, g *Group) (*Group, *Response }, } updated, resp, err := do[Group](ctx, s.client, "PUT", - s.client.orgPath("/groups/"+name), body) + s.client.orgPath("/groups/"+esc(name)), body) return ptrOrNil(updated, err), resp, err } @@ -79,7 +79,7 @@ func (g *Group) name() string { // Delete removes a group by name. func (s *GroupsService) Delete(ctx context.Context, name string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/groups/"+name), nil) + s.client.orgPath("/groups/"+esc(name)), nil) return resp, err } diff --git a/keys.go b/keys.go index d28f80d..7a4ad3d 100644 --- a/keys.go +++ b/keys.go @@ -34,14 +34,14 @@ type KeysService struct{ client *Client } // User returns a handle to the keys of the named global user. func (s *KeysService) User(name string) *KeyScope { - return &KeyScope{client: s.client, path: "/users/" + name + "/keys"} + return &KeyScope{client: s.client, path: "/users/" + esc(name) + "/keys"} } // Client returns a handle to the keys of the named org client. func (s *KeysService) Client(name string) *KeyScope { return &KeyScope{ client: s.client, - path: s.client.orgPath("/clients/" + name + "/keys"), + path: s.client.orgPath("/clients/" + esc(name) + "/keys"), } } @@ -52,7 +52,7 @@ type KeyScope struct { path string // absolute server path of the keys collection } -func (s *KeyScope) item(name string) string { return s.path + "/" + name } +func (s *KeyScope) item(name string) string { return s.path + "/" + esc(name) } // List returns every key in the scope. func (s *KeyScope) List(ctx context.Context) ([]Key, *Response, error) { diff --git a/orgs.go b/orgs.go index 681cc0d..c8c5825 100644 --- a/orgs.go +++ b/orgs.go @@ -30,7 +30,7 @@ func (s *OrgsService) List(ctx context.Context) (map[string]string, *Response, e // Get retrieves one organization's metadata. func (s *OrgsService) Get(ctx context.Context, name string) (*Org, *Response, error) { - o, resp, err := do[Org](ctx, s.client, "GET", "/organizations/"+name, nil) + o, resp, err := do[Org](ctx, s.client, "GET", "/organizations/"+esc(name), nil) return ptrOrNil(o, err), resp, err } @@ -44,12 +44,12 @@ func (s *OrgsService) Create(ctx context.Context, o *Org) (*OrgCreateResult, *Re // Update replaces an organization's metadata (typically FullName). func (s *OrgsService) Update(ctx context.Context, o *Org) (*Org, *Response, error) { - updated, resp, err := do[Org](ctx, s.client, "PUT", "/organizations/"+o.Name, o) + updated, resp, err := do[Org](ctx, s.client, "PUT", "/organizations/"+esc(o.Name), o) return ptrOrNil(updated, err), resp, err } // Delete removes an organization. func (s *OrgsService) Delete(ctx context.Context, name string) (*Response, error) { - _, resp, err := do[map[string]any](ctx, s.client, "DELETE", "/organizations/"+name, nil) + _, resp, err := do[map[string]any](ctx, s.client, "DELETE", "/organizations/"+esc(name), nil) return resp, err } diff --git a/pathescape.go b/pathescape.go new file mode 100644 index 0000000..ea76867 --- /dev/null +++ b/pathescape.go @@ -0,0 +1,18 @@ +package cinc + +import "net/url" + +// esc percent-encodes a caller-supplied identifier so it occupies exactly one +// path segment. +// +// Two things depend on this. The v1.3 signature covers the canonical request +// path, and net/http re-derives the wire path from the parsed URL — so an +// unescaped name that Go encodes differently (a space, a non-ASCII rune) is +// signed one way and sent another, and the server rejects it with a 401. And a +// name containing "/" or ".." would otherwise walk out of the collection its +// service owns, producing a correctly-signed request against a different +// object entirely. +// +// Every identifier Chef itself considers legal is unreserved, so for valid +// input this is the identity function and nothing on the wire changes. +func esc(s string) string { return url.PathEscape(s) } diff --git a/pathescape_test.go b/pathescape_test.go new file mode 100644 index 0000000..9237746 --- /dev/null +++ b/pathescape_test.go @@ -0,0 +1,96 @@ +package cinc + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/cinc-project/cinc-api/internal/signing" +) + +// verifySignature re-verifies the v1.3 signature the way a real Chef Server +// does: over the path that actually arrived on the wire. It is the only way to +// catch a client that signs one path and sends another. +func verifySignature(t *testing.T, r *http.Request, key *rsa.PrivateKey) { + t.Helper() + var sig strings.Builder + for i := 1; ; i++ { + chunk := r.Header.Get("X-Ops-Authorization-" + strconv.Itoa(i)) + if chunk == "" { + break + } + sig.WriteString(chunk) + } + raw, err := base64.StdEncoding.DecodeString(sig.String()) + if err != nil { + t.Fatalf("decode signature: %v", err) + } + canonical := signing.CanonicalRequest(signing.Request{ + Method: r.Method, + Path: r.URL.EscapedPath(), + UserID: r.Header.Get("X-Ops-Userid"), + Timestamp: r.Header.Get("X-Ops-Timestamp"), + }) + digest := sha256.Sum256([]byte(canonical)) + if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], raw); err != nil { + t.Errorf("signature does not cover the wire path %q: %v", r.URL.EscapedPath(), err) + } +} + +// A caller-supplied name is a single path segment: "/" and "." must not be +// able to walk out of the collection the service owns. +func TestRequestPath_NameCannotEscapeItsCollection(t *testing.T) { + var wire string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wire = r.URL.EscapedPath() + w.Write([]byte(`{}`)) + })) + defer srv.Close() + c := newTestClient(t, srv) + if _, err := c.Nodes.Delete(context.Background(), "../clients/validator"); err != nil { + t.Fatalf("Delete: %v", err) + } + const want = "/organizations/o/nodes/..%2Fclients%2Fvalidator" + if wire != want { + t.Errorf("wire path = %q, want %q", wire, want) + } +} + +// Whatever reaches the server must be exactly what was signed, for every +// character a name might contain. +func TestRequestPath_SignatureCoversWirePath(t *testing.T) { + key := testRSAKey(t) + for _, name := range []string{"plain", "a b", "a#b", "a?b", "a/b", "a%b", "ünïcode"} { + t.Run(name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + verifySignature(t, r, key) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + c := newTestClient(t, srv) + if _, _, err := c.Nodes.Get(context.Background(), name); err != nil { + t.Fatalf("Get(%q): %v", name, err) + } + }) + } +} + +// The org name comes from Config and lands in every org-scoped path. +func TestOrgPath_EscapesOrgName(t *testing.T) { + c, err := NewClient(Config{ + ServerURL: "https://h", Org: "a/b", ClientName: "c", Key: testRSAKey(t), + }) + if err != nil { + t.Fatal(err) + } + if got, want := c.orgPath("/nodes"), "/organizations/a%2Fb/nodes"; got != want { + t.Errorf("orgPath = %q, want %q", got, want) + } +} diff --git a/policies.go b/policies.go index 98017d0..44fc240 100644 --- a/policies.go +++ b/policies.go @@ -101,21 +101,21 @@ func (s *PoliciesService) List(ctx context.Context) (map[string]PolicyListEntry, // Get returns the set of revisions known for a single policy name. func (s *PoliciesService) Get(ctx context.Context, name string) (*PolicyRevisions, *Response, error) { r, resp, err := do[PolicyRevisions](ctx, s.client, "GET", - s.client.orgPath("/policies/"+name), nil) + s.client.orgPath("/policies/"+esc(name)), nil) return ptrOrNil(r, err), resp, err } // Delete removes a policy and every revision under it. func (s *PoliciesService) Delete(ctx context.Context, name string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/policies/"+name), nil) + s.client.orgPath("/policies/"+esc(name)), nil) return resp, err } // GetRevision fetches a single revision of a policy. func (s *PoliciesService) GetRevision(ctx context.Context, name, revisionID string) (*PolicyRevision, *Response, error) { r, resp, err := do[PolicyRevision](ctx, s.client, "GET", - s.client.orgPath("/policies/"+name+"/revisions/"+revisionID), nil) + s.client.orgPath("/policies/"+esc(name)+"/revisions/"+esc(revisionID)), nil) return ptrOrNil(r, err), resp, err } @@ -124,14 +124,14 @@ func (s *PoliciesService) GetRevision(ctx context.Context, name, revisionID stri // other JSON-marshallable value matching the Policyfile schema. func (s *PoliciesService) CreateRevision(ctx context.Context, name string, doc any) (*PolicyRevision, *Response, error) { r, resp, err := do[PolicyRevision](ctx, s.client, "POST", - s.client.orgPath("/policies/"+name+"/revisions"), doc) + s.client.orgPath("/policies/"+esc(name)+"/revisions"), doc) return ptrOrNil(r, err), resp, err } // DeleteRevision removes a single revision of a policy. func (s *PoliciesService) DeleteRevision(ctx context.Context, name, revisionID string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/policies/"+name+"/revisions/"+revisionID), nil) + s.client.orgPath("/policies/"+esc(name)+"/revisions/"+esc(revisionID)), nil) return resp, err } diff --git a/policy_groups.go b/policy_groups.go index 657e40c..700d67a 100644 --- a/policy_groups.go +++ b/policy_groups.go @@ -27,14 +27,14 @@ func (s *PolicyGroupsService) List(ctx context.Context) (map[string]PolicyGroup, // Get returns one group's pinned policy revisions. func (s *PolicyGroupsService) Get(ctx context.Context, name string) (*PolicyGroup, *Response, error) { g, resp, err := do[PolicyGroup](ctx, s.client, "GET", - s.client.orgPath("/policy_groups/"+name), nil) + s.client.orgPath("/policy_groups/"+esc(name)), nil) return ptrOrNil(g, err), resp, err } // Delete removes a policy group and all of its policy pinnings. func (s *PolicyGroupsService) Delete(ctx context.Context, name string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/policy_groups/"+name), nil) + s.client.orgPath("/policy_groups/"+esc(name)), nil) return resp, err } @@ -42,7 +42,7 @@ func (s *PolicyGroupsService) Delete(ctx context.Context, name string) (*Respons // named policy. func (s *PolicyGroupsService) GetPolicy(ctx context.Context, group, policy string) (*PolicyRevision, *Response, error) { r, resp, err := do[PolicyRevision](ctx, s.client, "GET", - s.client.orgPath("/policy_groups/"+group+"/policies/"+policy), nil) + s.client.orgPath("/policy_groups/"+esc(group)+"/policies/"+esc(policy)), nil) return ptrOrNil(r, err), resp, err } @@ -52,7 +52,7 @@ func (s *PolicyGroupsService) GetPolicy(ctx context.Context, group, policy strin // may be a *PolicyRevision, a map, or any JSON-marshallable Policyfile. func (s *PolicyGroupsService) PutPolicy(ctx context.Context, group, policy string, doc any) (*PolicyRevision, *Response, error) { r, resp, err := do[PolicyRevision](ctx, s.client, "PUT", - s.client.orgPath("/policy_groups/"+group+"/policies/"+policy), doc) + s.client.orgPath("/policy_groups/"+esc(group)+"/policies/"+esc(policy)), doc) return ptrOrNil(r, err), resp, err } @@ -60,6 +60,6 @@ func (s *PolicyGroupsService) PutPolicy(ctx context.Context, group, policy strin // deleting the underlying revision. func (s *PolicyGroupsService) DeletePolicy(ctx context.Context, group, policy string) (*Response, error) { _, resp, err := do[map[string]any](ctx, s.client, "DELETE", - s.client.orgPath("/policy_groups/"+group+"/policies/"+policy), nil) + s.client.orgPath("/policy_groups/"+esc(group)+"/policies/"+esc(policy)), nil) return resp, err } diff --git a/principals.go b/principals.go index af4174d..463a815 100644 --- a/principals.go +++ b/principals.go @@ -23,6 +23,6 @@ type PrincipalsService struct{ client *Client } func (s *PrincipalsService) Get(ctx context.Context, name string) ([]Principal, *Response, error) { out, resp, err := do[struct { Principals []Principal `json:"principals"` - }](ctx, s.client, "GET", s.client.orgPath("/principals/"+name), nil) + }](ctx, s.client, "GET", s.client.orgPath("/principals/"+esc(name)), nil) return out.Principals, resp, err } diff --git a/roles.go b/roles.go index 0abfc47..cce929b 100644 --- a/roles.go +++ b/roles.go @@ -63,13 +63,13 @@ func (s *RolesService) List(ctx context.Context) (map[string]string, *Response, // environment-specific run list (always including "_default"). func (s *RolesService) Environments(ctx context.Context, role string) ([]string, *Response, error) { return do[[]string](ctx, s.client, "GET", - s.client.orgPath("/roles/"+role+"/environments"), nil) + s.client.orgPath("/roles/"+esc(role)+"/environments"), nil) } // EnvironmentRunList returns the role's run list for one environment // (env_run_lists[env], or the default run_list when env is "_default"). func (s *RolesService) EnvironmentRunList(ctx context.Context, role, env string) ([]string, *Response, error) { rl, resp, err := do[runListBody](ctx, s.client, "GET", - s.client.orgPath("/roles/"+role+"/environments/"+env), nil) + s.client.orgPath("/roles/"+esc(role)+"/environments/"+esc(env)), nil) return rl.RunList, resp, err } diff --git a/sandboxes.go b/sandboxes.go index e53c428..51df333 100644 --- a/sandboxes.go +++ b/sandboxes.go @@ -35,7 +35,7 @@ func (c *Client) createSandbox(ctx context.Context, checksumsHex []string) (*san // commitSandbox finalizes a sandbox after all needed files are uploaded. func (c *Client) commitSandbox(ctx context.Context, id string) (*Response, error) { _, resp, err := do[map[string]any](ctx, c, "PUT", - c.orgPath("/sandboxes/"+id), map[string]any{"is_completed": true}) + c.orgPath("/sandboxes/"+esc(id)), map[string]any{"is_completed": true}) return resp, err } diff --git a/search.go b/search.go index 86748c0..3bfdab6 100644 --- a/search.go +++ b/search.go @@ -54,7 +54,7 @@ func (s *SearchService) Query(ctx context.Context, index, query string, opts ... v.Set("q", query) v.Set("start", strconv.Itoa(p.start)) v.Set("rows", strconv.Itoa(p.rows)) - path := s.client.orgPath("/search/"+index) + "?" + v.Encode() + path := s.client.orgPath("/search/"+esc(index)) + "?" + v.Encode() var body any method := "GET" diff --git a/users.go b/users.go index 4c36f49..6f243c9 100644 --- a/users.go +++ b/users.go @@ -43,7 +43,7 @@ func (s *UsersService) List(ctx context.Context) (map[string]string, *Response, // Get retrieves a single user's metadata by name. func (s *UsersService) Get(ctx context.Context, name string) (*User, *Response, error) { - u, resp, err := do[User](ctx, s.client, "GET", "/users/"+name, nil) + u, resp, err := do[User](ctx, s.client, "GET", "/users/"+esc(name), nil) return ptrOrNil(u, err), resp, err } @@ -57,13 +57,13 @@ func (s *UsersService) Create(ctx context.Context, u *User) (*UserCreateResult, // Update replaces a user's metadata. Use UserName as the lookup key; other // fields are the new values. func (s *UsersService) Update(ctx context.Context, u *User) (*User, *Response, error) { - updated, resp, err := do[User](ctx, s.client, "PUT", "/users/"+u.UserName, u) + updated, resp, err := do[User](ctx, s.client, "PUT", "/users/"+esc(u.UserName), u) return ptrOrNil(updated, err), resp, err } // Delete removes a user. func (s *UsersService) Delete(ctx context.Context, name string) (*Response, error) { - _, resp, err := do[map[string]any](ctx, s.client, "DELETE", "/users/"+name, nil) + _, resp, err := do[map[string]any](ctx, s.client, "DELETE", "/users/"+esc(name), nil) return resp, err }