diff --git a/.cursor/rules/go-url-construction.mdc b/.cursor/rules/go-url-construction.mdc index cfd97ad5a..4fbed4c84 100644 --- a/.cursor/rules/go-url-construction.mdc +++ b/.cursor/rules/go-url-construction.mdc @@ -10,14 +10,19 @@ alwaysApply: false Use `net/url` package or `pkg/baseurl.URLBuilder`. +**Always** wrap user-supplied path segments with `url.PathEscape` before passing them to `url.JoinPath`. `url.JoinPath` does **not** percent-encode slashes or reserved characters in its arguments — a value like `parent/child` silently adds an extra path segment. + ```go // BAD endpoint := fmt.Sprintf("https://api.example.com/users/%s?active=%t", userID, active) endpoint := "https://api.example.com/orgs/" + orgID + "/members" raw := baseEndpoint + "?domain=" + domain + "&limit=100" -// GOOD — url.JoinPath + url.Values -u, err := url.JoinPath("https://api.example.com", "users", userID) +// BAD — user-supplied value without PathEscape +u, err := url.JoinPath("https://api.example.com", "groups", groupID, "members") + +// GOOD — url.JoinPath with PathEscape + url.Values +u, err := url.JoinPath("https://api.example.com", "groups", url.PathEscape(groupID), "members") if err != nil { return fmt.Errorf("cannot build URL: %w", err) } diff --git a/contrib/claude/go-style.md b/contrib/claude/go-style.md index bf68a0966..c81414cc2 100644 --- a/contrib/claude/go-style.md +++ b/contrib/claude/go-style.md @@ -262,7 +262,7 @@ var trustCenterIDKey = &ctxKey{name: "trust_center_id"} - Use `url.URL` struct to build full URLs (scheme, host, path, query). - Use `url.Values` to build query parameters, then call `.Encode()`. -- Use `url.QueryEscape` or `url.PathEscape` when embedding a single value into a known-safe base. +- **Always** wrap user-supplied path segments with `url.PathEscape` before passing them to `url.JoinPath`. `url.JoinPath` does **not** percent-encode slashes or reserved characters — a value like `parent/child` silently adds an extra path segment. - Use the `pkg/baseurl.URLBuilder` when constructing URLs from configured base URLs. ```go @@ -272,8 +272,11 @@ endpoint := fmt.Sprintf("https://api.example.com/users/%s?active=%t", userID, ac // Bad — string concatenation endpoint := "https://api.example.com/orgs/" + orgID + "/members" -// Good — url.JoinPath escapes each segment and sets Path + RawPath -u, err := url.JoinPath("https://api.example.com", "users", userID) +// Bad — user-supplied value without PathEscape +u, err := url.JoinPath("https://api.example.com", "groups", groupID, "members") + +// Good — url.JoinPath with PathEscape on user-supplied segments +u, err := url.JoinPath("https://api.example.com", "groups", url.PathEscape(groupID), "members") if err != nil { return fmt.Errorf("cannot build URL: %w", err) } diff --git a/pkg/accessreview/drivers/asana.go b/pkg/accessreview/drivers/asana.go index 944b5e583..421e43419 100644 --- a/pkg/accessreview/drivers/asana.go +++ b/pkg/accessreview/drivers/asana.go @@ -67,7 +67,7 @@ type asanaUsersPage struct { func (d *AsanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { var records []AccountRecord - u, err := url.JoinPath("https://app.asana.com", "api", "1.0", "workspaces", d.workspaceGID, "users") + u, err := url.JoinPath("https://app.asana.com", "api", "1.0", "workspaces", url.PathEscape(d.workspaceGID), "users") if err != nil { return nil, fmt.Errorf("cannot build asana users URL: %w", err) } diff --git a/pkg/accessreview/drivers/bitbucket.go b/pkg/accessreview/drivers/bitbucket.go index 3236527ea..45bcd7ea8 100644 --- a/pkg/accessreview/drivers/bitbucket.go +++ b/pkg/accessreview/drivers/bitbucket.go @@ -67,7 +67,7 @@ type bitbucketMembersPage struct { func (d *BitbucketDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { var records []AccountRecord - u, err := url.JoinPath("https://api.bitbucket.org", "2.0", "workspaces", d.workspace, "members") + u, err := url.JoinPath("https://api.bitbucket.org", "2.0", "workspaces", url.PathEscape(d.workspace), "members") if err != nil { return nil, fmt.Errorf("cannot build bitbucket members URL: %w", err) } diff --git a/pkg/accessreview/drivers/clickup.go b/pkg/accessreview/drivers/clickup.go index 529408c7e..37d93c564 100644 --- a/pkg/accessreview/drivers/clickup.go +++ b/pkg/accessreview/drivers/clickup.go @@ -71,7 +71,7 @@ type clickupTeamResponse struct { } func (d *ClickUpDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { - endpoint, err := url.JoinPath("https://api.clickup.com", "api", "v2", "team", d.teamID) + endpoint, err := url.JoinPath("https://api.clickup.com", "api", "v2", "team", url.PathEscape(d.teamID)) if err != nil { return nil, fmt.Errorf("cannot build clickup team URL: %w", err) } diff --git a/pkg/accessreview/drivers/gitlab.go b/pkg/accessreview/drivers/gitlab.go index 53430ed87..4070b5ec5 100644 --- a/pkg/accessreview/drivers/gitlab.go +++ b/pkg/accessreview/drivers/gitlab.go @@ -67,7 +67,7 @@ type gitlabMember struct { func (d *GitLabDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { var records []AccountRecord - u, err := url.JoinPath("https://gitlab.com", "api", "v4", "groups", d.groupID, "members", "all") + u, err := url.JoinPath("https://gitlab.com", "api", "v4", "groups", url.PathEscape(d.groupID), "members", "all") if err != nil { return nil, fmt.Errorf("cannot build gitlab members URL: %w", err) } diff --git a/pkg/accessreview/drivers/heroku.go b/pkg/accessreview/drivers/heroku.go index 70ee30c99..6e950172a 100644 --- a/pkg/accessreview/drivers/heroku.go +++ b/pkg/accessreview/drivers/heroku.go @@ -72,7 +72,7 @@ type herokuTeamMember struct { func (d *HerokuDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { var records []AccountRecord - endpoint, err := url.JoinPath("https://api.heroku.com", "teams", d.teamID, "members") + endpoint, err := url.JoinPath("https://api.heroku.com", "teams", url.PathEscape(d.teamID), "members") if err != nil { return nil, fmt.Errorf("cannot build heroku members URL: %w", err) } diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index 3d4726ddf..0c4b9a5be 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -652,7 +652,7 @@ func (r *gitlabNameResolver) ResolveInstanceName(ctx context.Context) (string, e return "", nil } - endpoint, err := url.JoinPath("https://gitlab.com", "api", "v4", "groups", r.groupID) + endpoint, err := url.JoinPath("https://gitlab.com", "api", "v4", "groups", url.PathEscape(r.groupID)) if err != nil { return "", fmt.Errorf("cannot build gitlab group URL: %w", err) } @@ -705,7 +705,7 @@ func (r *bitbucketNameResolver) ResolveInstanceName(ctx context.Context) (string return "", nil } - endpoint, err := url.JoinPath("https://api.bitbucket.org", "2.0", "workspaces", r.workspace) + endpoint, err := url.JoinPath("https://api.bitbucket.org", "2.0", "workspaces", url.PathEscape(r.workspace)) if err != nil { return "", fmt.Errorf("cannot build bitbucket workspace URL: %w", err) } @@ -758,7 +758,7 @@ func (r *herokuNameResolver) ResolveInstanceName(ctx context.Context) (string, e return "", nil } - endpoint, err := url.JoinPath("https://api.heroku.com", "teams", r.teamID) + endpoint, err := url.JoinPath("https://api.heroku.com", "teams", url.PathEscape(r.teamID)) if err != nil { return "", fmt.Errorf("cannot build heroku team URL: %w", err) } @@ -821,7 +821,7 @@ func (r *asanaNameResolver) ResolveInstanceName(ctx context.Context) (string, er return "", nil } - endpoint, err := url.JoinPath("https://app.asana.com", "api", "1.0", "workspaces", r.workspaceGID) + endpoint, err := url.JoinPath("https://app.asana.com", "api", "1.0", "workspaces", url.PathEscape(r.workspaceGID)) if err != nil { return "", fmt.Errorf("cannot build asana workspace URL: %w", err) } @@ -871,7 +871,7 @@ func (r *netlifyNameResolver) ResolveInstanceName(ctx context.Context) (string, return "", nil } - endpoint, err := url.JoinPath("https://api.netlify.com", "api", "v1", "accounts", r.accountSlug) + endpoint, err := url.JoinPath("https://api.netlify.com", "api", "v1", "accounts", url.PathEscape(r.accountSlug)) if err != nil { return "", fmt.Errorf("cannot build netlify account URL: %w", err) } @@ -919,7 +919,7 @@ func (r *clickupNameResolver) ResolveInstanceName(ctx context.Context) (string, return "", nil } - endpoint, err := url.JoinPath("https://api.clickup.com", "api", "v2", "team", r.teamID) + endpoint, err := url.JoinPath("https://api.clickup.com", "api", "v2", "team", url.PathEscape(r.teamID)) if err != nil { return "", fmt.Errorf("cannot build clickup team URL: %w", err) } @@ -972,7 +972,7 @@ func (r *vercelNameResolver) ResolveInstanceName(ctx context.Context) (string, e return "", nil } - teamURL, err := url.JoinPath("https://api.vercel.com", "v2", "teams", r.teamID) + teamURL, err := url.JoinPath("https://api.vercel.com", "v2", "teams", url.PathEscape(r.teamID)) if err != nil { return "", fmt.Errorf("cannot build vercel team URL: %w", err) } diff --git a/pkg/accessreview/drivers/netlify.go b/pkg/accessreview/drivers/netlify.go index 12c3b9573..f104cdb97 100644 --- a/pkg/accessreview/drivers/netlify.go +++ b/pkg/accessreview/drivers/netlify.go @@ -56,7 +56,7 @@ type netlifyMember struct { func (d *NetlifyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { var records []AccountRecord - u, err := url.JoinPath("https://api.netlify.com", "api", "v1", d.accountSlug, "members") + u, err := url.JoinPath("https://api.netlify.com", "api", "v1", url.PathEscape(d.accountSlug), "members") if err != nil { return nil, fmt.Errorf("cannot build netlify members URL: %w", err) }