diff --git a/internal/handlers/git_server.go b/internal/handlers/git_server.go index d76bca4..98f171c 100644 --- a/internal/handlers/git_server.go +++ b/internal/handlers/git_server.go @@ -2,8 +2,10 @@ package handlers import ( "bytes" + "encoding/json" "fmt" "io" + "mime" "net/http" "strings" "sync" @@ -11,6 +13,7 @@ import ( "github.com/elazarl/goproxy" "github.com/dependabot/proxy/internal/config" + "github.com/dependabot/proxy/internal/gitproto" "github.com/dependabot/proxy/internal/helpers" "github.com/dependabot/proxy/internal/logging" "github.com/dependabot/proxy/internal/proxyctx" @@ -20,9 +23,10 @@ import ( // GitServerHandler handles requests destined remote git servers such as // github.com or private git servers type GitServerHandler struct { - credentials *gitCredentialsMap - jitAccessByHost map[string]jitAccessConfig - client ScopeRequester + credentials *gitCredentialsMap + jitAccessByHost map[string]jitAccessConfig + client ScopeRequester + readOnlyGitCredentials bool reposAlreadyTried *threadsafe.Map[string, struct{}] } @@ -33,6 +37,8 @@ type jitAccessConfig struct { password string } +const blockedGitRequestMessage = "Dependabot proxy blocked authentication for a non-read-only Git request\n" + type gitCredentialsMap struct { sync.RWMutex // data is a nested map structure to store credentials. @@ -213,9 +219,10 @@ type gitCredentials struct { } const ( - addedAuthCtxKey = "git-server.added-auth" - reqBodyCtxKey = "git-server.req-body" - allReposScopeIdentifier = "" + addedAuthCtxKey = "git-server.added-auth" + nonReadOnlyRequestCtxKey = "git-server.non-read-only-request" + reqBodyCtxKey = "git-server.req-body" + allReposScopeIdentifier = "" ) type ScopeRequester interface { @@ -224,12 +231,17 @@ type ScopeRequester interface { // NewGitServerHandler returns a new GitServerHandler, adding basic auth to // requests to hosts for which we have credentials -func NewGitServerHandler(creds config.Credentials, client ScopeRequester) *GitServerHandler { +func NewGitServerHandler( + creds config.Credentials, + client ScopeRequester, + readOnlyGitCredentials bool, +) *GitServerHandler { handler := GitServerHandler{ - credentials: newGitCredentialsMap(), - jitAccessByHost: map[string]jitAccessConfig{}, - client: client, - reposAlreadyTried: threadsafe.NewMap[string, struct{}](), + credentials: newGitCredentialsMap(), + jitAccessByHost: map[string]jitAccessConfig{}, + client: client, + readOnlyGitCredentials: readOnlyGitCredentials, + reposAlreadyTried: threadsafe.NewMap[string, struct{}](), } for _, cred := range creds { @@ -271,12 +283,26 @@ func (h *GitServerHandler) HandleRequest(req *http.Request, proxyCtx *goproxy.Pr return req, nil } - if _, pw, ok := req.BasicAuth(); ok && pw != "" { + creds := getCredentialsForRequest(req, h.credentials, gitExtractOrgAndRepo) + if len(creds) == 0 { return req, nil } - creds := getCredentialsForRequest(req, h.credentials, gitExtractOrgAndRepo) - if len(creds) == 0 { + if h.readOnlyGitCredentials { + readOnly := isReadOnlyGitRequest(req) + if !readOnly && proxyCtx != nil { + proxyctx.SetValue(proxyCtx, nonReadOnlyRequestCtxKey, true) + } + + if _, pw, ok := req.BasicAuth(); ok && pw != "" { + return req, nil + } + + if !readOnly { + logging.RequestLogf(proxyCtx, "* blocked authentication for non-read-only git request (method: %s, host: %s, path: %s)", req.Method, helpers.GetHost(req), req.URL.Path) + return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusForbidden, blockedGitRequestMessage) + } + } else if _, pw, ok := req.BasicAuth(); ok && pw != "" { return req, nil } @@ -304,6 +330,119 @@ func (h *GitServerHandler) HandleRequest(req *http.Request, proxyCtx *goproxy.Pr return req, nil } +func isReadOnlyGitRequest(req *http.Request) bool { + if helpers.MethodPermitted(req, http.MethodGet, http.MethodHead) { + return req.URL.Query().Get("service") != "git-receive-pack" + } + if gitproto.IsUploadPackRequest(req) { + return true + } + return isLFSDownloadRequest(req) +} + +func isLFSDownloadRequest(req *http.Request) bool { + if req.Method != http.MethodPost { + return false + } + mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")) + if err != nil || mediaType != "application/vnd.git-lfs+json" { + return false + } + if !strings.HasSuffix(req.URL.Path, "/objects/batch") || req.Body == nil { + return false + } + + var body bytes.Buffer + originalBody := req.Body + isDownload := isLFSDownloadBatch(io.TeeReader(originalBody, &body)) + req.Body = struct { + io.Reader + io.Closer + }{ + Reader: io.MultiReader(&body, originalBody), + Closer: originalBody, + } + return isDownload +} + +func isLFSDownloadBatch(reader io.Reader) bool { + decoder := json.NewDecoder(reader) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return false + } + + var operation string + operationSeen := false + for decoder.More() { + token, err = decoder.Token() + key, ok := token.(string) + if err != nil || !ok { + return false + } + + if strings.EqualFold(key, "operation") { + if key != "operation" || operationSeen { + return false + } + token, err = decoder.Token() + operation, ok = token.(string) + if err != nil || !ok { + return false + } + operationSeen = true + continue + } + + if !skipJSONValue(decoder) { + return false + } + } + + token, err = decoder.Token() + if err != nil || token != json.Delim('}') { + return false + } + if _, err = decoder.Token(); err != io.EOF { + return false + } + return operationSeen && operation == "download" +} + +func skipJSONValue(decoder *json.Decoder) bool { + token, err := decoder.Token() + if err != nil { + return false + } + + delim, ok := token.(json.Delim) + if !ok { + return true + } + + switch delim { + case '{': + for decoder.More() { + token, err = decoder.Token() + if _, ok = token.(string); err != nil || !ok || !skipJSONValue(decoder) { + return false + } + } + token, err = decoder.Token() + return err == nil && token == json.Delim('}') + case '[': + for decoder.More() { + if !skipJSONValue(decoder) { + return false + } + } + token, err = decoder.Token() + return err == nil && token == json.Delim(']') + default: + return false + } +} + // extracts the org and repo from the expected path type extractor func(path string) (org string, repo string, found bool) @@ -373,6 +512,10 @@ func (h *GitServerHandler) HandleResponse(rsp *http.Response, proxyCtx *goproxy. return rsp } + if nonReadOnly, ok := proxyctx.GetBool(proxyCtx, nonReadOnlyRequestCtxKey); ok && nonReadOnly { + return rsp + } + // Make sure we treat GHES requests like GitHub API requests. Do not retry if h.isGitHubAPIRequest(proxyCtx.Req) { return rsp @@ -537,7 +680,7 @@ func (h *GitServerHandler) isGitHubAPIRequest(req *http.Request) bool { } func (h *GitServerHandler) isGitUploadPackPost(req *http.Request) bool { - if req.Method != "POST" { + if req.Method != http.MethodPost { return false } return strings.HasSuffix(req.URL.Path, "/git-upload-pack") diff --git a/internal/handlers/git_server_test.go b/internal/handlers/git_server_test.go index 346cf86..ee8b60d 100644 --- a/internal/handlers/git_server_test.go +++ b/internal/handlers/git_server_test.go @@ -29,7 +29,7 @@ func TestGitServerHandler_url(t *testing.T) { "password": "token", } - handler := NewGitServerHandler(config.Credentials{cred}, nil) + handler := NewGitServerHandler(config.Credentials{cred}, nil, true) // Valid github git request, prioritises non-installation token req := httptest.NewRequestWithContext(t.Context(), "GET", "https://github.com/account/repo", nil) @@ -62,7 +62,7 @@ func TestGitServerHandler(t *testing.T) { rubygemsCred, proximaCred, } - handler := NewGitServerHandler(credentials, nil) + handler := NewGitServerHandler(credentials, nil, true) // Valid github git request, prioritises non-installation token req := httptest.NewRequestWithContext(t.Context(), "GET", "https://github.com/account/repo", nil) @@ -96,6 +96,10 @@ func TestGitServerHandler(t *testing.T) { gheCred.GetString("password"), "valid ghe request") + req = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "https://ghe.some-corp.com/api/v3/repos/account/repo/issues", nil) + req = handleRequestAndClose(handler, req, nil) + assertUnauthenticated(t, req, "ghe api mutation") + // Special GHE dependabot-api endpoint req = httptest.NewRequestWithContext(t.Context(), "GET", "https://ghe.some-corp.com/_dependabot/update_jobs/123/details", nil) req = handleRequestAndClose(handler, req, nil) @@ -116,7 +120,7 @@ func TestGitServerHandler(t *testing.T) { bitBucketCred, rubygemsCred, } - handler = NewGitServerHandler(credentials, nil) + handler = NewGitServerHandler(credentials, nil, true) // Valid github git request, uses installation token req = httptest.NewRequestWithContext(t.Context(), "GET", "https://github.com/account/repo", nil) @@ -127,6 +131,291 @@ func TestGitServerHandler(t *testing.T) { "valid github request") } +func TestGitServerHandler_OnlyAuthenticatesReadRequests(t *testing.T) { + credential := testGitSourceCred("github.com", "x-access-token", "github_pat_fakefakefakesuperfake") + handler := NewGitServerHandler(config.Credentials{credential}, nil, true) + + tests := []struct { + name string + method string + url string + contentType string + body string + authenticated bool + blocked bool + }{ + { + name: "get", + method: http.MethodGet, + url: "https://github.com/account/repo/info/refs?service=git-upload-pack", + authenticated: true, + }, + { + name: "head", + method: http.MethodHead, + url: "https://github.com/account/repo", + authenticated: true, + }, + { + name: "git upload pack", + method: http.MethodPost, + url: "https://github.com/account/repo/git-upload-pack", + contentType: "application/x-git-upload-pack-request", + body: "upload-pack request", + authenticated: true, + }, + { + name: "submodule checkout", + method: http.MethodPost, + url: "https://github.com/account/submodule.git/git-upload-pack", + contentType: "application/x-git-upload-pack-request", + body: "upload-pack request", + authenticated: true, + }, + { + name: "git upload pack without content type", + method: http.MethodPost, + url: "https://github.com/account/repo/git-upload-pack", + body: "upload-pack request", + blocked: true, + }, + { + name: "git receive pack discovery", + method: http.MethodGet, + url: "https://github.com/account/repo/info/refs?service=git-receive-pack", + blocked: true, + }, + { + name: "git receive pack", + method: http.MethodPost, + url: "https://github.com/account/repo/git-receive-pack", + contentType: "application/x-git-receive-pack-request", + body: "receive-pack request", + blocked: true, + }, + { + name: "lfs download", + method: http.MethodPost, + url: "https://github.com/account/repo.git/info/lfs/objects/batch", + contentType: "application/vnd.git-lfs+json; charset=utf-8", + body: `{"operation":"download","objects":[{"oid":"abc","size":3}]}`, + authenticated: true, + }, + { + name: "custom lfs download endpoint", + method: http.MethodPost, + url: "https://github.com/custom-lfs/objects/batch", + contentType: "application/vnd.git-lfs+json", + body: `{"operation":"download","objects":[{"oid":"abc","size":3}]}`, + authenticated: true, + }, + { + name: "lfs upload", + method: http.MethodPost, + url: "https://github.com/account/repo.git/info/lfs/objects/batch", + contentType: "application/vnd.git-lfs+json", + body: `{"operation":"upload","objects":[{"oid":"abc","size":3}]}`, + blocked: true, + }, + { + name: "lfs lock creation", + method: http.MethodPost, + url: "https://github.com/account/repo.git/info/lfs/locks", + contentType: "application/vnd.git-lfs+json", + body: `{"path":"file.bin"}`, + blocked: true, + }, + { + name: "lfs unlock", + method: http.MethodPost, + url: "https://github.com/account/repo.git/info/lfs/locks/123/unlock", + contentType: "application/vnd.git-lfs+json", + body: `{}`, + blocked: true, + }, + { + name: "arbitrary post", + method: http.MethodPost, + url: "https://github.com/account/repo/hooks", + contentType: "application/json", + body: `{}`, + blocked: true, + }, + { + name: "delete", + method: http.MethodDelete, + url: "https://github.com/account/repo", + blocked: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), tt.method, tt.url, strings.NewReader(tt.body)) + req.Header.Set("Content-Type", tt.contentType) + req, resp := handler.HandleRequest(req, nil) + + if tt.authenticated { + assertHasBasicAuth(t, req, credential.GetString("username"), credential.GetString("password"), "authenticated") + } else { + assertUnauthenticated(t, req, "unauthenticated") + } + if tt.blocked { + require.NotNil(t, resp) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Equal(t, goproxy.ContentTypeText, resp.Header.Get("Content-Type")) + responseBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, "Dependabot proxy blocked authentication for a non-read-only Git request\n", string(responseBody)) + } else { + assert.Nil(t, resp) + } + + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.NoError(t, req.Body.Close()) + assert.Equal(t, tt.body, string(body)) + }) + } +} + +func TestGitServerHandler_AuthenticatesWriteRequestsWhenReadOnlyCredentialsAreDisabled(t *testing.T) { + credential := testGitSourceCred("github.com", "x-access-token", "secret") + handler := NewGitServerHandler(config.Credentials{credential}, nil, false) + request := httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "https://github.com/dependabot/proxy/git-receive-pack", + strings.NewReader("write request"), + ) + + request, response := handler.HandleRequest(request, nil) + + require.Nil(t, response) + assertHasBasicAuth(t, request, "x-access-token", "secret", "write request") +} + +func TestGitServerHandler_RejectsAmbiguousLFSOperations(t *testing.T) { + credential := testGitSourceCred("github.com", "x-access-token", "proxy-token") + handler := NewGitServerHandler(config.Credentials{credential}, nil, true) + tests := []struct { + name string + body string + }{ + { + name: "case variant", + body: `{"Operation":"download","objects":[{"oid":"abc","size":3}]}`, + }, + { + name: "upload followed by case variant download", + body: `{"operation":"upload","Operation":"download","objects":[{"oid":"abc","size":3}]}`, + }, + { + name: "duplicate operation", + body: `{"operation":"upload","operation":"download","objects":[{"oid":"abc","size":3}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "https://github.com/account/repo.git/info/lfs/objects/batch", + strings.NewReader(tt.body), + ) + req.Header.Set("Content-Type", "application/vnd.git-lfs+json") + + req, response := handler.HandleRequest(req, nil) + + assertUnauthenticated(t, req, "ambiguous LFS operation") + require.NotNil(t, response) + assert.Equal(t, http.StatusForbidden, response.StatusCode) + require.NoError(t, response.Body.Close()) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.NoError(t, req.Body.Close()) + assert.Equal(t, tt.body, string(body)) + }) + } +} + +func TestGitServerHandler_DoesNotBlockIndependentlyAuthenticatedRequests(t *testing.T) { + credential := testGitSourceCred("github.com", "x-access-token", "proxy-token") + handler := NewGitServerHandler(config.Credentials{credential}, nil, true) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "https://github.com/account/repo/git-receive-pack", nil) + req.SetBasicAuth("caller", "caller-token") + + req, resp := handler.HandleRequest(req, nil) + + assert.Nil(t, resp) + assertHasBasicAuth(t, req, "caller", "caller-token", "caller authentication") +} + +func TestGitServerHandler_DoesNotRetryBlockedRequests(t *testing.T) { + credential := testGitSourceCred("github.com", "x-access-token", "proxy-token") + handler := NewGitServerHandler(config.Credentials{credential}, nil, true) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://github.com/account/repo/info/refs?service=git-receive-pack", nil) + roundTrips := 0 + roundTripper := goproxy.RoundTripperFunc(func(*http.Request, *goproxy.ProxyCtx) (*http.Response, error) { + roundTrips++ + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil + }) + proxyCtx := &goproxy.ProxyCtx{Req: req, RoundTripper: roundTripper} + + req, blockedResponse := handler.HandleRequest(req, proxyCtx) + proxyCtx.Req = req + response := handler.HandleResponse(blockedResponse, proxyCtx) + defer func() { + require.NoError(t, response.Body.Close()) + }() + + assert.Same(t, blockedResponse, response) + assert.Equal(t, http.StatusForbidden, response.StatusCode) + assert.Zero(t, roundTrips) +} + +func TestGitServerHandler_DoesNotRetryNonReadOnlyRequestsWithCallerAuth(t *testing.T) { + credential := testGitSourceCred("github.com", "x-access-token", "proxy-token") + handler := NewGitServerHandler(config.Credentials{credential}, nil, true) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://github.com/account/repo/info/refs?service=git-receive-pack", nil) + req.SetBasicAuth("caller", "caller-token") + roundTrips := 0 + roundTripper := goproxy.RoundTripperFunc(func(*http.Request, *goproxy.ProxyCtx) (*http.Response, error) { + roundTrips++ + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil + }) + proxyCtx := &goproxy.ProxyCtx{Req: req, RoundTripper: roundTripper} + + req, blockedResponse := handler.HandleRequest(req, proxyCtx) + require.Nil(t, blockedResponse) + proxyCtx.Req = req + upstreamResponse := &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader("unauthorized")), + } + response := handler.HandleResponse(upstreamResponse, proxyCtx) + defer func() { + require.NoError(t, response.Body.Close()) + }() + + assert.Same(t, upstreamResponse, response) + assert.Equal(t, http.StatusUnauthorized, response.StatusCode) + assert.Zero(t, roundTrips) +} + +func TestGitServerHandler_DoesNotBlockRequestsWithoutMatchingCredentials(t *testing.T) { + credential := testGitSourceCred("github.com", "x-access-token", "proxy-token") + handler := NewGitServerHandler(config.Credentials{credential}, nil, true) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "https://gitlab.com/account/repo/git-receive-pack", nil) + + req, resp := handler.HandleRequest(req, nil) + + assert.Nil(t, resp) + assertUnauthenticated(t, req, "unmatched host") +} + func TestGitServerHandler_AuthenticatedAccessToGitHubRepos(t *testing.T) { installationToken1 := "v1.token1" privateRepo1Cred := testGitSourceCred("github.com", "x-access-token", installationToken1, withAccessibleRepos([]string{"github/private-repo-1"})) @@ -177,7 +466,7 @@ func TestGitServerHandler_AuthenticatedAccessToGitHubRepos(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - handler := NewGitServerHandler(tt.credentials, nil) + handler := NewGitServerHandler(tt.credentials, nil, true) // Valid github git request, prioritises non-installation token req := httptest.NewRequestWithContext(t.Context(), "GET", fmt.Sprintf("https://github.com/%s", tt.repoNWO), nil) @@ -201,7 +490,7 @@ func TestGitServerHandler_AuthenticatedAccessToGitHubRepos(t *testing.T) { func TestGitServerHandler404Retry(t *testing.T) { installationCred := testGitSourceCred("github.com", "x-access-token", "v1.token") credentials := config.Credentials{installationCred} - handler := NewGitServerHandler(credentials, nil) + handler := NewGitServerHandler(credentials, nil, true) rsp := &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(""))} url, err := url.Parse("https://example.com") require.NoError(t, err) @@ -233,7 +522,7 @@ func TestGitServerHandler404Retry(t *testing.T) { func TestGitServerHandlerNoRetry(t *testing.T) { installationCred := testGitSourceCred("ghes.com", "x-access-token", "v1.token") credentials := config.Credentials{installationCred} - handler := NewGitServerHandler(credentials, nil) + handler := NewGitServerHandler(credentials, nil, true) rsp := &http.Response{StatusCode: 404} url := "https://ghes.com/api/v3" @@ -324,7 +613,7 @@ func TestGitServerHandler_TokenFallback(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - handler := NewGitServerHandler(credentials, nil) + handler := NewGitServerHandler(credentials, nil, true) var capturedTokens []string roundTripper := goproxy.RoundTripperFunc(func(r *http.Request, proxyCtx *goproxy.ProxyCtx) (*http.Response, error) { @@ -372,7 +661,7 @@ func TestGitServerHandler_TokenFallbackWithPost(t *testing.T) { testGitSourceCred("github.com", "x-access-token", installationToken), testGitSourceCred("github.com", "x-access-token", userToken), } - handler := NewGitServerHandler(credentials, nil) + handler := NewGitServerHandler(credentials, nil, true) tests := []struct { name string @@ -406,7 +695,7 @@ func TestGitServerHandler_TokenFallbackWithPost(t *testing.T) { "https://github.com/github/dependabot-action", 404, 404, - []string{userToken}, + []string{""}, }, } @@ -429,6 +718,7 @@ func TestGitServerHandler_TokenFallbackWithPost(t *testing.T) { req, err := http.NewRequestWithContext(context.Background(), "POST", tt.url, io.NopCloser(strings.NewReader("test body"))) require.NoError(t, err, "failed to create request") + req.Header.Set("Content-Type", "application/x-git-upload-pack-request") proxyCtx := &goproxy.ProxyCtx{Req: req, RoundTripper: roundTripper} rsp := &http.Response{StatusCode: tt.respCode, Body: io.NopCloser(strings.NewReader(""))} @@ -454,10 +744,11 @@ func TestGitServerHandler_NoCloneWithSingleCredPost(t *testing.T) { credentials := config.Credentials{ testGitSourceCred("github.com", "x-access-token", installationToken), } - handler := NewGitServerHandler(credentials, nil) + handler := NewGitServerHandler(credentials, nil, true) req, err := http.NewRequestWithContext(context.Background(), "POST", "https://github.com/github/dependabot-action/git-upload-pack", io.NopCloser(strings.NewReader("test body"))) require.NoError(t, err, "failed to create request") + req.Header.Set("Content-Type", "application/x-git-upload-pack-request") proxyCtx := &goproxy.ProxyCtx{Req: req} _ = handleRequestAndClose(handler, req, proxyCtx) buffer, found := proxyctx.GetBuffer(proxyCtx, reqBodyCtxKey) @@ -482,7 +773,7 @@ func TestGitServerHandler_RepositoryScopedCredentials(t *testing.T) { otherGitHubCred, bitBucketCred, } - handler := NewGitServerHandler(credentials, nil) + handler := NewGitServerHandler(credentials, nil, true) tests := map[string]string{ "valid github git request": "https://github.com/account1/repo1", @@ -549,7 +840,7 @@ func TestGitServerHandler_RequestJITAccess(t *testing.T) { credentials := config.Credentials{jitCred} testClient := &TestScopeRequester{} - handler := NewGitServerHandler(credentials, testClient) + handler := NewGitServerHandler(credentials, testClient, true) rsp := &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(""))} url, err := url.Parse(test.url) require.NoError(t, err) @@ -651,7 +942,7 @@ func TestJITEndpointUsesExplicitAuthWhenProvided(t *testing.T) { }) apiClient := apiclient.New("", "job-token-is-wrong-token", "") // other token given for the API client - handler := NewGitServerHandler(creds, apiClient) + handler := NewGitServerHandler(creds, apiClient, true) // this is the actual network request req, err := http.NewRequestWithContext(context.Background(), "GET", "https://github.com/account/repo/info/refs?service=git-upload-pack", nil) diff --git a/internal/handlers/github_api_test.go b/internal/handlers/github_api_test.go index 7541f87..6a0c780 100644 --- a/internal/handlers/github_api_test.go +++ b/internal/handlers/github_api_test.go @@ -103,6 +103,38 @@ func TestGitHubAPIHandler(t *testing.T) { } } +func TestGitHubAPIHandler_OnlyAuthenticatesReadRequests(t *testing.T) { + credential := testGitSourceCred("github.com", "x-access-token", "github_pat_fakefakefakesuperfake") + handler := NewGitHubAPIHandler(config.Credentials{credential}) + + tests := []struct { + method string + url string + authenticated bool + }{ + {method: http.MethodGet, url: "https://api.github.com/repos/account/repo", authenticated: true}, + {method: http.MethodHead, url: "https://api.github.com/repos/account/repo", authenticated: true}, + {method: http.MethodPost, url: "https://api.github.com/graphql"}, + {method: http.MethodPost, url: "https://api.github.com/repos/account/repo/issues"}, + {method: http.MethodPut, url: "https://api.github.com/repos/account/repo/contents/file"}, + {method: http.MethodPatch, url: "https://api.github.com/repos/account/repo"}, + {method: http.MethodDelete, url: "https://api.github.com/repos/account/repo"}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.url, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), tt.method, tt.url, nil) + req = handleRequestAndClose(handler, req, nil) + + if tt.authenticated { + assertHasTokenAuth(t, req, "token", credential.GetString("password"), "authenticated") + } else { + assertUnauthenticated(t, req, "unauthenticated") + } + }) + } +} + func TestGitHubAPIHandler_AuthenticatedAccessToGitHubRepos(t *testing.T) { installationToken1 := "v1.token1" privateRepo1Cred := testGitSourceCred("github.com", "x-access-token", installationToken1, withAccessibleRepos([]string{"github/private-repo-1"})) diff --git a/proxy.go b/proxy.go index 40bb2ea..3d871b3 100644 --- a/proxy.go +++ b/proxy.go @@ -94,7 +94,11 @@ func newProxyWithCacheDir(envSettings config.ProxyEnvSettings, cfg *config.Confi azureDevOpsAPIHandler := handlers.NewAzureDevOpsAPIHandler(cfg.Credentials) proxy.OnRequest().DoFunc(azureDevOpsAPIHandler.HandleRequest) - gitServerHandler := handlers.NewGitServerHandler(cfg.Credentials, apiClient) + gitServerHandler := handlers.NewGitServerHandler( + cfg.Credentials, + apiClient, + cfg.Experiments.Enabled("proxy_read_only_git_credentials"), + ) proxy.OnRequest().DoFunc(gitServerHandler.HandleRequest) proxy.OnResponse().DoFunc(gitServerHandler.HandleResponse)