Restore url.PathEscape on user-supplied path segments in url.JoinPath calls
url.JoinPath does not percent-encode slashes or reserved characters in its arguments, so user-supplied values (group IDs, slugs, team IDs) must be wrapped with url.PathEscape to prevent path traversal. Update cursor rule and contrib guide to codify this as a mandatory practice. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user