Skip to content

Commit cf3388c

Browse files
fix: Reject URL path segments containing ".." in all request methods (#4150)
1 parent 6b6e681 commit cf3388c

File tree

3 files changed

+114
-9
lines changed

3 files changed

+114
-9
lines changed

github/github.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"net/http"
2121
"net/url"
2222
"regexp"
23+
"slices"
2324
"strconv"
2425
"strings"
2526
"sync"
@@ -157,6 +158,10 @@ const (
157158

158159
var errNonNilContext = errors.New("context must be non-nil")
159160

161+
// ErrPathForbidden is returned when a URL path contains ".." as a path
162+
// segment, which could allow path traversal attacks.
163+
var ErrPathForbidden = errors.New("path must not contain '..' due to auth vulnerability issue")
164+
160165
// A Client manages communication with the GitHub API.
161166
type Client struct {
162167
clientMu sync.Mutex // clientMu protects the client during calls that modify the CheckRedirect func.
@@ -561,6 +566,10 @@ func (c *Client) NewRequest(method, urlStr string, body any, opts ...RequestOpti
561566
return nil, fmt.Errorf("baseURL must have a trailing slash, but %q does not", c.BaseURL)
562567
}
563568

569+
if err := checkURLPathTraversal(urlStr); err != nil {
570+
return nil, err
571+
}
572+
564573
u, err := c.BaseURL.Parse(urlStr)
565574
if err != nil {
566575
return nil, err
@@ -607,6 +616,10 @@ func (c *Client) NewFormRequest(urlStr string, body io.Reader, opts ...RequestOp
607616
return nil, fmt.Errorf("baseURL must have a trailing slash, but %q does not", c.BaseURL)
608617
}
609618

619+
if err := checkURLPathTraversal(urlStr); err != nil {
620+
return nil, err
621+
}
622+
610623
u, err := c.BaseURL.Parse(urlStr)
611624
if err != nil {
612625
return nil, err
@@ -631,13 +644,37 @@ func (c *Client) NewFormRequest(urlStr string, body io.Reader, opts ...RequestOp
631644
return req, nil
632645
}
633646

647+
// checkURLPathTraversal returns ErrPathForbidden if urlStr contains ".." as a
648+
// path segment (e.g. "a/../b"), preventing path traversal attacks. It does not
649+
// match ".." embedded within a segment (e.g. "file..txt"). The check is
650+
// performed only on the path portion of the URL, ignoring any query string or
651+
// fragment.
652+
func checkURLPathTraversal(urlStr string) error {
653+
if !strings.Contains(urlStr, "..") {
654+
return nil
655+
}
656+
u, err := url.Parse(urlStr)
657+
if err != nil {
658+
return err
659+
}
660+
if slices.Contains(strings.Split(u.Path, "/"), "..") {
661+
return ErrPathForbidden
662+
}
663+
return nil
664+
}
665+
634666
// NewUploadRequest creates an upload request. A relative URL can be provided in
635667
// urlStr, in which case it is resolved relative to the UploadURL of the Client.
636668
// Relative URLs should always be specified without a preceding slash.
637669
func (c *Client) NewUploadRequest(urlStr string, reader io.Reader, size int64, mediaType string, opts ...RequestOption) (*http.Request, error) {
638670
if !strings.HasSuffix(c.UploadURL.Path, "/") {
639671
return nil, fmt.Errorf("uploadURL must have a trailing slash, but %q does not", c.UploadURL)
640672
}
673+
674+
if err := checkURLPathTraversal(urlStr); err != nil {
675+
return nil, err
676+
}
677+
641678
u, err := c.UploadURL.Parse(urlStr)
642679
if err != nil {
643680
return nil, err

github/github_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,83 @@ func TestNewRequest_errorForNoTrailingSlash(t *testing.T) {
786786
}
787787
}
788788

789+
func TestCheckURLPathTraversal(t *testing.T) {
790+
t.Parallel()
791+
tests := []struct {
792+
input string
793+
wantErr error
794+
}{
795+
{"repos/o/r/contents/file.txt", nil},
796+
{"repos/o/r/contents/dir/file.txt", nil},
797+
{"repos/o/r/contents/file..txt", nil},
798+
{"repos/o/r?q=a..b", nil},
799+
{"repos/../admin/users", ErrPathForbidden},
800+
{"repos/x/../../../admin", ErrPathForbidden},
801+
{"../admin", ErrPathForbidden},
802+
{"repos/o/r/contents/..", ErrPathForbidden},
803+
{"repos/o/r/contents/../secrets", ErrPathForbidden},
804+
// Full URLs with scheme.
805+
{"https://api.github.com/repos/../admin", ErrPathForbidden},
806+
{"https://api.github.com/repos/o/r/contents/file.txt", nil},
807+
{"https://api.github.com/repos/o/r/contents/file..txt", nil},
808+
// URL with fragment.
809+
{"repos/o/r/contents/file.txt#section", nil},
810+
{"repos/../admin#frag", ErrPathForbidden},
811+
// URL with userinfo.
812+
{"https://user:pass@api.github.com/repos/../admin", ErrPathForbidden},
813+
{"https://user:pass@api.github.com/repos/o/r", nil},
814+
}
815+
for _, tt := range tests {
816+
err := checkURLPathTraversal(tt.input)
817+
if !errors.Is(err, tt.wantErr) {
818+
t.Errorf("checkURLPathTraversal(%q) = %v, want %v", tt.input, err, tt.wantErr)
819+
}
820+
}
821+
}
822+
823+
func TestNewRequest_pathTraversal(t *testing.T) {
824+
t.Parallel()
825+
c := NewClient(nil)
826+
827+
tests := []struct {
828+
urlStr string
829+
wantError bool
830+
}{
831+
{"repos/o/r/readme", false},
832+
{"repos/o/r/contents/file..txt", false},
833+
{"repos/x/../../../admin/users", true},
834+
{"repos/../admin", true},
835+
}
836+
for _, tt := range tests {
837+
_, err := c.NewRequest("GET", tt.urlStr, nil)
838+
if tt.wantError && !errors.Is(err, ErrPathForbidden) {
839+
t.Errorf("NewRequest(%q): want ErrPathForbidden, got %v", tt.urlStr, err)
840+
} else if !tt.wantError && err != nil {
841+
t.Errorf("NewRequest(%q): unexpected error: %v", tt.urlStr, err)
842+
}
843+
}
844+
}
845+
846+
func TestNewFormRequest_pathTraversal(t *testing.T) {
847+
t.Parallel()
848+
c := NewClient(nil)
849+
850+
_, err := c.NewFormRequest("repos/x/../../../admin", nil)
851+
if !errors.Is(err, ErrPathForbidden) {
852+
t.Fatalf("NewFormRequest with path traversal: want ErrPathForbidden, got %v", err)
853+
}
854+
}
855+
856+
func TestNewUploadRequest_pathTraversal(t *testing.T) {
857+
t.Parallel()
858+
c := NewClient(nil)
859+
860+
_, err := c.NewUploadRequest("repos/x/../../../admin", nil, 0, "")
861+
if !errors.Is(err, ErrPathForbidden) {
862+
t.Fatalf("NewUploadRequest with path traversal: want ErrPathForbidden, got %v", err)
863+
}
864+
}
865+
789866
func TestNewFormRequest(t *testing.T) {
790867
t.Parallel()
791868
c := NewClient(nil)

github/repos_contents.go

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ import (
2020
"strings"
2121
)
2222

23-
var ErrPathForbidden = errors.New("path must not contain '..' due to auth vulnerability issue")
24-
2523
// RepositoryContent represents a file or directory in a github repository.
2624
type RepositoryContent struct {
2725
Type *string `json:"type,omitempty"`
@@ -192,17 +190,10 @@ func (s *RepositoriesService) DownloadContentsWithMeta(ctx context.Context, owne
192190
// as possible, both result types will be returned but only one will contain a
193191
// value and the other will be nil.
194192
//
195-
// Due to an auth vulnerability issue in the GitHub v3 API, ".." is not allowed
196-
// to appear anywhere in the "path" or this method will return an error.
197-
//
198193
// GitHub API docs: https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28#get-repository-content
199194
//
200195
//meta:operation GET /repos/{owner}/{repo}/contents/{path}
201196
func (s *RepositoriesService) GetContents(ctx context.Context, owner, repo, path string, opts *RepositoryContentGetOptions) (fileContent *RepositoryContent, directoryContent []*RepositoryContent, resp *Response, err error) {
202-
if strings.Contains(path, "..") {
203-
return nil, nil, nil, ErrPathForbidden
204-
}
205-
206197
escapedPath := (&url.URL{Path: strings.TrimSuffix(path, "/")}).String()
207198
u := fmt.Sprintf("repos/%v/%v/contents/%v", owner, repo, escapedPath)
208199
u, err = addOptions(u, opts)

0 commit comments

Comments
 (0)