diff --git a/internal/handlers/nuget_feed.go b/internal/handlers/nuget_feed.go index c59c70a..40548b4 100644 --- a/internal/handlers/nuget_feed.go +++ b/internal/handlers/nuget_feed.go @@ -2,14 +2,13 @@ package handlers import ( "bytes" - "context" "encoding/json" "encoding/xml" "io" "net/http" "net/url" "strings" - "time" + "sync" "github.com/elazarl/goproxy" @@ -17,8 +16,11 @@ import ( "github.com/dependabot/proxy/internal/helpers" "github.com/dependabot/proxy/internal/logging" "github.com/dependabot/proxy/internal/oidc" + "github.com/dependabot/proxy/internal/proxyctx" ) +const nugetDiscoveryCtxKey = "nuget.discovery-auth" + type nugetV2IndexResponse struct { Base string `xml:"base,attr"` } @@ -35,8 +37,13 @@ type nugetV3IndexResponse struct { // NugetFeedHandler handles requests to nuget feeds, adding auth. type NugetFeedHandler struct { - credentials []nugetFeedCredentials - oidcRegistry *oidc.OIDCRegistry + credentials []nugetFeedCredentials + credentialURLs map[string]struct{} + credentialsMutex sync.RWMutex + discoverySources []nugetDiscoveryAuth + discoverySourceURLs map[string]struct{} + discoveryMutex sync.RWMutex + oidcRegistry *oidc.OIDCRegistry } type nugetFeedCredentials struct { @@ -47,15 +54,20 @@ type nugetFeedCredentials struct { password string } +type nugetDiscoveryAuth struct { + serviceIndexURL string + static nugetFeedCredentials + oidc *oidc.OIDCCredential + registerRedirectAuth bool +} + // NewNugetFeedHandler returns a new NugetFeedHandler. func NewNugetFeedHandler(creds config.Credentials) *NugetFeedHandler { handler := NugetFeedHandler{ - credentials: []nugetFeedCredentials{}, - oidcRegistry: oidc.NewOIDCRegistry(), - } - - httpClient := &http.Client{ - Timeout: time.Second * 10, + credentials: []nugetFeedCredentials{}, + credentialURLs: make(map[string]struct{}), + discoverySourceURLs: make(map[string]struct{}), + oidcRegistry: oidc.NewOIDCRegistry(), } for _, cred := range creds { @@ -72,59 +84,12 @@ func NewNugetFeedHandler(creds config.Credentials) *NugetFeedHandler { oidcCredential, _, ok := handler.oidcRegistry.Register(cred, []string{"url"}, "nuget feed") if ok { - // Discover additional resource URLs from the nuget feed index. - // Host-only credentials (from the CLI) are still registered above - // for request-time matching, but discovery requires an absolute URL. - // Wrapped in a closure so defer runs promptly for each credential, - // ensuring the HTTP response body is always closed (pre-existing - // leak fixed here: the body was previously leaked on ReadAll error - // and on early-return status code paths). if url != "" { - func() { - req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil) - if err != nil { - logging.RequestLogf(nil, "error creating http request (%s): %v", url, err) - return - } - - if req.URL.Scheme != "https" { - logging.RequestLogf(nil, "refusing to discover nuget feed over non-https URL %s", url) - return - } - - if !handler.oidcRegistry.TryAuth(req, nil) { - return - } - - rawRsp, err := httpClient.Do(req) - if err != nil { - logging.RequestLogf(nil, "error retrieving http response (%s): %v", url, err) - return - } - defer rawRsp.Body.Close() - - body, err := io.ReadAll(rawRsp.Body) - if err != nil { - logging.RequestLogf(nil, "error reading http response body (%s): %v", url, err) - return - } - - switch rawRsp.StatusCode { - case 401, 403: - logging.RequestLogf(nil, "unauthorized for nuget feed %s", url) - return - } - - if rawRsp.StatusCode >= 400 { - logging.RequestLogf(nil, "unexpected http response %d for nuget feed %s", rawRsp.StatusCode, url) - return - } - - urlsToAuthenticate := extraUrlsFromSourceResponse(body, url) - for _, discoveredURL := range urlsToAuthenticate { - handler.oidcRegistry.RegisterURL(discoveredURL, oidcCredential, "nuget resource") - } - }() + handler.addDiscoverySource(nugetDiscoveryAuth{ + serviceIndexURL: url, + oidc: oidcCredential, + registerRedirectAuth: true, + }) } continue } @@ -140,58 +105,13 @@ func NewNugetFeedHandler(creds config.Credentials) *NugetFeedHandler { username: username, password: password, } - handler.credentials = append(handler.credentials, feedCred) - - // If the credentials are for a specific feed, we query the base url to find all the resources - // and authenticate them all - if url != "" { - logging.RequestLogf(nil, "fetching service index for nuget feed %s", url) - // Same closure pattern as the OIDC block above — ensures the - // HTTP response body is always closed via defer. - func() { - req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil) - if err != nil { - logging.RequestLogf(nil, "error creating http request (%s): %v", url, err) - return - } - authenticateNugetRequest(req, feedCred, nil) - - rawRsp, err := httpClient.Do(req) - if err != nil { - logging.RequestLogf(nil, "error retrieving http response (%s): %v", url, err) - return - } - defer rawRsp.Body.Close() - - body, err := io.ReadAll(rawRsp.Body) - if err != nil { - logging.RequestLogf(nil, "error reading http response body (%s): %v", url, err) - return - } - - switch rawRsp.StatusCode { - case 401, 403: - logging.RequestLogf(nil, "unauthorized for nuget feed %s", url) - return - } - - if rawRsp.StatusCode >= 400 { - logging.RequestLogf(nil, "unexpected http response %d for nuget feed %s", rawRsp.StatusCode, url) - return - } - - urlsToAuthenticate := extraUrlsFromSourceResponse(body, url) - for _, discoveredURL := range urlsToAuthenticate { - feedCred := nugetFeedCredentials{ - url: discoveredURL, - token: token, - username: username, - password: password, - } - handler.credentials = append(handler.credentials, feedCred) - logging.RequestLogf(nil, " added url to authentication list: %s", discoveredURL) - } - }() + handler.addStaticCredential(feedCred) + if url != "" && (token != "" || password != "") { + handler.addDiscoverySource(nugetDiscoveryAuth{ + serviceIndexURL: url, + static: feedCred, + registerRedirectAuth: true, + }) } } @@ -201,6 +121,10 @@ func NewNugetFeedHandler(creds config.Credentials) *NugetFeedHandler { func extraUrlsFromSourceResponse(body []byte, url string) []string { var urls []string bodyString := strings.TrimSpace(string(body)) + if bodyString == "" { + logging.RequestLogf(nil, "empty API response from NuGet feed %s", url) + return nil + } bodyReader := bytes.NewReader(body) switch { case strings.HasPrefix(bodyString, "<"): @@ -210,7 +134,7 @@ func extraUrlsFromSourceResponse(body []byte, url string) []string { // JSON v3 API urls = handleV3Response(bodyReader, url) default: - logging.RequestLogf(nil, "unknown API response: %s...", bodyString[:10]) + logging.RequestLogf(nil, "unknown API response: %.10s...", bodyString) } var result []string @@ -267,29 +191,257 @@ func handleV3Response(body io.Reader, url string) (v3Urls []string) { return } -// HandleRequest adds auth to an nuget feed request +// PrepareRequest marks configured service-index requests for response-time +// discovery. It is registered before the cache handler so cached indexes can +// teach the same resource routes as live responses. +func (h *NugetFeedHandler) PrepareRequest(req *http.Request, proxyCtx *goproxy.ProxyCtx) (*http.Request, *http.Response) { + if proxyCtx == nil || req.Method != http.MethodGet || (req.URL.Scheme != "http" && req.URL.Scheme != "https") { + return req, nil + } + + h.discoveryMutex.RLock() + defer h.discoveryMutex.RUnlock() + for _, source := range h.discoverySources { + if source.oidc != nil && req.URL.Scheme != "https" { + continue + } + if isNugetServiceIndexRequest(req, source.serviceIndexURL) { + matchedSource := source + matchedSource.serviceIndexURL = req.URL.String() + markNugetDiscovery(proxyCtx, matchedSource) + return req, nil + } + } + + return req, nil +} + +// HandleRequest adds auth to a NuGet feed request. func (h *NugetFeedHandler) HandleRequest(req *http.Request, proxyCtx *goproxy.ProxyCtx) (*http.Request, *http.Response) { if (req.URL.Scheme != "http" && req.URL.Scheme != "https") || !helpers.MethodPermitted(req, "GET", "HEAD") { return req, nil } // Try OIDC credentials first (HTTPS only to avoid leaking tokens over plaintext) - if req.URL.Scheme == "https" && h.oidcRegistry.TryAuth(req, proxyCtx) { + if req.URL.Scheme == "https" { + oidcCredential := h.oidcRegistry.CredentialForRequest(req) + if h.oidcRegistry.TryAuthCredential(req, proxyCtx, oidcCredential) { + return req, nil + } + } + + // Prefer the most specific URL credential, with host-only credentials as + // fallback. This avoids a broad credential shadowing a narrower feed. + h.credentialsMutex.RLock() + defer h.credentialsMutex.RUnlock() + var urlCredential *nugetFeedCredentials + var hostCredential *nugetFeedCredentials + bestPathLength := -1 + for i := range h.credentials { + cred := &h.credentials[i] + if cred.token == "" && cred.password == "" { + continue + } + if helpers.UrlMatchesRequest(req, cred.url, true) { + parsedURL, err := helpers.ParseURLLax(cred.url) + if err == nil && len(parsedURL.Path) > bestPathLength { + urlCredential = cred + bestPathLength = len(parsedURL.Path) + } + } + if hostCredential == nil && helpers.CheckHost(req, cred.host) { + hostCredential = cred + } + } + if urlCredential != nil { + authenticateNugetRequest(req, *urlCredential, proxyCtx) return req, nil } + if hostCredential != nil { + authenticateNugetRequest(req, *hostCredential, proxyCtx) + } + + return req, nil +} + +func (h *NugetFeedHandler) HandleResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.Response { + if resp == nil { + return resp + } + + discoveryAuth, ok := nugetDiscoveryAuthFromContext(proxyCtx) + if !ok { + return resp + } + if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest { + h.registerServiceIndexRedirect(resp, discoveryAuth, proxyCtx) + return resp + } + if resp.Body == nil || resp.Body == http.NoBody || resp.StatusCode == http.StatusNoContent || + resp.StatusCode == http.StatusResetContent || resp.StatusCode < http.StatusOK || + resp.StatusCode >= http.StatusMultipleChoices { + return resp + } + + originalBody := resp.Body + body, err := io.ReadAll(originalBody) + resp.Body = &replayReadCloser{ + Reader: io.MultiReader(bytes.NewReader(body), originalBody), + Closer: originalBody, + } + if err != nil { + return resp + } - // Fall back to static credentials - for _, cred := range h.credentials { - if (cred.token == "" && cred.password == "") || (!helpers.UrlMatchesRequest(req, cred.url, true) && !helpers.CheckHost(req, cred.host)) { + for _, discoveredURL := range extraUrlsFromSourceResponse(body, discoveryAuth.serviceIndexURL) { + if discoveryAuth.oidc != nil { + h.oidcRegistry.RegisterURL(discoveredURL, discoveryAuth.oidc, "nuget resource") continue } - authenticateNugetRequest(req, cred, proxyCtx) + credential := discoveryAuth.static + credential.url = discoveredURL + credential.host = "" + if h.addStaticCredential(credential) { + logging.RequestLogf(proxyCtx, " added url to authentication list: %s", discoveredURL) + } + } + + return resp +} - return req, nil +func (h *NugetFeedHandler) registerServiceIndexRedirect(resp *http.Response, source nugetDiscoveryAuth, proxyCtx *goproxy.ProxyCtx) { + location := resp.Header.Get("Location") + if location == "" { + return + } + baseURL, err := url.Parse(source.serviceIndexURL) + if err != nil { + return + } + locationURL, err := url.Parse(location) + if err != nil { + return + } + redirectURL := baseURL.ResolveReference(locationURL) + if redirectURL.User != nil || (redirectURL.Scheme != "http" && redirectURL.Scheme != "https") { + return + } + if source.oidc != nil && redirectURL.Scheme != "https" { + return } - return req, nil + redirectedSource := source + redirectedSource.serviceIndexURL = redirectURL.String() + redirectedSource.registerRedirectAuth = source.registerRedirectAuth && sameOrigin(baseURL, redirectURL) + if !h.addDiscoverySource(redirectedSource) { + return + } + + if redirectedSource.registerRedirectAuth { + if source.oidc != nil { + h.oidcRegistry.RegisterURL(redirectURL.String(), source.oidc, "nuget service-index redirect") + } else { + credential := source.static + credential.url = redirectURL.String() + credential.host = "" + h.addStaticCredential(credential) + } + } + logging.RequestLogf(proxyCtx, " registered nuget service-index redirect: %s", redirectURL.String()) +} + +func (h *NugetFeedHandler) addStaticCredential(credential nugetFeedCredentials) bool { + if credential.token == "" && credential.password == "" { + return false + } + + h.credentialsMutex.Lock() + defer h.credentialsMutex.Unlock() + + if credential.url != "" { + key := nugetCredentialURLKey(credential.url) + if _, ok := h.credentialURLs[key]; ok { + logging.RequestLogf(nil, "skipping duplicate NuGet credential URL because it is already registered: %s", credential.url) + return false + } + h.credentialURLs[key] = struct{}{} + } + h.credentials = append(h.credentials, credential) + return true +} + +func (h *NugetFeedHandler) addDiscoverySource(source nugetDiscoveryAuth) bool { + h.discoveryMutex.Lock() + defer h.discoveryMutex.Unlock() + + key := nugetDiscoverySourceKey(source.serviceIndexURL) + if _, ok := h.discoverySourceURLs[key]; ok { + return false + } + h.discoverySourceURLs[key] = struct{}{} + h.discoverySources = append(h.discoverySources, source) + logging.RequestLogf(nil, "registered NuGet service index for deferred discovery: %s", source.serviceIndexURL) + return true +} + +func markNugetDiscovery(proxyCtx *goproxy.ProxyCtx, auth nugetDiscoveryAuth) { + if proxyCtx != nil { + proxyctx.SetValue(proxyCtx, nugetDiscoveryCtxKey, auth) + } +} + +func nugetDiscoveryAuthFromContext(proxyCtx *goproxy.ProxyCtx) (nugetDiscoveryAuth, bool) { + if proxyCtx == nil { + return nugetDiscoveryAuth{}, false + } + value, ok := proxyctx.GetValue(proxyCtx, nugetDiscoveryCtxKey) + if !ok { + return nugetDiscoveryAuth{}, false + } + auth, ok := value.(nugetDiscoveryAuth) + return auth, ok +} + +func isNugetServiceIndexRequest(req *http.Request, sourceURL string) bool { + if req.Method != http.MethodGet { + return false + } + parsedURL, err := helpers.ParseURLLax(sourceURL) + if err != nil || !helpers.UrlMatchesRequest(req, sourceURL, true) { + return false + } + if parsedURL.Scheme != "" && !strings.EqualFold(parsedURL.Scheme, req.URL.Scheme) { + return false + } + return strings.TrimRight(parsedURL.Path, "/") == strings.TrimRight(req.URL.Path, "/") && + parsedURL.RawQuery == req.URL.RawQuery +} + +func nugetCredentialURLKey(rawURL string) string { + parsedURL, err := helpers.ParseURLLax(rawURL) + if err != nil { + return rawURL + } + port := parsedURL.Port() + if port == "" { + port = "443" + } + return strings.ToLower(parsedURL.Hostname()) + ":" + port + strings.TrimRight(parsedURL.Path, "/") + "?" + parsedURL.RawQuery +} + +// Discovery matching distinguishes explicit schemes, while static credential +// matching intentionally remains scheme-agnostic for backwards compatibility. +func nugetDiscoverySourceKey(rawURL string) string { + parsedURL, err := helpers.ParseURLLax(rawURL) + if err != nil { + return rawURL + } + scheme := strings.ToLower(parsedURL.Scheme) + if scheme == "" { + scheme = "*" + } + return scheme + "|" + nugetCredentialURLKey(rawURL) } func authenticateNugetRequest(req *http.Request, cred nugetFeedCredentials, proxyCtx *goproxy.ProxyCtx) { diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index f6416f3..ed6bbe2 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -2,13 +2,18 @@ package handlers import ( "bytes" + "encoding/json" "fmt" + "io" "log" + "net/http" "net/http/httptest" "net/url" "strings" + "sync" "testing" + "github.com/elazarl/goproxy" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -62,9 +67,6 @@ func TestNugetFeedHandler(t *testing.T) { }, } - httpmock.Activate() - defer httpmock.DeactivateAndReset() - rsp := nugetV3IndexResponse{ Resource: []nugetV3IndexResource{ { @@ -82,10 +84,6 @@ func TestNugetFeedHandler(t *testing.T) { }, } - jsonResponder, err := httpmock.NewJsonResponder(200, rsp) - require.NoError(t, err) - httpmock.RegisterResponder("GET", "https://corp.dependabot.com/nuget/", jsonResponder) - xmlResponse := ` @@ -95,9 +93,6 @@ func TestNugetFeedHandler(t *testing.T) { ` - xmlResponder := httpmock.NewStringResponder(200, xmlResponse) - httpmock.RegisterResponder("GET", "https://nuget.example.com/v2", xmlResponder) - httpmock.RegisterResponder("GET", "https://nuget.example.com/auth-required/v3", httpmock.NewStringResponder(401, "missing authentication")) azureDevOpsRsp := nugetV3IndexResponse{ Resource: []nugetV3IndexResource{ @@ -108,10 +103,6 @@ func TestNugetFeedHandler(t *testing.T) { }, } - azureDevOpsJsonResponder, err := httpmock.NewJsonResponder(200, azureDevOpsRsp) - require.NoError(t, err) - httpmock.RegisterResponder("GET", "https://pkgs.dev.azure.com/example/public/_packaging/some-feed/nuget/v3/index.json", azureDevOpsJsonResponder) - azureDevOpsRsp2 := nugetV3IndexResponse{ Resource: []nugetV3IndexResource{ { @@ -121,17 +112,14 @@ func TestNugetFeedHandler(t *testing.T) { }, } - azureDevOpsJsonResponder2, err := httpmock.NewJsonResponder(200, azureDevOpsRsp2) - require.NoError(t, err) - httpmock.RegisterResponder("GET", "https://pkgs.dev.azure.com/example/public/_packaging/some-feed2/nuget/v3/index.json", azureDevOpsJsonResponder2) - - // Log for initial authentication contains appropriate information var buf bytes.Buffer log.SetOutput(&buf) handler := NewNugetFeedHandler(credentials) - logContents := buf.String() - assert.False(t, strings.Contains(logContents, "* authenticating nuget feed request (host: api.nuget.org, bearer auth)"), "don't authenticate a feed without a token or password") - assert.True(t, strings.Contains(logContents, "unauthorized for nuget feed https://nuget.example.com/auth-required/v3"), "authentication failure is reported") + + discoverNugetFeed(t, handler, "https://corp.dependabot.com/nuget/", http.StatusOK, mustMarshalJSON(t, rsp)) + discoverNugetFeed(t, handler, "https://nuget.example.com/v2", http.StatusOK, xmlResponse) + discoverNugetFeed(t, handler, "https://pkgs.dev.azure.com/example/public/_packaging/some-feed/nuget/v3/index.json", http.StatusOK, mustMarshalJSON(t, azureDevOpsRsp)) + discoverNugetFeed(t, handler, "https://pkgs.dev.azure.com/example/public/_packaging/some-feed2/nuget/v3/index.json", http.StatusOK, mustMarshalJSON(t, azureDevOpsRsp2)) req := httptest.NewRequestWithContext(t.Context(), "GET", "https://corp.dependabot.com/nuget", nil) req = handleRequestAndClose(handler, req, nil) @@ -206,7 +194,7 @@ func TestNugetFeedHandler(t *testing.T) { req = httptest.NewRequestWithContext(t.Context(), "GET", "https://pkgs.dev.azure.com/example/public/_packaging/some-feed/nuget/v3/some.package/index.json", nil) req = handleRequestAndClose(handler, req, nil) assertHasBasicAuth(t, req, "", dependabotToken, "Azure DevOps token handling") - logContents = buf.String() + logContents := buf.String() assert.True(t, strings.Contains(logContents, ", basic auth for Azure DevOps)"), "expected Azure DevOps token handling") // Check Azure token edge case in which it has a prepended ":" and is treated as a password successfully @@ -306,9 +294,6 @@ func TestExtraAuthenticatedURLsAreReportedInTheLog(t *testing.T) { }, } - httpmock.Activate() - defer httpmock.DeactivateAndReset() - jsonResponse := `{ "version": "3.0.0", "resources": [ @@ -326,15 +311,464 @@ func TestExtraAuthenticatedURLsAreReportedInTheLog(t *testing.T) { } ] }` - jsonResponder := httpmock.NewStringResponder(200, jsonResponse) - httpmock.RegisterResponder("GET", "https://nuget.example.com/index.json", jsonResponder) var buf bytes.Buffer log.SetOutput(&buf) - NewNugetFeedHandler(credentials) + handler := NewNugetFeedHandler(credentials) + discoverNugetFeed(t, handler, "https://nuget.example.com/index.json", http.StatusOK, jsonResponse) logContents := buf.String() assert.True(t, strings.Contains(logContents, " added url to authentication list: https://nuget.example.com/v3/packages"), "include PackageBaseAddress") assert.True(t, strings.Contains(logContents, " added url to authentication list: https://nuget.example.com/v3/query"), "include SearchQueryService") assert.True(t, strings.Contains(logContents, " added url to authentication list: https://nuget.example.com/v3/unknown"), "include SomeUnknownServiceTypeButShouldStillBeIncluded") } + +func TestNewNugetFeedHandlerDoesNotMakeHTTPRequests(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://unreachable.example.com/index.json", "some-token"), + }) + + assert.Zero(t, httpmock.GetTotalCallCount()) +} + +func TestNugetFeedHandlerDiscoversFromPreparedResponse(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), + }) + discoverNugetFeed(t, handler, "https://nuget.example.com/index.json", http.StatusOK, + nugetV3Response("https://cdn.example.com/packages")) + + packageReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://cdn.example.com/packages/example/1.0.0/example.nupkg", nil) + packageReq = handleRequestAndClose(handler, packageReq, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, packageReq, "Bearer", "some-token", "resource learned from prepared response") +} + +func TestNugetFeedHandlerSkipsDiscoveryFromUnsuccessfulResponse(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), + }) + discoverNugetFeed(t, handler, "https://nuget.example.com/index.json", http.StatusUnauthorized, + nugetV3Response("https://cdn.example.com/packages")) + + packageReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://cdn.example.com/packages/example/index.json", nil) + packageReq = handleRequestAndClose(handler, packageReq, &goproxy.ProxyCtx{}) + assertUnauthenticated(t, packageReq, "resource from unsuccessful response") +} + +func TestNugetFeedHandlerLeavesBodylessResponseUnchanged(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), + }) + + for _, statusCode := range []int{http.StatusNoContent, http.StatusResetContent} { + proxyCtx := &goproxy.ProxyCtx{} + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/index.json", nil) + handler.PrepareRequest(req, proxyCtx) + resp := &http.Response{StatusCode: statusCode, Body: http.NoBody} + + handler.HandleResponse(resp, proxyCtx) + + assert.True(t, resp.Body == http.NoBody) + } +} + +func TestNugetFeedHandlerReplaysBodyAfterReadError(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), + }) + proxyCtx := &goproxy.ProxyCtx{} + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/index.json", nil) + handler.PrepareRequest(req, proxyCtx) + originalBody := &readErrorThenData{ + first: []byte("first"), + rest: strings.NewReader("second"), + } + resp := &http.Response{StatusCode: http.StatusOK, Body: originalBody} + + handler.HandleResponse(resp, proxyCtx) + replayed, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "firstsecond", string(replayed)) +} + +func TestNugetFeedHandlerOnlyDiscoversFromConfiguredServiceIndex(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://nuget.example.com/v3/", "some-token"), + }) + proxyCtx := &goproxy.ProxyCtx{} + packageReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/v3/some-package/index.json", nil) + handler.PrepareRequest(packageReq, proxyCtx) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(nugetV3Response("https://untrusted.example.com/packages"))), + } + handler.HandleResponse(resp, proxyCtx) + + untrustedReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://untrusted.example.com/packages/example/index.json", nil) + untrustedReq = handleRequestAndClose(handler, untrustedReq, &goproxy.ProxyCtx{}) + assertUnauthenticated(t, untrustedReq, "resource from non-index response") +} + +func TestNugetFeedHandlerRequiresConfiguredServiceIndexSchemeForDiscovery(t *testing.T) { + testCases := []struct { + name string + configuredURL string + requestedURL string + expectCredentials bool + }{ + { + name: "HTTPS source does not trust HTTP response", + configuredURL: "https://nuget.example.com/v3/index.json", + requestedURL: "http://nuget.example.com/v3/index.json", + expectCredentials: false, + }, + { + name: "HTTP source does not trust HTTPS response", + configuredURL: "http://nuget.example.com/v3/index.json", + requestedURL: "https://nuget.example.com/v3/index.json", + expectCredentials: false, + }, + { + name: "scheme-less source trusts HTTP response", + configuredURL: "nuget.example.com/v3/index.json", + requestedURL: "http://nuget.example.com/v3/index.json", + expectCredentials: true, + }, + { + name: "scheme-less source trusts HTTPS response", + configuredURL: "nuget.example.com/v3/index.json", + requestedURL: "https://nuget.example.com/v3/index.json", + expectCredentials: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential(testCase.configuredURL, "some-token"), + }) + proxyCtx := &goproxy.ProxyCtx{} + indexReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, testCase.requestedURL, nil) + handler.PrepareRequest(indexReq, proxyCtx) + handler.HandleResponse(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(nugetV3Response("https://attacker.example.com/packages"))), + }, proxyCtx) + + packageReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://attacker.example.com/packages/example/index.json", nil) + packageReq = handleRequestAndClose(handler, packageReq, &goproxy.ProxyCtx{}) + if testCase.expectCredentials { + assertHasTokenAuth(t, packageReq, "Bearer", "some-token", "resource from scheme-less service index") + } else { + assertUnauthenticated(t, packageReq, "resource from mismatched service-index scheme") + } + }) + } +} + +func TestNugetFeedHandlerKeepsHTTPAndHTTPSDiscoverySourcesDistinct(t *testing.T) { + httpCredential := testNugetFeedCredential("http://nuget.example.com/v3/index.json", "http-token") + httpsCredential := testNugetFeedCredential("https://nuget.example.com/v3/index.json", "https-token") + + for _, credentials := range []config.Credentials{ + {httpCredential, httpsCredential}, + {httpsCredential, httpCredential}, + } { + handler := NewNugetFeedHandler(credentials) + discoverNugetFeed(t, handler, "http://nuget.example.com/v3/index.json", http.StatusOK, + nugetV3Response("https://http-resource.example.com/packages")) + discoverNugetFeed(t, handler, "https://nuget.example.com/v3/index.json", http.StatusOK, + nugetV3Response("https://https-resource.example.com/packages")) + + httpResourceReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://http-resource.example.com/packages/example/index.json", nil) + httpResourceReq = handleRequestAndClose(handler, httpResourceReq, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, httpResourceReq, "Bearer", "http-token", "resource discovered from HTTP index") + + httpsResourceReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://https-resource.example.com/packages/example/index.json", nil) + httpsResourceReq = handleRequestAndClose(handler, httpsResourceReq, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, httpsResourceReq, "Bearer", "https-token", "resource discovered from HTTPS index") + } +} + +func TestNugetFeedHandlerConcurrentDiscoveryIsDeduplicated(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), + }) + const workers = 50 + responseBody := nugetV3Response("https://cdn.example.com/packages") + start := make(chan struct{}) + var waitGroup sync.WaitGroup + for range workers { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + <-start + proxyCtx := &goproxy.ProxyCtx{} + indexReq := httptest.NewRequest(http.MethodGet, "https://nuget.example.com/index.json", nil) + handler.PrepareRequest(indexReq, proxyCtx) + handler.HandleRequest(indexReq, proxyCtx) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(responseBody)), + } + handler.HandleResponse(resp, proxyCtx) + resp.Body.Close() + + packageReq := httptest.NewRequest(http.MethodGet, "https://cdn.example.com/packages/example/index.json", nil) + handler.HandleRequest(packageReq, &goproxy.ProxyCtx{}) + }() + } + close(start) + waitGroup.Wait() + + handler.credentialsMutex.RLock() + defer handler.credentialsMutex.RUnlock() + assert.Len(t, handler.credentials, 2) +} + +func TestNugetFeedHandlerIgnoresUnusableStaticCredentials(t *testing.T) { + usableCredential := testNugetFeedCredential("https://nuget.example.com/v3/index.json", "some-token") + unusableCredential := config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/v3/index.json", + } + + for _, credentials := range []config.Credentials{ + {unusableCredential, usableCredential}, + {usableCredential, unusableCredential}, + } { + handler := NewNugetFeedHandler(credentials) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/v3/index.json", nil) + req = handleRequestAndClose(handler, req, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, req, "Bearer", "some-token", "usable duplicate credential") + } +} + +func TestNugetFeedHandlerLogsIgnoredDuplicateResourceURL(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://first.example.com/index.json", "first-token"), + testNugetFeedCredential("https://second.example.com/index.json", "second-token"), + }) + + const resourceURL = "https://shared.example.com/packages" + discoverNugetFeed(t, handler, "https://first.example.com/index.json", http.StatusOK, nugetV3Response(resourceURL)) + discoverNugetFeed(t, handler, "https://second.example.com/index.json", http.StatusOK, nugetV3Response(resourceURL)) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, resourceURL+"/example/index.json", nil) + req = handleRequestAndClose(handler, req, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, req, "Bearer", "first-token", "first credential registered for shared resource") + assert.Contains(t, buf.String(), "skipping duplicate NuGet credential URL because it is already registered: "+resourceURL) +} + +func TestNugetFeedHandlerUnusableCredentialDoesNotBlockDiscoveredCredential(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + config.Credential{ + "type": "nuget_feed", + "url": "https://cdn.example.com/packages", + }, + testNugetFeedCredential("https://nuget.example.com/v3/index.json", "some-token"), + }) + discoverNugetFeed(t, handler, "https://nuget.example.com/v3/index.json", http.StatusOK, + nugetV3Response("https://cdn.example.com/packages")) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://cdn.example.com/packages/example/index.json", nil) + req = handleRequestAndClose(handler, req, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, req, "Bearer", "some-token", "discovered credential replacing unusable entry") +} + +func TestNugetFeedHandlerPrefersMostSpecificURLCredential(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://nuget.example.com/feed", "broad-token"), + testNugetFeedCredential("https://nuget.example.com/feed/specific", "specific-token"), + config.Credential{ + "type": "nuget_feed", + "host": "nuget.example.com", + "username": "host-user", + "password": "host-password", + }, + }) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/feed/specific/package/index.json", nil) + req = handleRequestAndClose(handler, req, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, req, "Bearer", "specific-token", "most specific URL credential") +} + +func TestNugetFeedHandlerDiscoversThroughCrossOriginRedirectWithoutLeakingCredentials(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), + }) + + initialCtx := &goproxy.ProxyCtx{} + initialReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/index.json", nil) + handler.PrepareRequest(initialReq, initialCtx) + initialReq = handleRequestAndClose(handler, initialReq, initialCtx) + assertHasTokenAuth(t, initialReq, "Bearer", "some-token", "configured service index") + handler.HandleResponse(&http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": []string{"https://redirect.example.com/index.json"}}, + }, initialCtx) + + redirectCtx := &goproxy.ProxyCtx{} + redirectReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://redirect.example.com/index.json", nil) + handler.PrepareRequest(redirectReq, redirectCtx) + redirectReq = handleRequestAndClose(handler, redirectReq, redirectCtx) + assertUnauthenticated(t, redirectReq, "cross-origin service-index redirect") + handler.HandleResponse(&http.Response{ + StatusCode: http.StatusTemporaryRedirect, + Header: http.Header{"Location": []string{"/v3/index.json"}}, + }, redirectCtx) + + finalCtx := &goproxy.ProxyCtx{} + finalReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://redirect.example.com/v3/index.json", nil) + handler.PrepareRequest(finalReq, finalCtx) + finalReq = handleRequestAndClose(handler, finalReq, finalCtx) + assertUnauthenticated(t, finalReq, "redirect after cross-origin service-index redirect") + finalResp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(nugetV3Response("https://cdn.example.com/packages"))), + } + handler.HandleResponse(finalResp, finalCtx) + + packageReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://cdn.example.com/packages/example/index.json", nil) + packageReq = handleRequestAndClose(handler, packageReq, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, packageReq, "Bearer", "some-token", "resource learned through cross-origin redirect") +} + +func TestNugetFeedHandlerAuthenticatesSameOriginServiceIndexRedirect(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), + }) + + initialCtx := &goproxy.ProxyCtx{} + initialReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/index.json", nil) + handler.PrepareRequest(initialReq, initialCtx) + handleRequestAndClose(handler, initialReq, initialCtx) + handler.HandleResponse(&http.Response{ + StatusCode: http.StatusTemporaryRedirect, + Header: http.Header{"Location": []string{"/v3/index.json"}}, + }, initialCtx) + + redirectReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/v3/index.json", nil) + redirectReq = handleRequestAndClose(handler, redirectReq, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, redirectReq, "Bearer", "some-token", "same-origin service-index redirect") +} + +func TestNugetFeedHandlerResolvesRelativeRedirectAgainstRequestedServiceIndexURL(t *testing.T) { + testCases := []struct { + name string + configuredURL string + requestedURL string + redirectURL string + }{ + { + name: "configured URL has trailing slash", + configuredURL: "https://nuget.example.com/v3/", + requestedURL: "https://nuget.example.com/v3", + redirectURL: "https://nuget.example.com/next.json", + }, + { + name: "requested URL has trailing slash", + configuredURL: "https://nuget.example.com/v3", + requestedURL: "https://nuget.example.com/v3/", + redirectURL: "https://nuget.example.com/v3/next.json", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + testNugetFeedCredential(testCase.configuredURL, "some-token"), + }) + + initialCtx := &goproxy.ProxyCtx{} + initialReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, testCase.requestedURL, nil) + handler.PrepareRequest(initialReq, initialCtx) + handler.HandleResponse(&http.Response{ + StatusCode: http.StatusTemporaryRedirect, + Header: http.Header{"Location": []string{"next.json"}}, + }, initialCtx) + + redirectCtx := &goproxy.ProxyCtx{} + redirectReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, testCase.redirectURL, nil) + handler.PrepareRequest(redirectReq, redirectCtx) + handler.HandleResponse(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(nugetV3Response("https://cdn.example.com/packages"))), + }, redirectCtx) + + packageReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://cdn.example.com/packages/example/index.json", nil) + packageReq = handleRequestAndClose(handler, packageReq, &goproxy.ProxyCtx{}) + assertHasTokenAuth(t, packageReq, "Bearer", "some-token", "resource learned through relative redirect") + }) + } +} + +func TestExtraUrlsFromSourceResponseHandlesShortUnknownBody(t *testing.T) { + assert.NotPanics(t, func() { + assert.Empty(t, extraUrlsFromSourceResponse([]byte("x"), "https://nuget.example.com/index.json")) + }) +} + +func TestExtraUrlsFromSourceResponseHandlesBlankBody(t *testing.T) { + assert.Empty(t, extraUrlsFromSourceResponse(nil, "https://nuget.example.com/index.json")) +} + +func discoverNugetFeed(t *testing.T, handler *NugetFeedHandler, sourceURL string, statusCode int, responseBody string) { + t.Helper() + proxyCtx := &goproxy.ProxyCtx{} + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, sourceURL, nil) + handler.PrepareRequest(req, proxyCtx) + req = handleRequestAndClose(handler, req, proxyCtx) + + resp := &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader(responseBody)), + } + handler.HandleResponse(resp, proxyCtx) + + replayedBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, responseBody, string(replayedBody)) + require.NoError(t, resp.Body.Close()) +} + +func testNugetFeedCredential(rawURL, token string) config.Credential { + return config.Credential{ + "type": "nuget_feed", + "url": rawURL, + "token": token, + } +} + +func nugetV3Response(resourceURL string) string { + return fmt.Sprintf(`{"version":"3.0.0","resources":[{"@id":%q,"@type":"PackageBaseAddress/3.0.0"}]}`, resourceURL) +} + +func mustMarshalJSON(t *testing.T, value any) string { + t.Helper() + body, err := json.Marshal(value) + require.NoError(t, err) + return string(body) +} + +type readErrorThenData struct { + first []byte + rest io.Reader +} + +func (r *readErrorThenData) Read(p []byte) (int, error) { + if r.first != nil { + n := copy(p, r.first) + r.first = nil + return n, assert.AnError + } + return r.rest.Read(p) +} + +func (r *readErrorThenData) Close() error { return nil } diff --git a/internal/handlers/oidc_handling_test.go b/internal/handlers/oidc_handling_test.go index be186cf..705ca18 100644 --- a/internal/handlers/oidc_handling_test.go +++ b/internal/handlers/oidc_handling_test.go @@ -21,12 +21,6 @@ type oidcHandler interface { HandleRequest(req *http.Request, proxyCtx *goproxy.ProxyCtx) (*http.Request, *http.Response) } -type mockHttpRequest struct { - verb string - url string - response string -} - func TestOIDCURLsAreAuthenticated(t *testing.T) { testTenantId := "12345678-1234-1234-1234-123456789012" testClientId := "87654321-4321-4321-4321-210987654321" @@ -36,7 +30,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { provider string handlerFactory func(creds config.Credentials) oidcHandler credentials config.Credentials - urlMocks []mockHttpRequest + serviceIndexURL string + resourceURL string expectedLogLines []string urlsToAuthenticate []string }{ @@ -60,7 +55,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for cargo registry: https://cargo.example.com/packages", }, @@ -82,7 +76,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for cargo registry: https://cargo.example.com/packages", }, @@ -103,7 +96,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for cargo registry: https://jfrog.example.com/packages", }, @@ -126,7 +118,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for cargo registry: https://cloudsmith.example.com", }, @@ -147,7 +138,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for cargo registry: https://us-central1-cargo.pkg.dev/my-project/my-repo", }, @@ -175,7 +165,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for composer repository: https://composer.example.com", }, @@ -197,7 +186,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for composer repository: https://composer.example.com", }, @@ -219,7 +207,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for composer repository: https://jfrog.example.com", }, @@ -242,7 +229,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for composer repository: https://cloudsmith.example.com", }, @@ -263,7 +249,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for composer repository: https://us-central1-composer.pkg.dev/my-project/my-repo", }, @@ -292,7 +277,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for docker registry: https://docker.example.com", }, @@ -314,7 +298,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for docker registry: https://docker.example.com", }, @@ -335,7 +318,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for docker registry: jfrog.example.com", }, @@ -358,7 +340,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for docker registry: https://cloudsmith.example.com", }, @@ -379,7 +360,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for docker registry: https://us-central1-docker.pkg.dev", }, @@ -407,7 +387,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for goproxy server: https://goproxy.example.com", }, @@ -429,7 +408,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for goproxy server: goproxy.example.com", }, @@ -450,7 +428,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for goproxy server: https://jfrog.example.com", }, @@ -473,7 +450,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for goproxy server: https://cloudsmith.example.com", }, @@ -494,7 +470,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for goproxy server: https://us-central1-go.pkg.dev/my-project/my-repo", }, @@ -522,7 +497,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for helm registry: https://helm.example.com", }, @@ -544,7 +518,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for helm registry: https://helm.example.com", }, @@ -565,7 +538,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for helm registry: jfrog.example.com", }, @@ -588,7 +560,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for helm registry: https://cloudsmith.example.com", }, @@ -609,7 +580,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for helm registry: https://us-central1-helm.pkg.dev/my-project/my-repo", }, @@ -637,7 +607,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for hex repository: https://hex.example.com", }, @@ -659,7 +628,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for hex repository: https://hex.example.com", }, @@ -680,7 +648,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for hex repository: https://jfrog.example.com", }, @@ -703,7 +670,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for hex repository: https://cloudsmith.example.com", }, @@ -724,7 +690,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for hex repository: https://us-central1-hex.pkg.dev/my-project/my-repo", }, @@ -752,7 +717,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for maven repository: https://maven.example.com/packages", }, @@ -774,7 +738,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for maven repository: https://maven.example.com/packages", }, @@ -795,7 +758,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for maven repository: https://jfrog.example.com/packages", }, @@ -818,7 +780,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for maven repository: https://cloudsmith.example.com", }, @@ -839,7 +800,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for maven repository: https://us-central1-maven.pkg.dev/my-project/my-repo", }, @@ -867,7 +827,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for npm registry: https://npm.example.com", }, @@ -889,7 +848,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for npm registry: https://npm.example.com", }, @@ -910,7 +868,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for npm registry: https://jfrog.example.com", }, @@ -933,7 +890,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for npm registry: https://cloudsmith.example.com", }, @@ -954,7 +910,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for npm registry: https://us-central1-npm.pkg.dev/my-project/my-repo", }, @@ -982,16 +937,10 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{ - { - verb: "GET", - url: "https://nuget.example.com/index.json", - response: `{"version":"3.0.0","resources":[{"@id":"https://nuget.example.com/v3/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - }, - }, + serviceIndexURL: "https://nuget.example.com/index.json", + resourceURL: "https://nuget.example.com/v3/packages", expectedLogLines: []string{ "registered aws OIDC credentials for nuget feed: https://nuget.example.com/index.json", - "registered aws OIDC credentials for nuget resource: https://nuget.example.com/v3/packages", }, urlsToAuthenticate: []string{ "https://nuget.example.com/index.json", // base url @@ -1012,16 +961,10 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{ - { - verb: "GET", - url: "https://nuget.example.com/index.json", - response: `{"version":"3.0.0","resources":[{"@id":"https://nuget.example.com/v3/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - }, - }, + serviceIndexURL: "https://nuget.example.com/index.json", + resourceURL: "https://nuget.example.com/v3/packages", expectedLogLines: []string{ "registered azure OIDC credentials for nuget feed: https://nuget.example.com/index.json", - "registered azure OIDC credentials for nuget resource: https://nuget.example.com/v3/packages", }, urlsToAuthenticate: []string{ "https://nuget.example.com/index.json", // base url @@ -1041,16 +984,10 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{ - { - verb: "GET", - url: "https://jfrog.example.com/index.json", - response: `{"version":"3.0.0","resources":[{"@id":"https://jfrog.example.com/v3/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - }, - }, + serviceIndexURL: "https://jfrog.example.com/index.json", + resourceURL: "https://jfrog.example.com/v3/packages", expectedLogLines: []string{ "registered jfrog OIDC credentials for nuget feed: https://jfrog.example.com/index.json", - "registered jfrog OIDC credentials for nuget resource: https://jfrog.example.com/v3/packages", }, urlsToAuthenticate: []string{ "https://jfrog.example.com/index.json", // base url @@ -1072,16 +1009,10 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{ - { - verb: "GET", - url: "https://cloudsmith.example.com/v3/index.json", - response: `{"version":"3.0.0","resources":[{"@id":"https://cloudsmith.example.com/v3/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - }, - }, + serviceIndexURL: "https://cloudsmith.example.com/v3/index.json", + resourceURL: "https://cloudsmith.example.com/v3/packages", expectedLogLines: []string{ "registered cloudsmith OIDC credentials for nuget feed: https://cloudsmith.example.com/v3/index.json", - "registered cloudsmith OIDC credentials for nuget resource: https://cloudsmith.example.com/v3/packages", }, urlsToAuthenticate: []string{ "https://cloudsmith.example.com/v3/index.json", // base url @@ -1101,16 +1032,10 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{ - { - verb: "GET", - url: "https://us-central1-nuget.pkg.dev/my-project/my-repo/index.json", - response: `{"version":"3.0.0","resources":[{"@id":"https://us-central1-nuget.pkg.dev/my-project/my-repo/v3/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - }, - }, + serviceIndexURL: "https://us-central1-nuget.pkg.dev/my-project/my-repo/index.json", + resourceURL: "https://us-central1-nuget.pkg.dev/my-project/my-repo/v3/packages", expectedLogLines: []string{ "registered gcp OIDC credentials for nuget feed: https://us-central1-nuget.pkg.dev/my-project/my-repo/index.json", - "registered gcp OIDC credentials for nuget resource: https://us-central1-nuget.pkg.dev/my-project/my-repo/v3/packages", }, urlsToAuthenticate: []string{ "https://us-central1-nuget.pkg.dev/my-project/my-repo/index.json", // base url @@ -1137,7 +1062,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for pub repository: https://pub.example.com", }, @@ -1159,7 +1083,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for pub repository: https://pub.example.com", }, @@ -1180,7 +1103,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for pub repository: https://jfrog.example.com", }, @@ -1203,7 +1125,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for pub repository: https://cloudsmith.example.com", }, @@ -1224,7 +1145,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for pub repository: https://us-central1-pub.pkg.dev/my-project/my-repo", }, @@ -1252,7 +1172,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for python index: https://python.example.com", }, @@ -1274,7 +1193,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for python index: https://python.example.com", }, @@ -1295,7 +1213,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for python index: https://jfrog.example.com", }, @@ -1318,7 +1235,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for python index: https://cloudsmith.example.com", }, @@ -1339,7 +1255,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for python index: https://us-central1-python.pkg.dev/my-project/my-repo/", }, @@ -1367,7 +1282,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for rubygems server: https://rubygems.example.com", }, @@ -1389,7 +1303,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for rubygems server: https://rubygems.example.com", }, @@ -1411,7 +1324,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for rubygems server: https://jfrog.example.com", }, @@ -1435,7 +1347,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for rubygems server: https://cloudsmith.example.com", }, @@ -1457,7 +1368,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for rubygems server: https://us-central1-ruby.pkg.dev/my-project/my-repo", }, @@ -1485,7 +1395,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for terraform registry: https://terraform.example.com", }, @@ -1507,7 +1416,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for terraform registry: https://terraform.example.com", }, @@ -1528,7 +1436,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered jfrog OIDC credentials for terraform registry: https://jfrog.example.com", }, @@ -1551,7 +1458,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for terraform registry: https://cloudsmith.example.com", }, @@ -1572,7 +1478,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered gcp OIDC credentials for terraform registry: https://us-central1-terraform.pkg.dev/my-project/my-repo", }, @@ -1586,12 +1491,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() - // mock URLs - for _, mockReq := range tc.urlMocks { - httpmock.RegisterResponder(mockReq.verb, mockReq.url, - httpmock.NewStringResponder(200, mockReq.response)) - } - // mock GitHub OIDC token request tokenUrl := "https://token.actions.example.com" //nolint:gosec // test URL httpmock.RegisterResponder("GET", tokenUrl, @@ -1659,6 +1558,13 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) handler := tc.handlerFactory(tc.credentials) + if tc.serviceIndexURL != "" { + nugetHandler, ok := handler.(*NugetFeedHandler) + if !assert.True(t, ok, "handler with a service index should be a NuGet handler") { + return + } + discoverNugetFeed(t, nugetHandler, tc.serviceIndexURL, http.StatusOK, nugetV3Response(tc.resourceURL)) + } logContents := buf.String() // check expected log lines diff --git a/proxy.go b/proxy.go index 33d4a7a..40bb2ea 100644 --- a/proxy.go +++ b/proxy.go @@ -68,6 +68,9 @@ func newProxyWithCacheDir(envSettings config.ProxyEnvSettings, cfg *config.Confi proxy.OnRequest().DoFunc(logger.logRequest) proxy.OnResponse().DoFunc(logger.logResponse) + nugetFeedHandler := handlers.NewNugetFeedHandler(cfg.Credentials) + proxy.OnRequest().DoFunc(nugetFeedHandler.PrepareRequest) + enableCache := os.Getenv("PROXY_CACHE") == "true" cacher, err := cache.New(enableCache, cacheDir) if err != nil { @@ -117,8 +120,8 @@ func newProxyWithCacheDir(envSettings config.ProxyEnvSettings, cfg *config.Confi rubyGemsServerHandler := handlers.NewRubyGemsServerHandler(cfg.Credentials) proxy.OnRequest().DoFunc(rubyGemsServerHandler.HandleRequest) - nugetFeedHandler := handlers.NewNugetFeedHandler(cfg.Credentials) proxy.OnRequest().DoFunc(nugetFeedHandler.HandleRequest) + proxy.OnResponse().DoFunc(nugetFeedHandler.HandleResponse) mavenRepositoryHandler := handlers.NewMavenRepositoryHandler(cfg.Credentials) proxy.OnRequest().DoFunc(mavenRepositoryHandler.HandleRequest)