mirror of
https://github.com/hauler-dev/hauler.git
synced 2026-08-19 04:16:27 +00:00
337 lines
15 KiB
Go
337 lines
15 KiB
Go
package content
|
|
|
|
// registry_test.go covers the TLS/scheme wiring in NewRegistryHTTPClient()
|
|
// and NewRegistryTarget(). It reproduces a v2 regression where
|
|
// RegistryOptions.Insecure was a dead field for TLS purposes (the resolver
|
|
// always used http.DefaultClient, which has no TLS configuration) and was
|
|
// also incorrectly conflated with PlainHTTP when selecting the http/https
|
|
// scheme, plus a follow-up regression where Insecure was wired into the
|
|
// registry client but not into the Docker Bearer-auth token-fetch client.
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
// TestNewRegistryTarget_InsecureSkipsTLSVerification reproduces the case
|
|
// where a registry serves TLS with a self-signed certificate and the caller
|
|
// passes --insecure. Before the fix, the resolver's Client was
|
|
// http.DefaultClient (no TLS configuration), so the request would fail with
|
|
// a certificate verification error even though Insecure was set.
|
|
func TestNewRegistryTarget_InsecureSkipsTLSVerification(t *testing.T) {
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
host := strings.TrimPrefix(srv.URL, "https://")
|
|
|
|
opts := RegistryOptions{Insecure: true}
|
|
target := NewRegistryTarget(host, opts, NewRegistryHTTPClient(host, opts))
|
|
|
|
_, err := target.Resolve(context.Background(), host+"/library/test:latest")
|
|
if err == nil {
|
|
t.Fatalf("expected an error resolving against a fake registry that returns 404, got nil")
|
|
}
|
|
lower := strings.ToLower(err.Error())
|
|
if strings.Contains(lower, "certificate") {
|
|
t.Fatalf("expected no certificate verification error with Insecure: true, got: %v", err)
|
|
}
|
|
// "http://" (not "https://") in the error means Insecure wrongly
|
|
// forced a plain-http dial against this TLS-only server -- the
|
|
// historical conflation of Insecure with PlainHTTP.
|
|
if strings.Contains(err.Error(), "http://") {
|
|
t.Fatalf("expected https scheme to be used (Insecure must not force plain http), got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestNewRegistryTarget_InsecurePlainHTTPFollowsHTTPSRedirect reproduces the
|
|
// user's exact scenario: a registry that serves HTTP but 301-redirects to an
|
|
// HTTPS endpoint signed by a private CA. With --insecure --plain-http,
|
|
// hauler should dial http, follow the redirect to https, and skip cert
|
|
// verification on the redirected request.
|
|
//
|
|
// The two httptest servers both listen on 127.0.0.1, on different ports, so
|
|
// this also proves that the PlainHTTP https->http rewrite is scoped
|
|
// precisely enough to leave the redirect target's scheme alone: a blanket
|
|
// rewrite of every outgoing https request (as opposed to one scoped to the
|
|
// registry's own host:port) would downgrade this redirect to http and the
|
|
// TLS-only redirect target would never be reached.
|
|
func TestNewRegistryTarget_InsecurePlainHTTPFollowsHTTPSRedirect(t *testing.T) {
|
|
var tlsReached atomic.Bool
|
|
tlsSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tlsReached.Store(true)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer tlsSrv.Close()
|
|
|
|
httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, tlsSrv.URL+r.URL.Path, http.StatusMovedPermanently)
|
|
}))
|
|
defer httpSrv.Close()
|
|
|
|
host := strings.TrimPrefix(httpSrv.URL, "http://")
|
|
|
|
opts := RegistryOptions{Insecure: true, PlainHTTP: true}
|
|
target := NewRegistryTarget(host, opts, NewRegistryHTTPClient(host, opts))
|
|
|
|
_, err := target.Resolve(context.Background(), host+"/library/test:latest")
|
|
if err == nil {
|
|
t.Fatalf("expected an error resolving against a fake registry that returns 404, got nil")
|
|
}
|
|
lower := strings.ToLower(err.Error())
|
|
if strings.Contains(lower, "certificate signed by unknown authority") || strings.Contains(lower, "certificate") {
|
|
t.Fatalf("expected no certificate verification error after following http->https redirect with Insecure: true, got: %v", err)
|
|
}
|
|
if !tlsReached.Load() {
|
|
t.Fatal("plain-http downgraded a cross-host https redirect: TLS endpoint was never reached")
|
|
}
|
|
}
|
|
|
|
// TestNewRegistryTarget_PlainHTTPRewritesBearerTokenFetchScheme reproduces
|
|
// issue #677: a plain-http Bearer-auth registry (e.g. Harbor) that always
|
|
// advertises an https:// realm in its WWW-Authenticate challenge, even
|
|
// though the registry itself is only reachable over plain http. The token
|
|
// realm is on the SAME host:port as the registry (Harbor's own token
|
|
// service is co-located behind the same reverse proxy), which is what makes
|
|
// the host-scoped rewrite in plainHTTPRoundTripper apply here. Before the
|
|
// fix, the containerd Docker authorizer dialed the https realm literally
|
|
// and failed with "server gave HTTP response to HTTPS client".
|
|
func TestNewRegistryTarget_PlainHTTPRewritesBearerTokenFetchScheme(t *testing.T) {
|
|
var registrySrv *httptest.Server
|
|
registrySrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/service/token" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"token":"fake-token"}`))
|
|
return
|
|
}
|
|
realm := "https://" + strings.TrimPrefix(registrySrv.URL, "http://") + "/service/token"
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="registry"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer registrySrv.Close()
|
|
|
|
host := strings.TrimPrefix(registrySrv.URL, "http://")
|
|
|
|
opts := RegistryOptions{PlainHTTP: true}
|
|
target := NewRegistryTarget(host, opts, NewRegistryHTTPClient(host, opts))
|
|
|
|
_, err := target.Resolve(context.Background(), host+"/library/test:latest")
|
|
if err == nil {
|
|
t.Fatalf("expected an error resolving against a 401-only fake registry, got nil")
|
|
}
|
|
lower := strings.ToLower(err.Error())
|
|
if strings.Contains(lower, "server gave http response to https client") {
|
|
t.Fatalf("plain-http Bearer token fetch dialed the https realm literally instead of being rewritten to http, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestNewRegistryTarget_PlainHTTPRewritesBearerTokenFetchScheme_PathBearingHost
|
|
// reproduces the real call path used by `hauler store copy registry://`:
|
|
// cmd/hauler/cli/store/copy.go derives its host argument from
|
|
// strings.SplitN(targetRef, "://", 2)[1], which for a target reference like
|
|
// "oci://harbor:80/library" is "harbor:80/library" -- host:port WITH the
|
|
// repo path still attached, not a clean authority. NewRegistryHTTPClient
|
|
// must normalize that down to just the authority before comparing against
|
|
// req.URL.Host (which is never anything but the authority), or the
|
|
// plainHTTPRoundTripper rewrite silently never fires and #677 recurs in
|
|
// production even though the "clean host" tests above pass.
|
|
func TestNewRegistryTarget_PlainHTTPRewritesBearerTokenFetchScheme_PathBearingHost(t *testing.T) {
|
|
var registrySrv *httptest.Server
|
|
registrySrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/service/token" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"token":"fake-token"}`))
|
|
return
|
|
}
|
|
realm := "https://" + strings.TrimPrefix(registrySrv.URL, "http://") + "/service/token"
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="registry"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer registrySrv.Close()
|
|
|
|
hostPort := strings.TrimPrefix(registrySrv.URL, "http://")
|
|
// Mirrors copy.go's components[1]: host:port with a repo path attached.
|
|
componentsOne := hostPort + "/library"
|
|
|
|
opts := RegistryOptions{PlainHTTP: true}
|
|
target := NewRegistryTarget(componentsOne, opts, NewRegistryHTTPClient(componentsOne, opts))
|
|
|
|
_, err := target.Resolve(context.Background(), hostPort+"/library/test:latest")
|
|
if err == nil {
|
|
t.Fatalf("expected an error resolving against a 401-only fake registry, got nil")
|
|
}
|
|
lower := strings.ToLower(err.Error())
|
|
if strings.Contains(lower, "server gave http response to https client") {
|
|
t.Fatalf("plain-http Bearer token fetch dialed the https realm literally instead of being rewritten to http (path-bearing host arg like copy.go passes), got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestNewRegistryTarget_InsecureAppliesToBearerTokenFetch reproduces the case
|
|
// of a Bearer-auth registry (Harbor, Zot, Distribution + a token server)
|
|
// backed by a private CA. The registry responds 401 with a WWW-Authenticate
|
|
// Bearer challenge pointing at a token endpoint that is itself self-signed
|
|
// TLS. Before the fix, the resolver's TLS-aware client was only wired into
|
|
// RegistryHost.Client, never into the docker authorizer -- so the token
|
|
// fetch fell back to http.DefaultClient and failed cert verification even
|
|
// with Insecure: true. Basic-auth registries never exercise this 401 ->
|
|
// token-fetch path, which is why the other tests in this file didn't catch
|
|
// it.
|
|
func TestNewRegistryTarget_InsecureAppliesToBearerTokenFetch(t *testing.T) {
|
|
tokenSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"token":"fake-token"}`))
|
|
}))
|
|
defer tokenSrv.Close()
|
|
|
|
registrySrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="`+tokenSrv.URL+`/token",service="registry"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer registrySrv.Close()
|
|
|
|
host := strings.TrimPrefix(registrySrv.URL, "https://")
|
|
|
|
opts := RegistryOptions{Insecure: true}
|
|
target := NewRegistryTarget(host, opts, NewRegistryHTTPClient(host, opts))
|
|
|
|
_, err := target.Resolve(context.Background(), host+"/library/test:latest")
|
|
if err == nil {
|
|
t.Fatalf("expected an error resolving against a 401-only fake registry, got nil")
|
|
}
|
|
lower := strings.ToLower(err.Error())
|
|
if strings.Contains(lower, "certificate") {
|
|
t.Fatalf("expected the Bearer token fetch to honor Insecure and skip cert verification, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestNewRegistryTarget_SchemeSelection asserts that only PlainHTTP selects
|
|
// the http scheme, and that Insecure alone does not force http. This guards
|
|
// against the historical conflation of the two flags regressing.
|
|
//
|
|
// It uses a plain (non-TLS) httptest server as the target. When a resolver
|
|
// mistakenly dials https against a plain HTTP server, Go's net/http client
|
|
// surfaces the well-known error "http: server gave HTTP response to HTTPS
|
|
// client" -- that string's presence/absence tells us which scheme the
|
|
// resolver actually used, without needing to expose any internals.
|
|
func TestNewRegistryTarget_SchemeSelection(t *testing.T) {
|
|
plainSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer plainSrv.Close()
|
|
|
|
host := strings.TrimPrefix(plainSrv.URL, "http://")
|
|
|
|
tests := []struct {
|
|
name string
|
|
opts RegistryOptions
|
|
wantSchemeMismatch bool // true when the resolver dialed https against this plain http server
|
|
}{
|
|
{
|
|
name: "neither flag set defaults to https",
|
|
opts: RegistryOptions{},
|
|
wantSchemeMismatch: true,
|
|
},
|
|
{
|
|
name: "plainHTTP alone selects http",
|
|
opts: RegistryOptions{PlainHTTP: true},
|
|
wantSchemeMismatch: false,
|
|
},
|
|
{
|
|
name: "insecure alone does not select http",
|
|
opts: RegistryOptions{Insecure: true},
|
|
wantSchemeMismatch: true,
|
|
},
|
|
{
|
|
name: "insecure and plainHTTP together select http",
|
|
opts: RegistryOptions{Insecure: true, PlainHTTP: true},
|
|
wantSchemeMismatch: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
target := NewRegistryTarget(host, tt.opts, NewRegistryHTTPClient(host, tt.opts))
|
|
|
|
_, err := target.Resolve(context.Background(), host+"/library/test:latest")
|
|
|
|
gotMismatch := err != nil && strings.Contains(strings.ToLower(err.Error()), "server gave http response to https client")
|
|
if gotMismatch != tt.wantSchemeMismatch {
|
|
t.Fatalf("scheme mismatch detected = %v (err=%v), want %v", gotMismatch, err, tt.wantSchemeMismatch)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewRegistryHTTPClient_DoesNotLeakGlobalTLSConfig asserts that building
|
|
// an insecure client never mutates http.DefaultTransport in place, which
|
|
// would leak InsecureSkipVerify into every other HTTP client in the process.
|
|
// It compares http.DefaultTransport.TLSClientConfig before and after
|
|
// construction (rather than asserting it is nil) because the Go standard
|
|
// library itself lazily populates that field the first time DefaultTransport
|
|
// performs a TLS dial elsewhere in the test binary -- the property under
|
|
// test is that *construction* leaves it untouched, not that it is globally
|
|
// pristine.
|
|
func TestNewRegistryHTTPClient_DoesNotLeakGlobalTLSConfig(t *testing.T) {
|
|
dt, ok := http.DefaultTransport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatalf("http.DefaultTransport is not *http.Transport: %T", http.DefaultTransport)
|
|
}
|
|
before := dt.TLSClientConfig
|
|
|
|
_ = NewRegistryHTTPClient("registry.example.com", RegistryOptions{Insecure: true})
|
|
|
|
if dt.TLSClientConfig != before {
|
|
t.Fatalf("NewRegistryHTTPClient mutated the global http.DefaultTransport.TLSClientConfig: before=%+v after=%+v", before, dt.TLSClientConfig)
|
|
}
|
|
}
|
|
|
|
// stubRoundTripper is a minimal http.RoundTripper used to stand in for
|
|
// http.DefaultTransport in TestNewRegistryHTTPClient_FallsBackWhenDefaultTransportIsNotHTTPTransport.
|
|
// It is deliberately not a *http.Transport, so the comma-ok type assertion in
|
|
// NewRegistryHTTPClient takes its "not ok" branch.
|
|
type stubRoundTripper struct{}
|
|
|
|
func (stubRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// TestNewRegistryHTTPClient_FallsBackWhenDefaultTransportIsNotHTTPTransport
|
|
// guards against a panic if something in the process (instrumentation, a
|
|
// test harness) has replaced http.DefaultTransport with a RoundTripper that
|
|
// isn't *http.Transport. NewRegistryHTTPClient must fall back to a plain
|
|
// *http.Transport instead of panicking on the type assertion.
|
|
func TestNewRegistryHTTPClient_FallsBackWhenDefaultTransportIsNotHTTPTransport(t *testing.T) {
|
|
original := http.DefaultTransport
|
|
http.DefaultTransport = stubRoundTripper{}
|
|
defer func() { http.DefaultTransport = original }()
|
|
|
|
var client *http.Client
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("NewRegistryHTTPClient panicked with a non-*http.Transport DefaultTransport: %v", r)
|
|
}
|
|
}()
|
|
client = NewRegistryHTTPClient("registry.example.com", RegistryOptions{Insecure: true})
|
|
}()
|
|
|
|
if client == nil {
|
|
t.Fatalf("expected a non-nil client")
|
|
}
|
|
transport, ok := client.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatalf("expected client.Transport to be *http.Transport, got %T", client.Transport)
|
|
}
|
|
if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify {
|
|
t.Fatalf("expected InsecureSkipVerify to be honored on the fallback transport")
|
|
}
|
|
}
|