From b4435f0c47126fc230985f63ca822a354ea40237 Mon Sep 17 00:00:00 2001 From: Emil Ljungdahl Date: Thu, 7 May 2026 23:08:28 +0200 Subject: [PATCH 1/2] Strip auth-required from Cargo registry responses The efforts made in dependabot-core to "trick" Cargo into unauthorized requests (like https://github.com/dependabot/dependabot-core/pull/14359) is needed, but is not enough. If an authorized registry serves a config.toml containing "auth-required": true, Cargo will still try to authenticate upcoming requests in its own. This patch makes sure the "auth-required": true property in a config.toml response is filtered out. --- internal/handlers/cargo_registry.go | 67 ++++++++++++ internal/handlers/cargo_registry_test.go | 124 +++++++++++++++++++++++ proxy.go | 1 + 3 files changed, 192 insertions(+) diff --git a/internal/handlers/cargo_registry.go b/internal/handlers/cargo_registry.go index b875d84..f4a646e 100644 --- a/internal/handlers/cargo_registry.go +++ b/internal/handlers/cargo_registry.go @@ -1,7 +1,11 @@ package handlers import ( + "bytes" + "encoding/json" + "io" "net/http" + "strings" "github.com/elazarl/goproxy" "github.com/sirupsen/logrus" @@ -33,6 +37,16 @@ import ( // In that case, the supplied token value should be `Bearer `. This would match how cargo stores the // credentials locally in this example: // https://jfrog.com/help/r/artifactory-how-to-integrate-artifactory-with-cargo-using-sparse-indexing/client-configuration +// +// Response Rewriting: +// When a registry responds with a config.json file containing the "auth-required" property, +// this handler removes that property before returning the response to the client (cargo command +// in the Dependabot updater container). This is necessary because the proxy is responsible for +// injecting authentication credentials into the request via the Authorization header. When cargo +// sees "auth-required": true, it expects to need authentication but no credentials are available +// in its configuration, causing it to error with "authenticated registries require a +// credential-provider to be configured". By removing this property, cargo treats the response as +// coming from an unauthenticated registry. type CargoRegistryHandler struct { credentials []cargoRepositoryCredentials oidcRegistry *oidc.OIDCRegistry @@ -108,3 +122,56 @@ func (h *CargoRegistryHandler) HandleRequest(req *http.Request, ctx *goproxy.Pro return req, nil } + +// HandleResponse rewrites the response if it's a config.json file with auth-required property +func (h *CargoRegistryHandler) HandleResponse(rsp *http.Response, ctx *goproxy.ProxyCtx) *http.Response { + if rsp == nil || ctx == nil || ctx.Req == nil { + return rsp + } + + // Check if the request path ends with config.json + requestPath := ctx.Req.URL.Path + if !strings.HasSuffix(requestPath, "config.json") { + return rsp + } + + // Read the response body + body, err := io.ReadAll(rsp.Body) + if err != nil { + logging.RequestLogf(ctx, "* error reading cargo registry response body: %v", err) + return rsp + } + + // Try to parse as JSON + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + logging.RequestLogf(ctx, "* cargo registry config.json response is not valid JSON, leaving unchanged") + rsp.Body = io.NopCloser(bytes.NewReader(body)) + return rsp + } + + // Check if auth-required property exists + if _, hasAuthRequired := payload["auth-required"]; !hasAuthRequired { + rsp.Body = io.NopCloser(bytes.NewReader(body)) + return rsp + } + + // Remove auth-required property + delete(payload, "auth-required") + logging.RequestLogf(ctx, "* removing auth-required property from cargo registry config.json") + + // Serialize the modified JSON + modifiedBody, err := json.Marshal(payload) + if err != nil { + logging.RequestLogf(ctx, "* error serializing modified cargo registry response: %v", err) + rsp.Body = io.NopCloser(bytes.NewReader(body)) + return rsp + } + + // Create a new response with the modified body + rsp.Body = io.NopCloser(bytes.NewReader(modifiedBody)) + rsp.ContentLength = int64(len(modifiedBody)) + rsp.Header.Set("Content-Length", string(rune(len(modifiedBody)))) + + return rsp +} diff --git a/internal/handlers/cargo_registry_test.go b/internal/handlers/cargo_registry_test.go index e4a0d75..1f10b82 100644 --- a/internal/handlers/cargo_registry_test.go +++ b/internal/handlers/cargo_registry_test.go @@ -1,11 +1,16 @@ package handlers import ( + "bytes" + "encoding/json" + "io" + "net/http" "net/http/httptest" "strings" "testing" "github.com/dependabot/proxy/internal/config" + "github.com/elazarl/goproxy" ) func TestCargoRegistryHandler(t *testing.T) { @@ -96,3 +101,122 @@ func TestCargoRegistryHandler(t *testing.T) { req = handleRequestAndClose(handler, req, nil) assertUnauthenticated(t, req, "non-GET request") } + +func TestCargoRegistryHandlerConfigJsonResponse(t *testing.T) { + validURL := "https://valid-url.example.com" + token := "Bearer abc123" //nolint:gosec // test credential + + credentials := config.Credentials{ + config.Credential{ + "type": "cargo_registry", + "url": validURL, + "token": token, + }, + } + + handler := NewCargoRegistryHandler(credentials) + + tests := []struct { + name string + requestPath string + responseBody interface{} + expectRewrite bool + checkModified func(t *testing.T, body []byte) + }{ + { + name: "config.json with auth-required should be removed", + requestPath: "/index/te/st/config.json", + responseBody: map[string]interface{}{"auth-required": true, "dl": "https://example.com/download"}, + expectRewrite: true, + checkModified: func(t *testing.T, body []byte) { + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if _, exists := result["auth-required"]; exists { + t.Error("auth-required property should have been removed") + } + if dl, ok := result["dl"].(string); !ok || dl != "https://example.com/download" { + t.Error("dl property should be preserved") + } + }, + }, + { + name: "config.json without auth-required should not be modified", + requestPath: "/index/te/st/config.json", + responseBody: map[string]interface{}{"dl": "https://example.com/download"}, + expectRewrite: false, + checkModified: func(t *testing.T, body []byte) {}, + }, + { + name: "non-config.json paths should not be rewritten", + requestPath: "/index/te/st/package.json", + responseBody: map[string]interface{}{"auth-required": true}, + expectRewrite: false, + checkModified: func(t *testing.T, body []byte) {}, + }, + { + name: "invalid JSON should not be rewritten", + requestPath: "/index/te/st/config.json", + responseBody: "not valid json", + expectRewrite: false, + checkModified: func(t *testing.T, body []byte) { + if string(body) != "not valid json" { + t.Error("invalid JSON response should be unchanged") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create response body + var respBody []byte + if str, ok := tt.responseBody.(string); ok { + respBody = []byte(str) + } else { + var err error + respBody, err = json.Marshal(tt.responseBody) + if err != nil { + t.Fatalf("failed to marshal response body: %v", err) + } + } + + // Create request and response + req := httptest.NewRequest("GET", validURL+tt.requestPath, nil) + rsp := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(respBody)), + Header: make(http.Header), + } + + // Create proxy context + proxyCtx := &goproxy.ProxyCtx{ + Req: req, + } + + // Call HandleResponse + modifiedRsp := handler.HandleResponse(rsp, proxyCtx) + + // Read modified response body + modifiedBody, err := io.ReadAll(modifiedRsp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + // Check if it was rewritten + if tt.expectRewrite { + if len(modifiedBody) >= len(respBody) && string(modifiedBody) == string(respBody) { + t.Error("response should have been rewritten") + } + } else { + if string(modifiedBody) != string(respBody) { + t.Errorf("response should not have been modified, got %s, expected %s", string(modifiedBody), string(respBody)) + } + } + + // Run custom check + tt.checkModified(t, modifiedBody) + }) + } +} diff --git a/proxy.go b/proxy.go index 1f4c42d..70d8133 100644 --- a/proxy.go +++ b/proxy.go @@ -123,6 +123,7 @@ func newProxy(envSettings config.ProxyEnvSettings, cfg *config.Config, blockedIp cargoRegistryHandler := handlers.NewCargoRegistryHandler(cfg.Credentials) proxy.OnRequest().DoFunc(cargoRegistryHandler.HandleRequest) + proxy.OnResponse().DoFunc(cargoRegistryHandler.HandleResponse) goProxyServerHandler := handlers.NewGoProxyServerHandler(cfg.Credentials) proxy.OnRequest().DoFunc(goProxyServerHandler.HandleRequest) From f6e36649dc6deae2f79fd67627296d6b4286fbe7 Mon Sep 17 00:00:00 2001 From: Emil Ljungdahl Date: Thu, 7 May 2026 23:29:32 +0200 Subject: [PATCH 2/2] Fixed lint errors --- internal/handlers/cargo_registry_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/handlers/cargo_registry_test.go b/internal/handlers/cargo_registry_test.go index 1f10b82..d1c3f5d 100644 --- a/internal/handlers/cargo_registry_test.go +++ b/internal/handlers/cargo_registry_test.go @@ -124,9 +124,9 @@ func TestCargoRegistryHandlerConfigJsonResponse(t *testing.T) { checkModified func(t *testing.T, body []byte) }{ { - name: "config.json with auth-required should be removed", - requestPath: "/index/te/st/config.json", - responseBody: map[string]interface{}{"auth-required": true, "dl": "https://example.com/download"}, + name: "config.json with auth-required should be removed", + requestPath: "/index/te/st/config.json", + responseBody: map[string]interface{}{"auth-required": true, "dl": "https://example.com/download"}, expectRewrite: true, checkModified: func(t *testing.T, body []byte) { var result map[string]interface{} @@ -142,9 +142,9 @@ func TestCargoRegistryHandlerConfigJsonResponse(t *testing.T) { }, }, { - name: "config.json without auth-required should not be modified", - requestPath: "/index/te/st/config.json", - responseBody: map[string]interface{}{"dl": "https://example.com/download"}, + name: "config.json without auth-required should not be modified", + requestPath: "/index/te/st/config.json", + responseBody: map[string]interface{}{"dl": "https://example.com/download"}, expectRewrite: false, checkModified: func(t *testing.T, body []byte) {}, },