From 83f04f41cdaf4dd14406528a1a507439e3e144c2 Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Fri, 14 Aug 2026 00:38:41 +0000 Subject: [PATCH 01/11] Defer NuGet service index discovery --- internal/handlers/nuget_feed.go | 374 ++++++++++++++++-------- internal/handlers/nuget_feed_test.go | 291 ++++++++++++++++-- internal/handlers/oidc_handling_test.go | 24 ++ proxy.go | 5 +- 4 files changed, 541 insertions(+), 153 deletions(-) diff --git a/internal/handlers/nuget_feed.go b/internal/handlers/nuget_feed.go index c59c70a..99ddcc7 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,21 @@ type nugetFeedCredentials struct { password string } +type nugetDiscoveryAuth struct { + serviceIndexURL string + static nugetFeedCredentials + hasStatic bool + oidc *oidc.OIDCCredential + authenticateURL 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 +85,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, + authenticateURL: true, + }) } continue } @@ -140,58 +106,14 @@ 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, + hasStatic: true, + authenticateURL: true, + }) } } @@ -201,6 +123,9 @@ func NewNugetFeedHandler(creds config.Credentials) *NugetFeedHandler { func extraUrlsFromSourceResponse(body []byte, url string) []string { var urls []string bodyString := strings.TrimSpace(string(body)) + if bodyString == "" { + return nil + } bodyReader := bytes.NewReader(body) switch { case strings.HasPrefix(bodyString, "<"): @@ -210,7 +135,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,31 +192,234 @@ 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) { + markNugetDiscovery(proxyCtx, source) + 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) { - return req, nil + if req.URL.Scheme == "https" { + oidcCredential := h.oidcRegistry.CredentialForRequest(req) + if h.oidcRegistry.TryAuthCredential(req, proxyCtx, oidcCredential) { + return req, nil + } } // 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)) { + 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 } - - authenticateNugetRequest(req, cred, proxyCtx) - + 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.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return resp + } + + body, err := io.ReadAll(resp.Body) + resp.Body = &replayReadCloser{ + Reader: bytes.NewReader(body), + Closer: resp.Body, + } + if err != nil { + return resp + } + + for _, discoveredURL := range extraUrlsFromSourceResponse(body, discoveryAuth.serviceIndexURL) { + if discoveryAuth.oidc != nil { + h.oidcRegistry.RegisterURL(discoveredURL, discoveryAuth.oidc, "nuget resource") + continue + } + if !discoveryAuth.hasStatic { + continue + } + + credential := discoveryAuth.static + credential.url = discoveredURL + credential.host = "" + if h.addStaticCredential(credential) { + logging.RequestLogf(proxyCtx, " added url to authentication list: %s", discoveredURL) + } + } + + return resp +} + +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 + } + + redirectedSource := source + redirectedSource.serviceIndexURL = redirectURL.String() + redirectedSource.authenticateURL = source.authenticateURL && sameOrigin(baseURL, redirectURL) + if !h.addDiscoverySource(redirectedSource) { + return + } + + if redirectedSource.authenticateURL { + if source.oidc != nil { + h.oidcRegistry.RegisterURL(redirectURL.String(), source.oidc, "nuget service-index redirect") + } + if source.hasStatic { + 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 { + h.credentialsMutex.Lock() + defer h.credentialsMutex.Unlock() + + if credential.url != "" { + key := nugetCredentialURLKey(credential.url) + if _, ok := h.credentialURLs[key]; ok { + 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 := nugetCredentialURLKey(source.serviceIndexURL) + if _, ok := h.discoverySourceURLs[key]; ok { + return false + } + h.discoverySourceURLs[key] = struct{}{} + h.discoverySources = append(h.discoverySources, source) + 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 + } + 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 +} + func authenticateNugetRequest(req *http.Request, cred nugetFeedCredentials, proxyCtx *goproxy.ProxyCtx) { token := cred.token if token == "" && cred.password != "" { diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index f6416f3..589fc08 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,263 @@ 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{ + config.Credential{ + "type": "nuget_feed", + "url": "https://unreachable.example.com/index.json", + "token": "some-token", + }, + }) + + assert.Zero(t, httpmock.GetTotalCallCount()) +} + +func TestNugetFeedHandlerDiscoversFromPreparedResponse(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/index.json", + "token": "some-token", + }, + }) + proxyCtx := &goproxy.ProxyCtx{} + indexReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/index.json", nil) + handler.PrepareRequest(indexReq, proxyCtx) + + indexResp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, + )), + } + handler.HandleResponse(indexResp, proxyCtx) + + 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{ + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/index.json", + "token": "some-token", + }, + }) + discoverNugetFeed(t, handler, "https://nuget.example.com/index.json", http.StatusUnauthorized, + `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`) + + 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 TestNugetFeedHandlerOnlyDiscoversFromConfiguredServiceIndex(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/v3/", + "token": "some-token", + }, + }) + proxyCtx := &goproxy.ProxyCtx{} + packageReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/v3/some-package/index.json", nil) + packageReq = handleRequestAndClose(handler, packageReq, proxyCtx) + assertHasTokenAuth(t, packageReq, "Bearer", "some-token", "package under configured feed") + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `{"version":"3.0.0","resources":[{"@id":"https://untrusted.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, + )), + } + 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 TestNugetFeedHandlerConcurrentDiscoveryIsDeduplicated(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/index.json", + "token": "some-token", + }, + }) + const workers = 50 + responseBody := `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}` + 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 TestNugetFeedHandlerPrefersMostSpecificURLCredential(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/feed", + "token": "broad-token", + }, + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/feed/specific", + "token": "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{ + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/index.json", + "token": "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( + `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, + )), + } + 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{ + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/index.json", + "token": "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 TestExtraUrlsFromSourceResponseHandlesShortUnknownBody(t *testing.T) { + assert.NotPanics(t, func() { + assert.Empty(t, extraUrlsFromSourceResponse([]byte("x"), "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 mustMarshalJSON(t *testing.T, value any) string { + t.Helper() + body, err := json.Marshal(value) + require.NoError(t, err) + return string(body) +} diff --git a/internal/handlers/oidc_handling_test.go b/internal/handlers/oidc_handling_test.go index be186cf..00bfde6 100644 --- a/internal/handlers/oidc_handling_test.go +++ b/internal/handlers/oidc_handling_test.go @@ -21,6 +21,10 @@ type oidcHandler interface { HandleRequest(req *http.Request, proxyCtx *goproxy.ProxyCtx) (*http.Request, *http.Response) } +type oidcResponseHandler interface { + HandleResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.Response +} + type mockHttpRequest struct { verb string url string @@ -1659,6 +1663,26 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) handler := tc.handlerFactory(tc.credentials) + if len(tc.urlMocks) > 0 { + responseHandler, ok := handler.(oidcResponseHandler) + if !assert.True(t, ok, "handler with response fixtures should implement HandleResponse") { + return + } + for _, mockReq := range tc.urlMocks { + proxyCtx := &goproxy.ProxyCtx{} + req := httptest.NewRequestWithContext(t.Context(), mockReq.verb, mockReq.url, nil) + if preparer, ok := handler.(*NugetFeedHandler); ok { + preparer.PrepareRequest(req, proxyCtx) + } + handleRequestAndClose(handler, req, proxyCtx) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(mockReq.response)), + } + responseHandler.HandleResponse(resp, proxyCtx) + resp.Body.Close() + } + } 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) From 8dce70ae613615bfa7b78cd96743490b086d3da8 Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Sat, 15 Aug 2026 17:09:48 +0000 Subject: [PATCH 02/11] Address NuGet discovery review feedback --- internal/handlers/nuget_feed.go | 16 +++++-- internal/handlers/nuget_feed_test.go | 71 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/internal/handlers/nuget_feed.go b/internal/handlers/nuget_feed.go index 99ddcc7..400280b 100644 --- a/internal/handlers/nuget_feed.go +++ b/internal/handlers/nuget_feed.go @@ -124,6 +124,7 @@ 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) @@ -229,7 +230,8 @@ func (h *NugetFeedHandler) HandleRequest(req *http.Request, proxyCtx *goproxy.Pr } } - // Fall back to static credentials + // 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 @@ -275,14 +277,17 @@ func (h *NugetFeedHandler) HandleResponse(resp *http.Response, proxyCtx *goproxy h.registerServiceIndexRedirect(resp, discoveryAuth, proxyCtx) return resp } - if resp.Body == nil || resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + 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 } - body, err := io.ReadAll(resp.Body) + originalBody := resp.Body + body, err := io.ReadAll(originalBody) resp.Body = &replayReadCloser{ - Reader: bytes.NewReader(body), - Closer: resp.Body, + Reader: io.MultiReader(bytes.NewReader(body), originalBody), + Closer: originalBody, } if err != nil { return resp @@ -375,6 +380,7 @@ func (h *NugetFeedHandler) addDiscoverySource(source nugetDiscoveryAuth) bool { } 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 } diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index 589fc08..4e1b934 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -327,6 +327,8 @@ func TestNewNugetFeedHandlerDoesNotMakeHTTPRequests(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() + var buf bytes.Buffer + log.SetOutput(&buf) NewNugetFeedHandler(config.Credentials{ config.Credential{ "type": "nuget_feed", @@ -336,6 +338,7 @@ func TestNewNugetFeedHandlerDoesNotMakeHTTPRequests(t *testing.T) { }) assert.Zero(t, httpmock.GetTotalCallCount()) + assert.Contains(t, buf.String(), "registered NuGet service index for deferred discovery: https://unreachable.example.com/index.json") } func TestNugetFeedHandlerDiscoversFromPreparedResponse(t *testing.T) { @@ -379,6 +382,50 @@ func TestNugetFeedHandlerSkipsDiscoveryFromUnsuccessfulResponse(t *testing.T) { assertUnauthenticated(t, packageReq, "resource from unsuccessful response") } +func TestNugetFeedHandlerLeavesBodylessResponseUnchanged(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/index.json", + "token": "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{ + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/index.json", + "token": "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{ config.Credential{ @@ -546,6 +593,14 @@ func TestExtraUrlsFromSourceResponseHandlesShortUnknownBody(t *testing.T) { }) } +func TestExtraUrlsFromSourceResponseLogsBlankBody(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) + + assert.Empty(t, extraUrlsFromSourceResponse(nil, "https://nuget.example.com/index.json")) + assert.Contains(t, buf.String(), "empty API response from NuGet feed https://nuget.example.com/index.json") +} + func discoverNugetFeed(t *testing.T, handler *NugetFeedHandler, sourceURL string, statusCode int, responseBody string) { t.Helper() proxyCtx := &goproxy.ProxyCtx{} @@ -571,3 +626,19 @@ func mustMarshalJSON(t *testing.T, value any) string { 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 } From ad0855acb4abfab2730214c05196aa90a39f3858 Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Mon, 17 Aug 2026 20:27:13 +0000 Subject: [PATCH 03/11] Clean up NuGet discovery tests --- internal/handlers/nuget_feed_test.go | 1 + internal/handlers/oidc_handling_test.go | 94 +++---------------------- 2 files changed, 12 insertions(+), 83 deletions(-) diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index 4e1b934..1b7aaa4 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -436,6 +436,7 @@ func TestNugetFeedHandlerOnlyDiscoversFromConfiguredServiceIndex(t *testing.T) { }) proxyCtx := &goproxy.ProxyCtx{} packageReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/v3/some-package/index.json", nil) + handler.PrepareRequest(packageReq, proxyCtx) packageReq = handleRequestAndClose(handler, packageReq, proxyCtx) assertHasTokenAuth(t, packageReq, "Bearer", "some-token", "package under configured feed") diff --git a/internal/handlers/oidc_handling_test.go b/internal/handlers/oidc_handling_test.go index 00bfde6..69e05d8 100644 --- a/internal/handlers/oidc_handling_test.go +++ b/internal/handlers/oidc_handling_test.go @@ -25,8 +25,7 @@ type oidcResponseHandler interface { HandleResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.Response } -type mockHttpRequest struct { - verb string +type oidcResponseFixture struct { url string response string } @@ -40,7 +39,7 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { provider string handlerFactory func(creds config.Credentials) oidcHandler credentials config.Credentials - urlMocks []mockHttpRequest + responseFixtures []oidcResponseFixture expectedLogLines []string urlsToAuthenticate []string }{ @@ -64,7 +63,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", }, @@ -86,7 +84,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", }, @@ -107,7 +104,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", }, @@ -130,7 +126,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for cargo registry: https://cloudsmith.example.com", }, @@ -151,7 +146,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", }, @@ -179,7 +173,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for composer repository: https://composer.example.com", }, @@ -201,7 +194,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for composer repository: https://composer.example.com", }, @@ -223,7 +215,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", }, @@ -246,7 +237,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for composer repository: https://cloudsmith.example.com", }, @@ -267,7 +257,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", }, @@ -296,7 +285,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for docker registry: https://docker.example.com", }, @@ -318,7 +306,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for docker registry: https://docker.example.com", }, @@ -339,7 +326,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", }, @@ -362,7 +348,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for docker registry: https://cloudsmith.example.com", }, @@ -383,7 +368,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", }, @@ -411,7 +395,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for goproxy server: https://goproxy.example.com", }, @@ -433,7 +416,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for goproxy server: goproxy.example.com", }, @@ -454,7 +436,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", }, @@ -477,7 +458,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for goproxy server: https://cloudsmith.example.com", }, @@ -498,7 +478,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", }, @@ -526,7 +505,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for helm registry: https://helm.example.com", }, @@ -548,7 +526,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for helm registry: https://helm.example.com", }, @@ -569,7 +546,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", }, @@ -592,7 +568,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for helm registry: https://cloudsmith.example.com", }, @@ -613,7 +588,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", }, @@ -641,7 +615,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for hex repository: https://hex.example.com", }, @@ -663,7 +636,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for hex repository: https://hex.example.com", }, @@ -684,7 +656,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", }, @@ -707,7 +678,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for hex repository: https://cloudsmith.example.com", }, @@ -728,7 +698,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", }, @@ -756,7 +725,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", }, @@ -778,7 +746,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", }, @@ -799,7 +766,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", }, @@ -822,7 +788,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for maven repository: https://cloudsmith.example.com", }, @@ -843,7 +808,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", }, @@ -871,7 +835,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for npm registry: https://npm.example.com", }, @@ -893,7 +856,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for npm registry: https://npm.example.com", }, @@ -914,7 +876,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", }, @@ -937,7 +898,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for npm registry: https://cloudsmith.example.com", }, @@ -958,7 +918,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", }, @@ -986,9 +945,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{ + responseFixtures: []oidcResponseFixture{ { - 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"}]}`, }, @@ -1016,9 +974,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{ + responseFixtures: []oidcResponseFixture{ { - 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"}]}`, }, @@ -1045,9 +1002,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - urlMocks: []mockHttpRequest{ + responseFixtures: []oidcResponseFixture{ { - 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"}]}`, }, @@ -1076,9 +1032,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{ + responseFixtures: []oidcResponseFixture{ { - 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"}]}`, }, @@ -1105,9 +1060,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - urlMocks: []mockHttpRequest{ + responseFixtures: []oidcResponseFixture{ { - 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"}]}`, }, @@ -1141,7 +1095,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for pub repository: https://pub.example.com", }, @@ -1163,7 +1116,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for pub repository: https://pub.example.com", }, @@ -1184,7 +1136,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", }, @@ -1207,7 +1158,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for pub repository: https://cloudsmith.example.com", }, @@ -1228,7 +1178,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", }, @@ -1256,7 +1205,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for python index: https://python.example.com", }, @@ -1278,7 +1226,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for python index: https://python.example.com", }, @@ -1299,7 +1246,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", }, @@ -1322,7 +1268,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for python index: https://cloudsmith.example.com", }, @@ -1343,7 +1288,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/", }, @@ -1371,7 +1315,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for rubygems server: https://rubygems.example.com", }, @@ -1393,7 +1336,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for rubygems server: https://rubygems.example.com", }, @@ -1415,7 +1357,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", }, @@ -1439,7 +1380,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for rubygems server: https://cloudsmith.example.com", }, @@ -1461,7 +1401,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", }, @@ -1489,7 +1428,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered aws OIDC credentials for terraform registry: https://terraform.example.com", }, @@ -1511,7 +1449,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered azure OIDC credentials for terraform registry: https://terraform.example.com", }, @@ -1532,7 +1469,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", }, @@ -1555,7 +1491,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - urlMocks: []mockHttpRequest{}, expectedLogLines: []string{ "registered cloudsmith OIDC credentials for terraform registry: https://cloudsmith.example.com", }, @@ -1576,7 +1511,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", }, @@ -1590,12 +1524,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, @@ -1663,21 +1591,21 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) handler := tc.handlerFactory(tc.credentials) - if len(tc.urlMocks) > 0 { + if len(tc.responseFixtures) > 0 { responseHandler, ok := handler.(oidcResponseHandler) if !assert.True(t, ok, "handler with response fixtures should implement HandleResponse") { return } - for _, mockReq := range tc.urlMocks { + for _, fixture := range tc.responseFixtures { proxyCtx := &goproxy.ProxyCtx{} - req := httptest.NewRequestWithContext(t.Context(), mockReq.verb, mockReq.url, nil) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fixture.url, nil) if preparer, ok := handler.(*NugetFeedHandler); ok { preparer.PrepareRequest(req, proxyCtx) } handleRequestAndClose(handler, req, proxyCtx) resp := &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(mockReq.response)), + Body: io.NopCloser(strings.NewReader(fixture.response)), } responseHandler.HandleResponse(resp, proxyCtx) resp.Body.Close() From 60aa635bb281e2b22c65a0bf85eaf8db6081df7e Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Mon, 17 Aug 2026 21:24:31 +0000 Subject: [PATCH 04/11] Resolve NuGet redirects from requested URL --- internal/handlers/nuget_feed.go | 4 +- internal/handlers/nuget_feed_test.go | 56 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/internal/handlers/nuget_feed.go b/internal/handlers/nuget_feed.go index 400280b..7e1e2f2 100644 --- a/internal/handlers/nuget_feed.go +++ b/internal/handlers/nuget_feed.go @@ -208,7 +208,9 @@ func (h *NugetFeedHandler) PrepareRequest(req *http.Request, proxyCtx *goproxy.P continue } if isNugetServiceIndexRequest(req, source.serviceIndexURL) { - markNugetDiscovery(proxyCtx, source) + matchedSource := source + matchedSource.serviceIndexURL = req.URL.String() + markNugetDiscovery(proxyCtx, matchedSource) return req, nil } } diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index 1b7aaa4..5fbbf09 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -588,6 +588,62 @@ func TestNugetFeedHandlerAuthenticatesSameOriginServiceIndexRedirect(t *testing. 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{ + config.Credential{ + "type": "nuget_feed", + "url": testCase.configuredURL, + "token": "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( + `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, + )), + }, 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")) From 0fc9c3c258d5383f27eed39cbbe664436d4e2387 Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Mon, 17 Aug 2026 21:42:22 +0000 Subject: [PATCH 05/11] Match NuGet discovery request schemes --- internal/handlers/nuget_feed.go | 3 ++ internal/handlers/nuget_feed_test.go | 63 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/internal/handlers/nuget_feed.go b/internal/handlers/nuget_feed.go index 7e1e2f2..e83a4e8 100644 --- a/internal/handlers/nuget_feed.go +++ b/internal/handlers/nuget_feed.go @@ -412,6 +412,9 @@ func isNugetServiceIndexRequest(req *http.Request, sourceURL string) bool { 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 } diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index 5fbbf09..5c4b2eb 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -453,6 +453,69 @@ func TestNugetFeedHandlerOnlyDiscoversFromConfiguredServiceIndex(t *testing.T) { 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{ + config.Credential{ + "type": "nuget_feed", + "url": testCase.configuredURL, + "token": "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( + `{"version":"3.0.0","resources":[{"@id":"https://attacker.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, + )), + }, 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 TestNugetFeedHandlerConcurrentDiscoveryIsDeduplicated(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ config.Credential{ From c5956a3174f62029a7b284d172ffa69569b816cd Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Mon, 17 Aug 2026 22:08:42 +0000 Subject: [PATCH 06/11] Ignore unusable NuGet credentials --- internal/handlers/nuget_feed.go | 4 +++ internal/handlers/nuget_feed_test.go | 42 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/handlers/nuget_feed.go b/internal/handlers/nuget_feed.go index e83a4e8..9829254 100644 --- a/internal/handlers/nuget_feed.go +++ b/internal/handlers/nuget_feed.go @@ -358,6 +358,10 @@ func (h *NugetFeedHandler) registerServiceIndexRedirect(resp *http.Response, sou } func (h *NugetFeedHandler) addStaticCredential(credential nugetFeedCredentials) bool { + if credential.token == "" && credential.password == "" { + return false + } + h.credentialsMutex.Lock() defer h.credentialsMutex.Unlock() diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index 5c4b2eb..d86890d 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -556,6 +556,48 @@ func TestNugetFeedHandlerConcurrentDiscoveryIsDeduplicated(t *testing.T) { assert.Len(t, handler.credentials, 2) } +func TestNugetFeedHandlerIgnoresUnusableStaticCredentials(t *testing.T) { + usableCredential := config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/v3/index.json", + "token": "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 TestNugetFeedHandlerUnusableCredentialDoesNotBlockDiscoveredCredential(t *testing.T) { + handler := NewNugetFeedHandler(config.Credentials{ + config.Credential{ + "type": "nuget_feed", + "url": "https://cdn.example.com/packages", + }, + config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/v3/index.json", + "token": "some-token", + }, + }) + discoverNugetFeed(t, handler, "https://nuget.example.com/v3/index.json", http.StatusOK, + `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`) + + 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{ config.Credential{ From 4154ef160a1fbc2f1099d3c270704ff3019c6fe0 Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Mon, 17 Aug 2026 22:56:57 +0000 Subject: [PATCH 07/11] Distinguish NuGet discovery source schemes --- internal/handlers/nuget_feed.go | 16 +++++++++++++- internal/handlers/nuget_feed_test.go | 32 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/handlers/nuget_feed.go b/internal/handlers/nuget_feed.go index 9829254..be33242 100644 --- a/internal/handlers/nuget_feed.go +++ b/internal/handlers/nuget_feed.go @@ -380,7 +380,7 @@ func (h *NugetFeedHandler) addDiscoverySource(source nugetDiscoveryAuth) bool { h.discoveryMutex.Lock() defer h.discoveryMutex.Unlock() - key := nugetCredentialURLKey(source.serviceIndexURL) + key := nugetDiscoverySourceKey(source.serviceIndexURL) if _, ok := h.discoverySourceURLs[key]; ok { return false } @@ -435,6 +435,20 @@ func nugetCredentialURLKey(rawURL string) string { 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) { token := cred.token if token == "" && cred.password != "" { diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index d86890d..3a0f32c 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -516,6 +516,38 @@ func TestNugetFeedHandlerRequiresConfiguredServiceIndexSchemeForDiscovery(t *tes } } +func TestNugetFeedHandlerKeepsHTTPAndHTTPSDiscoverySourcesDistinct(t *testing.T) { + httpCredential := config.Credential{ + "type": "nuget_feed", + "url": "http://nuget.example.com/v3/index.json", + "token": "http-token", + } + httpsCredential := config.Credential{ + "type": "nuget_feed", + "url": "https://nuget.example.com/v3/index.json", + "token": "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, + `{"version":"3.0.0","resources":[{"@id":"https://http-resource.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`) + discoverNugetFeed(t, handler, "https://nuget.example.com/v3/index.json", http.StatusOK, + `{"version":"3.0.0","resources":[{"@id":"https://https-resource.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`) + + 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{ config.Credential{ From 89ab9eb6e523c2a147ff4eedb5bb6ef97358fbd7 Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Mon, 17 Aug 2026 23:24:51 +0000 Subject: [PATCH 08/11] Slim NuGet discovery tests --- internal/handlers/nuget_feed_test.go | 155 ++++++------------------ internal/handlers/oidc_handling_test.go | 73 +++-------- 2 files changed, 56 insertions(+), 172 deletions(-) diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index 3a0f32c..d043af7 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -330,11 +330,7 @@ func TestNewNugetFeedHandlerDoesNotMakeHTTPRequests(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://unreachable.example.com/index.json", - "token": "some-token", - }, + testNugetFeedCredential("https://unreachable.example.com/index.json", "some-token"), }) assert.Zero(t, httpmock.GetTotalCallCount()) @@ -343,23 +339,10 @@ func TestNewNugetFeedHandlerDoesNotMakeHTTPRequests(t *testing.T) { func TestNugetFeedHandlerDiscoversFromPreparedResponse(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/index.json", - "token": "some-token", - }, + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), }) - proxyCtx := &goproxy.ProxyCtx{} - indexReq := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://nuget.example.com/index.json", nil) - handler.PrepareRequest(indexReq, proxyCtx) - - indexResp := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader( - `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - )), - } - handler.HandleResponse(indexResp, proxyCtx) + 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{}) @@ -368,14 +351,10 @@ func TestNugetFeedHandlerDiscoversFromPreparedResponse(t *testing.T) { func TestNugetFeedHandlerSkipsDiscoveryFromUnsuccessfulResponse(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/index.json", - "token": "some-token", - }, + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), }) discoverNugetFeed(t, handler, "https://nuget.example.com/index.json", http.StatusUnauthorized, - `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`) + 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{}) @@ -384,11 +363,7 @@ func TestNugetFeedHandlerSkipsDiscoveryFromUnsuccessfulResponse(t *testing.T) { func TestNugetFeedHandlerLeavesBodylessResponseUnchanged(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/index.json", - "token": "some-token", - }, + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), }) for _, statusCode := range []int{http.StatusNoContent, http.StatusResetContent} { @@ -405,11 +380,7 @@ func TestNugetFeedHandlerLeavesBodylessResponseUnchanged(t *testing.T) { func TestNugetFeedHandlerReplaysBodyAfterReadError(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/index.json", - "token": "some-token", - }, + 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) @@ -428,23 +399,15 @@ func TestNugetFeedHandlerReplaysBodyAfterReadError(t *testing.T) { func TestNugetFeedHandlerOnlyDiscoversFromConfiguredServiceIndex(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/v3/", - "token": "some-token", - }, + 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) - packageReq = handleRequestAndClose(handler, packageReq, proxyCtx) - assertHasTokenAuth(t, packageReq, "Bearer", "some-token", "package under configured feed") resp := &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader( - `{"version":"3.0.0","resources":[{"@id":"https://untrusted.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - )), + Body: io.NopCloser(strings.NewReader(nugetV3Response("https://untrusted.example.com/packages"))), } handler.HandleResponse(resp, proxyCtx) @@ -489,20 +452,14 @@ func TestNugetFeedHandlerRequiresConfiguredServiceIndexSchemeForDiscovery(t *tes for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": testCase.configuredURL, - "token": "some-token", - }, + 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( - `{"version":"3.0.0","resources":[{"@id":"https://attacker.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - )), + 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) @@ -517,16 +474,8 @@ func TestNugetFeedHandlerRequiresConfiguredServiceIndexSchemeForDiscovery(t *tes } func TestNugetFeedHandlerKeepsHTTPAndHTTPSDiscoverySourcesDistinct(t *testing.T) { - httpCredential := config.Credential{ - "type": "nuget_feed", - "url": "http://nuget.example.com/v3/index.json", - "token": "http-token", - } - httpsCredential := config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/v3/index.json", - "token": "https-token", - } + 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}, @@ -534,9 +483,9 @@ func TestNugetFeedHandlerKeepsHTTPAndHTTPSDiscoverySourcesDistinct(t *testing.T) } { handler := NewNugetFeedHandler(credentials) discoverNugetFeed(t, handler, "http://nuget.example.com/v3/index.json", http.StatusOK, - `{"version":"3.0.0","resources":[{"@id":"https://http-resource.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`) + nugetV3Response("https://http-resource.example.com/packages")) discoverNugetFeed(t, handler, "https://nuget.example.com/v3/index.json", http.StatusOK, - `{"version":"3.0.0","resources":[{"@id":"https://https-resource.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`) + 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{}) @@ -550,14 +499,10 @@ func TestNugetFeedHandlerKeepsHTTPAndHTTPSDiscoverySourcesDistinct(t *testing.T) func TestNugetFeedHandlerConcurrentDiscoveryIsDeduplicated(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/index.json", - "token": "some-token", - }, + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), }) const workers = 50 - responseBody := `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}` + responseBody := nugetV3Response("https://cdn.example.com/packages") start := make(chan struct{}) var waitGroup sync.WaitGroup for range workers { @@ -589,11 +534,7 @@ func TestNugetFeedHandlerConcurrentDiscoveryIsDeduplicated(t *testing.T) { } func TestNugetFeedHandlerIgnoresUnusableStaticCredentials(t *testing.T) { - usableCredential := config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/v3/index.json", - "token": "some-token", - } + 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", @@ -616,14 +557,10 @@ func TestNugetFeedHandlerUnusableCredentialDoesNotBlockDiscoveredCredential(t *t "type": "nuget_feed", "url": "https://cdn.example.com/packages", }, - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/v3/index.json", - "token": "some-token", - }, + testNugetFeedCredential("https://nuget.example.com/v3/index.json", "some-token"), }) discoverNugetFeed(t, handler, "https://nuget.example.com/v3/index.json", http.StatusOK, - `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`) + 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{}) @@ -632,16 +569,8 @@ func TestNugetFeedHandlerUnusableCredentialDoesNotBlockDiscoveredCredential(t *t func TestNugetFeedHandlerPrefersMostSpecificURLCredential(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/feed", - "token": "broad-token", - }, - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/feed/specific", - "token": "specific-token", - }, + 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", @@ -657,11 +586,7 @@ func TestNugetFeedHandlerPrefersMostSpecificURLCredential(t *testing.T) { func TestNugetFeedHandlerDiscoversThroughCrossOriginRedirectWithoutLeakingCredentials(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/index.json", - "token": "some-token", - }, + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), }) initialCtx := &goproxy.ProxyCtx{} @@ -691,9 +616,7 @@ func TestNugetFeedHandlerDiscoversThroughCrossOriginRedirectWithoutLeakingCreden assertUnauthenticated(t, finalReq, "redirect after cross-origin service-index redirect") finalResp := &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader( - `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - )), + Body: io.NopCloser(strings.NewReader(nugetV3Response("https://cdn.example.com/packages"))), } handler.HandleResponse(finalResp, finalCtx) @@ -704,11 +627,7 @@ func TestNugetFeedHandlerDiscoversThroughCrossOriginRedirectWithoutLeakingCreden func TestNugetFeedHandlerAuthenticatesSameOriginServiceIndexRedirect(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": "https://nuget.example.com/index.json", - "token": "some-token", - }, + testNugetFeedCredential("https://nuget.example.com/index.json", "some-token"), }) initialCtx := &goproxy.ProxyCtx{} @@ -749,11 +668,7 @@ func TestNugetFeedHandlerResolvesRelativeRedirectAgainstRequestedServiceIndexURL for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { handler := NewNugetFeedHandler(config.Credentials{ - config.Credential{ - "type": "nuget_feed", - "url": testCase.configuredURL, - "token": "some-token", - }, + testNugetFeedCredential(testCase.configuredURL, "some-token"), }) initialCtx := &goproxy.ProxyCtx{} @@ -769,9 +684,7 @@ func TestNugetFeedHandlerResolvesRelativeRedirectAgainstRequestedServiceIndexURL handler.PrepareRequest(redirectReq, redirectCtx) handler.HandleResponse(&http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader( - `{"version":"3.0.0","resources":[{"@id":"https://cdn.example.com/packages","@type":"PackageBaseAddress/3.0.0"}]}`, - )), + 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) @@ -814,6 +727,18 @@ func discoverNugetFeed(t *testing.T, handler *NugetFeedHandler, sourceURL string 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) diff --git a/internal/handlers/oidc_handling_test.go b/internal/handlers/oidc_handling_test.go index 69e05d8..25a7171 100644 --- a/internal/handlers/oidc_handling_test.go +++ b/internal/handlers/oidc_handling_test.go @@ -21,15 +21,6 @@ type oidcHandler interface { HandleRequest(req *http.Request, proxyCtx *goproxy.ProxyCtx) (*http.Request, *http.Response) } -type oidcResponseHandler interface { - HandleResponse(resp *http.Response, proxyCtx *goproxy.ProxyCtx) *http.Response -} - -type oidcResponseFixture struct { - url string - response string -} - func TestOIDCURLsAreAuthenticated(t *testing.T) { testTenantId := "12345678-1234-1234-1234-123456789012" testClientId := "87654321-4321-4321-4321-210987654321" @@ -39,7 +30,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { provider string handlerFactory func(creds config.Credentials) oidcHandler credentials config.Credentials - responseFixtures []oidcResponseFixture + serviceIndexURL string + resourceURL string expectedLogLines []string urlsToAuthenticate []string }{ @@ -945,12 +937,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "domain-owner": "9876543210", }, }, - responseFixtures: []oidcResponseFixture{ - { - 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", @@ -974,12 +962,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "client-id": testClientId, }, }, - responseFixtures: []oidcResponseFixture{ - { - 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", @@ -1002,12 +986,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "jfrog-oidc-provider-name": "proxy-test", }, }, - responseFixtures: []oidcResponseFixture{ - { - 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", @@ -1032,12 +1012,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "audience": "my-audience", }, }, - responseFixtures: []oidcResponseFixture{ - { - 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", @@ -1060,12 +1036,8 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { "workload-identity-provider": "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", }, }, - responseFixtures: []oidcResponseFixture{ - { - 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", @@ -1591,25 +1563,12 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) handler := tc.handlerFactory(tc.credentials) - if len(tc.responseFixtures) > 0 { - responseHandler, ok := handler.(oidcResponseHandler) - if !assert.True(t, ok, "handler with response fixtures should implement HandleResponse") { + if tc.serviceIndexURL != "" { + nugetHandler, ok := handler.(*NugetFeedHandler) + if !assert.True(t, ok, "handler with a service index should be a NuGet handler") { return } - for _, fixture := range tc.responseFixtures { - proxyCtx := &goproxy.ProxyCtx{} - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fixture.url, nil) - if preparer, ok := handler.(*NugetFeedHandler); ok { - preparer.PrepareRequest(req, proxyCtx) - } - handleRequestAndClose(handler, req, proxyCtx) - resp := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(fixture.response)), - } - responseHandler.HandleResponse(resp, proxyCtx) - resp.Body.Close() - } + discoverNugetFeed(t, nugetHandler, tc.serviceIndexURL, http.StatusOK, nugetV3Response(tc.resourceURL)) } logContents := buf.String() From 4976ed45dd3390d8bcdaa1de07e6425ecddd48dd Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Mon, 17 Aug 2026 23:26:14 +0000 Subject: [PATCH 09/11] Remove redundant NuGet log assertions --- internal/handlers/nuget_feed_test.go | 9 +-------- internal/handlers/oidc_handling_test.go | 5 ----- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index d043af7..1608cc4 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -327,14 +327,11 @@ func TestNewNugetFeedHandlerDoesNotMakeHTTPRequests(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() - var buf bytes.Buffer - log.SetOutput(&buf) NewNugetFeedHandler(config.Credentials{ testNugetFeedCredential("https://unreachable.example.com/index.json", "some-token"), }) assert.Zero(t, httpmock.GetTotalCallCount()) - assert.Contains(t, buf.String(), "registered NuGet service index for deferred discovery: https://unreachable.example.com/index.json") } func TestNugetFeedHandlerDiscoversFromPreparedResponse(t *testing.T) { @@ -700,12 +697,8 @@ func TestExtraUrlsFromSourceResponseHandlesShortUnknownBody(t *testing.T) { }) } -func TestExtraUrlsFromSourceResponseLogsBlankBody(t *testing.T) { - var buf bytes.Buffer - log.SetOutput(&buf) - +func TestExtraUrlsFromSourceResponseHandlesBlankBody(t *testing.T) { assert.Empty(t, extraUrlsFromSourceResponse(nil, "https://nuget.example.com/index.json")) - assert.Contains(t, buf.String(), "empty API response from NuGet feed https://nuget.example.com/index.json") } func discoverNugetFeed(t *testing.T, handler *NugetFeedHandler, sourceURL string, statusCode int, responseBody string) { diff --git a/internal/handlers/oidc_handling_test.go b/internal/handlers/oidc_handling_test.go index 25a7171..705ca18 100644 --- a/internal/handlers/oidc_handling_test.go +++ b/internal/handlers/oidc_handling_test.go @@ -941,7 +941,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { 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 @@ -966,7 +965,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { 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 @@ -990,7 +988,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { 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 @@ -1016,7 +1013,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { 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 @@ -1040,7 +1036,6 @@ func TestOIDCURLsAreAuthenticated(t *testing.T) { 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 From bdf0625eb58db1e4c3bd23e0dd46e1d364149a19 Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Tue, 18 Aug 2026 00:14:53 +0000 Subject: [PATCH 10/11] Log ignored duplicate NuGet routes --- internal/handlers/nuget_feed.go | 1 + internal/handlers/nuget_feed_test.go | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/internal/handlers/nuget_feed.go b/internal/handlers/nuget_feed.go index be33242..8507315 100644 --- a/internal/handlers/nuget_feed.go +++ b/internal/handlers/nuget_feed.go @@ -368,6 +368,7 @@ func (h *NugetFeedHandler) addStaticCredential(credential nugetFeedCredentials) 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{}{} diff --git a/internal/handlers/nuget_feed_test.go b/internal/handlers/nuget_feed_test.go index 1608cc4..ed6bbe2 100644 --- a/internal/handlers/nuget_feed_test.go +++ b/internal/handlers/nuget_feed_test.go @@ -548,6 +548,24 @@ func TestNugetFeedHandlerIgnoresUnusableStaticCredentials(t *testing.T) { } } +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{ From c6b20590b7f64ec103c5fb7ff924ea28864e6170 Mon Sep 17 00:00:00 2001 From: Jeff Widman Date: Tue, 18 Aug 2026 02:49:07 +0000 Subject: [PATCH 11/11] Simplify NuGet redirect auth state --- internal/handlers/nuget_feed.go | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/internal/handlers/nuget_feed.go b/internal/handlers/nuget_feed.go index 8507315..40548b4 100644 --- a/internal/handlers/nuget_feed.go +++ b/internal/handlers/nuget_feed.go @@ -55,11 +55,10 @@ type nugetFeedCredentials struct { } type nugetDiscoveryAuth struct { - serviceIndexURL string - static nugetFeedCredentials - hasStatic bool - oidc *oidc.OIDCCredential - authenticateURL bool + serviceIndexURL string + static nugetFeedCredentials + oidc *oidc.OIDCCredential + registerRedirectAuth bool } // NewNugetFeedHandler returns a new NugetFeedHandler. @@ -87,9 +86,9 @@ func NewNugetFeedHandler(creds config.Credentials) *NugetFeedHandler { if ok { if url != "" { handler.addDiscoverySource(nugetDiscoveryAuth{ - serviceIndexURL: url, - oidc: oidcCredential, - authenticateURL: true, + serviceIndexURL: url, + oidc: oidcCredential, + registerRedirectAuth: true, }) } continue @@ -109,10 +108,9 @@ func NewNugetFeedHandler(creds config.Credentials) *NugetFeedHandler { handler.addStaticCredential(feedCred) if url != "" && (token != "" || password != "") { handler.addDiscoverySource(nugetDiscoveryAuth{ - serviceIndexURL: url, - static: feedCred, - hasStatic: true, - authenticateURL: true, + serviceIndexURL: url, + static: feedCred, + registerRedirectAuth: true, }) } } @@ -300,9 +298,6 @@ func (h *NugetFeedHandler) HandleResponse(resp *http.Response, proxyCtx *goproxy h.oidcRegistry.RegisterURL(discoveredURL, discoveryAuth.oidc, "nuget resource") continue } - if !discoveryAuth.hasStatic { - continue - } credential := discoveryAuth.static credential.url = discoveredURL @@ -338,16 +333,15 @@ func (h *NugetFeedHandler) registerServiceIndexRedirect(resp *http.Response, sou redirectedSource := source redirectedSource.serviceIndexURL = redirectURL.String() - redirectedSource.authenticateURL = source.authenticateURL && sameOrigin(baseURL, redirectURL) + redirectedSource.registerRedirectAuth = source.registerRedirectAuth && sameOrigin(baseURL, redirectURL) if !h.addDiscoverySource(redirectedSource) { return } - if redirectedSource.authenticateURL { + if redirectedSource.registerRedirectAuth { if source.oidc != nil { h.oidcRegistry.RegisterURL(redirectURL.String(), source.oidc, "nuget service-index redirect") - } - if source.hasStatic { + } else { credential := source.static credential.url = redirectURL.String() credential.host = ""