From 33e439fc09e41ca8cea47b2ee1d7037e8a7a9c80 Mon Sep 17 00:00:00 2001 From: Diogenes Fernandes Date: Wed, 29 Apr 2026 14:38:32 -0300 Subject: [PATCH] Add OpenTofu registry handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dependabot-core's OpenTofu ecosystem accepts credentials with type `opentofu_registry`, but the proxy had no matching handler to credential-stuff requests. Without it, requests against private OpenTofu registries reach the upstream anonymously and fail with authentication errors. Add an `OpenTofuRegistryHandler` modeled on the Terraform handler (same wire protocol — Terraform Module Registry HTTP API), and register it after the Terraform handler in the proxy chain. --- internal/handlers/opentofu_registry.go | 95 +++++++++++++ internal/handlers/opentofu_registry_test.go | 142 ++++++++++++++++++++ proxy.go | 3 + 3 files changed, 240 insertions(+) create mode 100644 internal/handlers/opentofu_registry.go create mode 100644 internal/handlers/opentofu_registry_test.go diff --git a/internal/handlers/opentofu_registry.go b/internal/handlers/opentofu_registry.go new file mode 100644 index 0000000..aaedf70 --- /dev/null +++ b/internal/handlers/opentofu_registry.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "net/http" + "sort" + + "github.com/elazarl/goproxy" + + "github.com/dependabot/proxy/internal/config" + "github.com/dependabot/proxy/internal/helpers" + "github.com/dependabot/proxy/internal/logging" + "github.com/dependabot/proxy/internal/oidc" +) + +type OpenTofuRegistryHandler struct { + credentials []openTofuRegistryCredentials + oidcRegistry *oidc.OIDCRegistry +} + +type openTofuRegistryCredentials struct { + host string + url string + token string +} + +func NewOpenTofuRegistryHandler(credentials config.Credentials) *OpenTofuRegistryHandler { + handler := OpenTofuRegistryHandler{ + credentials: []openTofuRegistryCredentials{}, + oidcRegistry: oidc.NewOIDCRegistry(), + } + + for _, credential := range credentials { + if credential["type"] != "opentofu_registry" { + continue + } + + // OIDC credentials are not used as static credentials. + if oidcCred, _, _ := handler.oidcRegistry.Register(credential, []string{"url"}, "opentofu registry"); oidcCred != nil { + continue + } + + host := credential.Host() + token := credential.GetString("token") + url := credential.GetString("url") + + // Skip credentials with empty token or both empty host and url + if token == "" || (host == "" && url == "") { + continue + } + + opentofuCred := openTofuRegistryCredentials{ + url: url, + token: token, + } + // Only set host when url is not provided to ensure URL-prefix matching + // takes precedence and doesn't fall back to host matching + if url == "" { + opentofuCred.host = host + } + handler.credentials = append(handler.credentials, opentofuCred) + } + + // Sort credentials by URL length descending (longest first) to ensure + // more specific URLs match before shorter ones. Using SliceStable for + // deterministic ordering when URL lengths are equal. + sort.SliceStable(handler.credentials, func(i, j int) bool { + return len(handler.credentials[i].url) > len(handler.credentials[j].url) + }) + + return &handler +} + +func (h *OpenTofuRegistryHandler) HandleRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) { + if request.URL.Scheme != "https" || !helpers.MethodPermitted(request, "GET", "HEAD") { + return request, nil + } + + // Try OIDC credentials first + if h.oidcRegistry.TryAuth(request, context) { + return request, nil + } + + // Fall back to static credentials + for _, cred := range h.credentials { + if !urlMatchesRequestWithBoundary(request, cred.url) && !helpers.CheckHost(request, cred.host) { + continue + } + + logging.RequestLogf(context, "* authenticating opentofu registry request (host: %s)", request.URL.Hostname()) + request.Header.Set("Authorization", "Bearer "+cred.token) + return request, nil + } + + return request, nil +} diff --git a/internal/handlers/opentofu_registry_test.go b/internal/handlers/opentofu_registry_test.go new file mode 100644 index 0000000..d10bd9b --- /dev/null +++ b/internal/handlers/opentofu_registry_test.go @@ -0,0 +1,142 @@ +package handlers + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dependabot/proxy/internal/config" +) + +func TestOpenTofuRegistryHandler(t *testing.T) { + var tests = []struct { + credentials config.Credentials + registryType string + host string + token string + url string + + authorization string + }{ + { + credentials: config.Credentials{ + config.Credential{"type": "opentofu_registry", "host": "registry.example.org", "token": "header.body.signature"}, + }, + url: "https://registry.example.org/v1/providers/org/name/versions", + authorization: "Bearer header.body.signature", + }, + { + credentials: config.Credentials{ + config.Credential{"type": "opentofu_registry", "url": "https://registry.example.org", "token": "header.body.signature"}, + }, + url: "https://registry.example.org/v1/providers/org/name/versions", + authorization: "Bearer header.body.signature", + }, + { + credentials: config.Credentials{ + config.Credential{"type": "opentofu_registry", "host": "registry.example.org", "token": "header.body.signature"}, + }, + url: "https://registry.opentofu.org/v1/providers/org/name/versions", + authorization: "", + }, + { + credentials: config.Credentials{ + config.Credential{"type": "rubygems_server", "host": "registry.example.org", "token": "header.body.signature"}, + }, + url: "https://registry.example.org/v1/providers/org/name/versions", + authorization: "", + }, + { + credentials: config.Credentials{ + config.Credential{"type": "terraform_registry", "host": "registry.example.org", "token": "header.body.signature"}, + }, + url: "https://registry.example.org/v1/providers/org/name/versions", + authorization: "", + }, + { + credentials: config.Credentials{ + config.Credential{"type": "opentofu_registry", "host": "rEgIstRy.eXampLe.orG", "token": "token"}, + }, + url: "https://registry.example.org/v1/providers/org/name/versions", + authorization: "Bearer token", + }, + } + for _, tt := range tests { + t.Run(strings.Join([]string{tt.registryType, tt.host, tt.token}, " "), func(t *testing.T) { + handler := NewOpenTofuRegistryHandler(tt.credentials) + + request := handleRequestAndClose(handler, httptest.NewRequest("GET", tt.url, nil), nil) + + assert.Equal(t, tt.authorization, request.Header.Get("Authorization")) + }) + } + + t.Run("HandleRequest without credentials", func(t *testing.T) { + handler := NewOpenTofuRegistryHandler(config.Credentials{}) + + url := "https://registry.opentofu.org/v1/providers/org/name/versions" + request := handleRequestAndClose(handler, httptest.NewRequest("GET", url, nil), nil) + + assert.Equal(t, "", request.Header.Get("Authorization"), "should be empty") + }) + + t.Run("multiple credentials on same host with different URL paths", func(t *testing.T) { + credentials := config.Credentials{ + config.Credential{"type": "opentofu_registry", "url": "https://registry.example.com/org1", "token": "token-org1"}, + config.Credential{"type": "opentofu_registry", "url": "https://registry.example.com/org2", "token": "token-org2"}, + } + handler := NewOpenTofuRegistryHandler(credentials) + + // Request to org1 path should use org1 token + req1 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://registry.example.com/org1/v1/providers/foo", nil), nil) + assert.Equal(t, "Bearer token-org1", req1.Header.Get("Authorization"), "should use org1 token") + + // Request to org2 path should use org2 token + req2 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://registry.example.com/org2/v1/providers/bar", nil), nil) + assert.Equal(t, "Bearer token-org2", req2.Header.Get("Authorization"), "should use org2 token") + + // Request to unmatched path should not be authenticated + req3 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://registry.example.com/org3/v1/providers/baz", nil), nil) + assert.Equal(t, "", req3.Header.Get("Authorization"), "should not be authenticated") + }) + + t.Run("skips credentials with empty token", func(t *testing.T) { + credentials := config.Credentials{ + config.Credential{"type": "opentofu_registry", "host": "registry.example.org", "token": ""}, + } + handler := NewOpenTofuRegistryHandler(credentials) + assert.Equal(t, 0, len(handler.credentials), "should skip credential with empty token") + }) + + t.Run("skips credentials with empty host and url", func(t *testing.T) { + credentials := config.Credentials{ + config.Credential{"type": "opentofu_registry", "token": "some-token"}, + } + handler := NewOpenTofuRegistryHandler(credentials) + assert.Equal(t, 0, len(handler.credentials), "should skip credential with empty host and url") + }) + + t.Run("path boundary: /org should not match /org1", func(t *testing.T) { + // Credentials are sorted longest-path-first to ensure /org1 matches before /org + credentials := config.Credentials{ + config.Credential{"type": "opentofu_registry", "url": "https://registry.example.com/org", "token": "token-org"}, + config.Credential{"type": "opentofu_registry", "url": "https://registry.example.com/org1", "token": "token-org1"}, + } + handler := NewOpenTofuRegistryHandler(credentials) + + assert.Equal(t, "https://registry.example.com/org1", handler.credentials[0].url, "longer path should be first") + assert.Equal(t, "https://registry.example.com/org", handler.credentials[1].url, "shorter path should be second") + + req1 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://registry.example.com/org1/v1/providers/foo", nil), nil) + assert.Equal(t, "Bearer token-org1", req1.Header.Get("Authorization"), "/org1 path should use org1 token") + + req2 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://registry.example.com/org/v1/providers/bar", nil), nil) + assert.Equal(t, "Bearer token-org", req2.Header.Get("Authorization"), "/org path should use org token") + + // Request to /org123 should NOT match /org1 or /org (path boundary check) + req3 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://registry.example.com/org123/v1/providers/baz", nil), nil) + assert.Equal(t, "", req3.Header.Get("Authorization"), "/org123 should not match /org or /org1") + }) +} diff --git a/proxy.go b/proxy.go index 1f4c42d..f39c8e3 100644 --- a/proxy.go +++ b/proxy.go @@ -118,6 +118,9 @@ func newProxy(envSettings config.ProxyEnvSettings, cfg *config.Config, blockedIp terraformRegistryHandler := handlers.NewTerraformRegistryHandler(cfg.Credentials) proxy.OnRequest().DoFunc(terraformRegistryHandler.HandleRequest) + openTofuRegistryHandler := handlers.NewOpenTofuRegistryHandler(cfg.Credentials) + proxy.OnRequest().DoFunc(openTofuRegistryHandler.HandleRequest) + pubRepositoryHandler := handlers.NewPubRepositoryHandler(cfg.Credentials) proxy.OnRequest().DoFunc(pubRepositoryHandler.HandleRequest)