Path IDs used in REST API routes are double-escaped
Bug Report
As part of the refactor to use Request Handlers, there appears to be a regression in the escaping of API routes which include a path (such as a project or group path) as part of the API route URI. The project/group portion of the route is incorrectly double-escaped.
For a project path, provided as an ID, such as test-project, using a function such as ListProjectIssues previously would produce an API route URI such as /api/v4/projects/test%2Fproject/issues. Noting that %2F is the correct HTTP/URI encoded form of -. After the refactor, it now double-escapes the dash in the project path, producing /api/v4/projects/test%252Fproject/issues. %252F is a unicode character rather than -, and is the result of double-escaping %2F. This can lead to issues with the project not being found (404 errors) or issues with intermediary reverse proxies which may block the double-escaped request as being invalid.
Relevant Code
Starting with ListProjectIsues we can see that when sending the request, the withPath helper is used, with a ProjectID interpolated into the path.
// ListProjectIssues gets a list of project issues. This function accepts
// pagination parameters page and per_page to return the list of project issues.
//
// GitLab API docs: https://docs.gitlab.com/api/issues/#list-project-issues
func (s *IssuesService) ListProjectIssues(pid any, opt *ListProjectIssuesOptions, options ...RequestOptionFunc) ([]*Issue, *Response, error) {
return do[[]*Issue](s.client,
withPath("projects/%s/issues", ProjectID{pid}),
withAPIOpts(opt),
withRequestOpts(options...),
)
}
Calling ProjectID with a string/path value calls this code via the Pather interface, which escapes the project path portion once, producing test%2Fproject as the ID.
func (i ProjectID) forPath() (string, error) {
id, err := parseID(i.Value)
if err != nil {
return "", err
}
return PathEscape(id), nil
}
When withPath is called, we can see it is escaped again, even though the interpolated test%2Fproject has been interpolated into the path and already escaped:
func withPath(path string, args ...any) doOption {
return func(c *doConfig) error {
as := make([]any, len(args))
for i, a := range args {
switch v := a.(type) {
case Pather:
project, err := v.forPath()
if err != nil {
return err
}
as[i] = PathEscape(project)
case string:
as[i] = PathEscape(v)
default:
as[i] = v
}
}
c.path = fmt.Sprintf(path, as...)
return nil
}
}
Looking at the tests in request_handler_test.go, I notice that all the interpolated routes like this only test numeric IDs, which would not exercise this codepath.
Relevant Log Output
N/A
Additional Details
- GitLab Client Go Version:
v0.160.1 - GitLab Instance Version:
18.7.0-pre - Go Version:
1.25.3 - License Tier:
Ultimate