Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions internal/handlers/cargo_registry.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package handlers

import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"

"github.com/elazarl/goproxy"
"github.com/sirupsen/logrus"
Expand Down Expand Up @@ -33,6 +37,16 @@ import (
// In that case, the supplied token value should be `Bearer <token>`. 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
Expand Down Expand Up @@ -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
}
124 changes: 124 additions & 0 deletions internal/handlers/cargo_registry_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
})
}
}
1 change: 1 addition & 0 deletions proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading