Skip to content
Open
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
95 changes: 95 additions & 0 deletions internal/handlers/opentofu_registry.go
Original file line number Diff line number Diff line change
@@ -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
}
142 changes: 142 additions & 0 deletions internal/handlers/opentofu_registry_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
3 changes: 3 additions & 0 deletions proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading