VYPR
High severity7.4GHSA Advisory· Published May 29, 2026· Updated May 29, 2026

CVE-2026-48501

CVE-2026-48501

Description

GitHub CLI (gh) is GitHub’s official command line tool. Prior to 2.93.0, GitHub CLI incorrectly includes authorization header in API requests to TUF repository mirrors via gh attestation, gh release verify, and gh release verify-asset commands. The CLI uses a shared HTTP client with an authentication layer that automatically attaches tokens to outgoing requests. This layer lacks accurate host detection and can incorrectly attribute the target host, providing it with a token it should never receive. Specifically, the host normalization logic collapses any *.github.com subdomain to github.com, so a request to tuf-repo.github.com (a GitHub Pages site, not a GitHub API endpoint) is treated as a request to github.com and receives the user's github.com token. For hosts that don't match github.com or a known GHES instance at all, the resolver falls back to GH_ENTERPRISE_TOKEN if set. The gh attestation, gh release verify and gh release verify-asset commands fetch data from several external hosts as part of their normal operation (TUF metadata from tuf-repo.github.com and tuf-repo-cdn.sigstore.dev, artifact bundles from Azure Blob Storage). Because these requests go through the same authenticated HTTP client, the token is sent to all of them. This vulnerability is fixed in 2.93.0.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

GitHub CLI prior to 2.93.0 leaks authentication tokens to unintended external hosts via TUF and artifact requests in three attestation/verification commands.

Vulnerability

GitHub CLI (gh) versions prior to 2.93.0 incorrectly include the user's github.com OAuth token (or GH_ENTERPRISE_TOKEN / GITHUB_ENTERPRISE_TOKEN) as an authorization header in HTTP requests made to unintended hosts during the gh attestation, gh release verify, and gh release verify-asset commands. The shared HTTP client's authentication layer lacks precise host detection: it collapses any *.github.com subdomain to github.com, causing requests to tuf-repo.github.com (a GitHub Pages site) to receive the github.com token. For hosts not matching github.com or a known GHES instance, the resolver falls back to GH_ENTERPRISE_TOKEN if set, which is then sent to external hosts such as tuf-repo-cdn.sigstore.dev and tmaproduction.blob.core.windows.net [1][2][3].

Exploitation

An attacker does not need any authentication or special access; the vulnerability is triggered automatically whenever a victim runs any of the three affected commands (gh attestation, gh release verify, gh release verify-asset) in a normal workflow. These commands fetch TUF metadata from tuf-repo.github.com and tuf-repo-cdn.sigstore.dev and artifact bundles from Azure Blob Storage (tmaproduction.blob.core.windows.net). Because these requests use the same authenticated HTTP client, the token is included in the HTTP Authorization header sent to those hosts. No user interaction beyond executing the command is required [2][3].

Impact

An attacker who controls, monitors, or compromises any of the recipient hosts (tuf-repo.github.com, tuf-repo-cdn.sigstore.dev, tmaproduction.blob.core.windows.net) could capture the leaked token. For github.com users, this would expose the user's github.com OAuth token; for enterprise users with environment variables set, it would expose their enterprise token. The token could then be used to impersonate the victim or gain unauthorized access to repositories, actions, and other GitHub resources with the same privileges as the victim. There is no evidence that tokens were logged, retained, or actively exploited prior to disclosure [2][3].

Mitigation

The vulnerability is fixed in GitHub CLI version 2.93.0. Users should upgrade immediately by downloading the latest release or running the appropriate package manager update command. No workarounds have been published; users who cannot upgrade should avoid using the affected commands (gh attestation, gh release verify, gh release verify-asset) until the update is applied. The fix is included in the release notes for v2.93.0 [1][2][3].

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

3
98d91db0e22d

test(attestation): align integration tests with new external HTTP client

https://github.com/cli/cliKynan WareMay 27, 2026Fixed in 2.93.0via ghsa-release-walk
4 files changed · +39 36
  • pkg/cmd/attestation/inspect/inspect_integration_test.go+3 0 modified
    @@ -21,6 +21,9 @@ func TestNewInspectCmd_PrintOutputJSONFormat(t *testing.T) {
     		HttpClient: func() (*http.Client, error) {
     			return http.DefaultClient, nil
     		},
    +		ExternalHttpClient: func() (*http.Client, error) {
    +			return http.DefaultClient, nil
    +		},
     	}
     
     	t.Run("Print output in JSON format", func(t *testing.T) {
    
  • pkg/cmd/attestation/verification/sigstore_integration_test.go+16 16 modified
    @@ -52,9 +52,9 @@ func TestLiveSigstoreVerifier(t *testing.T) {
     	for _, tc := range testcases {
     		t.Run(tc.name, func(t *testing.T) {
     			verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
    -				HttpClient:     http.DefaultClient,
    -				Logger:         io.NewTestHandler(),
    -				TUFMetadataDir: o.Some(t.TempDir()),
    +				ExternalHttpClient: http.DefaultClient,
    +				Logger:             io.NewTestHandler(),
    +				TUFMetadataDir:     o.Some(t.TempDir()),
     			})
     			require.NoError(t, err)
     
    @@ -73,9 +73,9 @@ func TestLiveSigstoreVerifier(t *testing.T) {
     
     	t.Run("with 2/3 verified attestations", func(t *testing.T) {
     		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
    -			HttpClient:     http.DefaultClient,
    -			Logger:         io.NewTestHandler(),
    -			TUFMetadataDir: o.Some(t.TempDir()),
    +			ExternalHttpClient: http.DefaultClient,
    +			Logger:             io.NewTestHandler(),
    +			TUFMetadataDir:     o.Some(t.TempDir()),
     		})
     		require.NoError(t, err)
     
    @@ -92,9 +92,9 @@ func TestLiveSigstoreVerifier(t *testing.T) {
     
     	t.Run("fail with 0/2 verified attestations", func(t *testing.T) {
     		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
    -			HttpClient:     http.DefaultClient,
    -			Logger:         io.NewTestHandler(),
    -			TUFMetadataDir: o.Some(t.TempDir()),
    +			ExternalHttpClient: http.DefaultClient,
    +			Logger:             io.NewTestHandler(),
    +			TUFMetadataDir:     o.Some(t.TempDir()),
     		})
     		require.NoError(t, err)
     
    @@ -118,9 +118,9 @@ func TestLiveSigstoreVerifier(t *testing.T) {
     		attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl")
     
     		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
    -			HttpClient:     http.DefaultClient,
    -			Logger:         io.NewTestHandler(),
    -			TUFMetadataDir: o.Some(t.TempDir()),
    +			ExternalHttpClient: http.DefaultClient,
    +			Logger:             io.NewTestHandler(),
    +			TUFMetadataDir:     o.Some(t.TempDir()),
     		})
     		require.NoError(t, err)
     
    @@ -133,10 +133,10 @@ func TestLiveSigstoreVerifier(t *testing.T) {
     		attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
     
     		verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
    -			HttpClient:     http.DefaultClient,
    -			Logger:         io.NewTestHandler(),
    -			TrustedRoot:    test.NormalizeRelativePath("../test/data/trusted_root.json"),
    -			TUFMetadataDir: o.Some(t.TempDir()),
    +			ExternalHttpClient: http.DefaultClient,
    +			Logger:             io.NewTestHandler(),
    +			TrustedRoot:        test.NormalizeRelativePath("../test/data/trusted_root.json"),
    +			TUFMetadataDir:     o.Some(t.TempDir()),
     		})
     		require.NoError(t, err)
     
    
  • pkg/cmd/attestation/verify/attestation_integration_test.go+3 3 modified
    @@ -27,9 +27,9 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation {
     
     func TestVerifyAttestations(t *testing.T) {
     	sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
    -		HttpClient:     http.DefaultClient,
    -		Logger:         io.NewTestHandler(),
    -		TUFMetadataDir: o.Some(t.TempDir()),
    +		ExternalHttpClient: http.DefaultClient,
    +		Logger:             io.NewTestHandler(),
    +		TUFMetadataDir:     o.Some(t.TempDir()),
     	})
     	require.NoError(t, err)
     
    
  • pkg/cmd/attestation/verify/verify_integration_test.go+17 17 modified
    @@ -25,9 +25,9 @@ func TestVerifyIntegration(t *testing.T) {
     	logger := io.NewTestHandler()
     
     	sigstoreConfig := verification.SigstoreConfig{
    -		HttpClient:     http.DefaultClient,
    -		Logger:         logger,
    -		TUFMetadataDir: o.Some(t.TempDir()),
    +		ExternalHttpClient: http.DefaultClient,
    +		Logger:             logger,
    +		TUFMetadataDir:     o.Some(t.TempDir()),
     	}
     
     	ios, _, _, _ := iostreams.Test()
    @@ -45,7 +45,7 @@ func TestVerifyIntegration(t *testing.T) {
     	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
     	require.NoError(t, err)
     	publicGoodOpts := Options{
    -		APIClient:        api.NewLiveClient(hc, host, logger),
    +		APIClient:        api.NewLiveClient(hc, http.DefaultClient, host, logger),
     		ArtifactPath:     artifactPath,
     		BundlePath:       bundlePath,
     		DigestAlgorithm:  "sha512",
    @@ -120,7 +120,7 @@ func TestVerifyIntegration(t *testing.T) {
     		sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
     		require.NoError(t, err)
     		opts := Options{
    -			APIClient:             api.NewLiveClient(hc, host, logger),
    +			APIClient:             api.NewLiveClient(hc, http.DefaultClient, host, logger),
     			ArtifactPath:          "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9",
     			UseBundleFromRegistry: true,
     			DigestAlgorithm:       "sha256",
    @@ -145,9 +145,9 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
     	logger := io.NewTestHandler()
     
     	sigstoreConfig := verification.SigstoreConfig{
    -		HttpClient:     http.DefaultClient,
    -		Logger:         logger,
    -		TUFMetadataDir: o.Some(t.TempDir()),
    +		ExternalHttpClient: http.DefaultClient,
    +		Logger:             logger,
    +		TUFMetadataDir:     o.Some(t.TempDir()),
     	}
     
     	ios, _, _, _ := iostreams.Test()
    @@ -165,7 +165,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
     	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
     	require.NoError(t, err)
     	baseOpts := Options{
    -		APIClient:        api.NewLiveClient(hc, host, logger),
    +		APIClient:        api.NewLiveClient(hc, http.DefaultClient, host, logger),
     		ArtifactPath:     artifactPath,
     		BundlePath:       bundlePath,
     		DigestAlgorithm:  "sha256",
    @@ -222,9 +222,9 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
     	logger := io.NewTestHandler()
     
     	sigstoreConfig := verification.SigstoreConfig{
    -		HttpClient:     http.DefaultClient,
    -		Logger:         logger,
    -		TUFMetadataDir: o.Some(t.TempDir()),
    +		ExternalHttpClient: http.DefaultClient,
    +		Logger:             logger,
    +		TUFMetadataDir:     o.Some(t.TempDir()),
     	}
     
     	cfg := config.NewBlankConfig()
    @@ -243,7 +243,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
     	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
     	require.NoError(t, err)
     	baseOpts := Options{
    -		APIClient:        api.NewLiveClient(hc, host, logger),
    +		APIClient:        api.NewLiveClient(hc, http.DefaultClient, host, logger),
     		ArtifactPath:     artifactPath,
     		BundlePath:       bundlePath,
     		DigestAlgorithm:  "sha256",
    @@ -319,9 +319,9 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
     	logger := io.NewTestHandler()
     
     	sigstoreConfig := verification.SigstoreConfig{
    -		HttpClient:     http.DefaultClient,
    -		Logger:         logger,
    -		TUFMetadataDir: o.Some(t.TempDir()),
    +		ExternalHttpClient: http.DefaultClient,
    +		Logger:             logger,
    +		TUFMetadataDir:     o.Some(t.TempDir()),
     	}
     
     	cfg := config.NewBlankConfig()
    @@ -340,7 +340,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
     	sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
     	require.NoError(t, err)
     	baseOpts := Options{
    -		APIClient:    api.NewLiveClient(hc, host, logger),
    +		APIClient:    api.NewLiveClient(hc, http.DefaultClient, host, logger),
     		ArtifactPath: artifactPath,
     		BundlePath:   bundlePath,
     		Config: func() (gh.Config, error) {
    
e6dfcd3ce728

fix: use separate http client for non-github hosts

https://github.com/cli/cliBabak K. ShandizMay 27, 2026Fixed in 2.93.0via ghsa-release-walk
25 files changed · +294 143
  • api/http_client.go+44 0 modified
    @@ -86,6 +86,50 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
     	return client, nil
     }
     
    +// ExternalHTTPClientOptions holds options for creating an external HTTP client.
    +type ExternalHTTPClientOptions struct {
    +	AppVersion  string
    +	Log         io.Writer
    +	LogColorize bool
    +	Transport   http.RoundTripper
    +}
    +
    +// NewExternalHTTPClient creates an HTTP client for talking to non-GitHub hosts.
    +// It includes debug logging and a User-Agent header but does not attach any
    +// authentication tokens or GitHub-specific headers.
    +func NewExternalHTTPClient(opts ExternalHTTPClientOptions) (*http.Client, error) {
    +	clientOpts := ghAPI.ClientOptions{
    +		Host:               "none",
    +		AuthToken:          "none",
    +		LogIgnoreEnv:       true,
    +		SkipDefaultHeaders: true,
    +		Transport:          opts.Transport,
    +	}
    +
    +	debugEnabled, debugValue := utils.IsDebugEnabled()
    +	logVerboseHTTP := false
    +	if strings.Contains(debugValue, "api") {
    +		logVerboseHTTP = true
    +	}
    +
    +	if logVerboseHTTP || debugEnabled {
    +		clientOpts.Log = opts.Log
    +		clientOpts.LogColorize = opts.LogColorize
    +		clientOpts.LogVerboseHTTP = logVerboseHTTP
    +	}
    +
    +	clientOpts.Headers = map[string]string{
    +		userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
    +	}
    +
    +	client, err := ghAPI.NewHTTPClient(clientOpts)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	return client, nil
    +}
    +
     func NewCachedHTTPClient(httpClient *http.Client, ttl time.Duration) *http.Client {
     	newClient := *httpClient
     	newClient.Transport = AddCacheTTLHeader(httpClient.Transport, ttl)
    
  • api/http_client_test.go+49 0 modified
    @@ -381,6 +381,55 @@ func TestNewHTTPClientWithoutTelemetryDisabler(t *testing.T) {
     	assert.Equal(t, 204, res.StatusCode)
     }
     
    +func TestNewExternalHTTPClient(t *testing.T) {
    +	tests := []struct {
    +		name string
    +		url  string
    +	}{
    +		{
    +			name: "third-party host",
    +			url:  "https://example.com/path",
    +		},
    +		{
    +			// Even when talking to GitHub, the external client must not set
    +			// authorization or any GitHub-specific headers.
    +			name: "github.com host",
    +			url:  "https://api.github.com/repos/cli/cli",
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			var gotReq *http.Request
    +			transport := &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
    +				gotReq = req
    +				return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader(""))}, nil
    +			}}
    +
    +			client, err := NewExternalHTTPClient(ExternalHTTPClientOptions{
    +				AppVersion: "v1.2.3",
    +				Transport:  transport,
    +			})
    +			require.NoError(t, err)
    +
    +			req, err := http.NewRequest("GET", tt.url, nil)
    +			require.NoError(t, err)
    +
    +			res, err := client.Do(req)
    +			require.NoError(t, err)
    +			assert.Equal(t, 204, res.StatusCode)
    +
    +			// No headers should be set by default, except for User-Agent which should include the app version.
    +			assert.Equal(t, []string{"GitHub CLI v1.2.3"}, gotReq.Header.Values("user-agent"))
    +			assert.Empty(t, gotReq.Header.Values("authorization"))
    +			assert.Empty(t, gotReq.Header.Values("x-github-api-version"))
    +			assert.Empty(t, gotReq.Header.Values("accept"))
    +			assert.Empty(t, gotReq.Header.Values("content-type"))
    +			assert.Empty(t, gotReq.Header.Values("time-zone"))
    +		})
    +	}
    +}
    +
     type fakeTelemetryDisabler struct {
     	disabled bool
     }
    
  • internal/codespaces/api/api.go+14 16 modified
    @@ -60,10 +60,11 @@ const (
     
     // API is the interface to the codespace service.
     type API struct {
    -	client       func() (*http.Client, error)
    -	githubAPI    string
    -	githubServer string
    -	retryBackoff time.Duration
    +	client         func() (*http.Client, error)
    +	externalClient func() (*http.Client, error)
    +	githubAPI      string
    +	githubServer   string
    +	retryBackoff   time.Duration
     }
     
     // New creates a new API client connecting to the configured endpoints with the HTTP client.
    @@ -93,10 +94,11 @@ func New(f *cmdutil.Factory) *API {
     	}
     
     	return &API{
    -		client:       f.HttpClient,
    -		githubAPI:    strings.TrimSuffix(apiURL, "/"),
    -		githubServer: strings.TrimSuffix(serverURL, "/"),
    -		retryBackoff: 100 * time.Millisecond,
    +		client:         f.HttpClient,
    +		externalClient: f.ExternalHttpClient,
    +		githubAPI:      strings.TrimSuffix(apiURL, "/"),
    +		githubServer:   strings.TrimSuffix(serverURL, "/"),
    +		retryBackoff:   100 * time.Millisecond,
     	}
     }
     
    @@ -1214,12 +1216,8 @@ func (a *API) withRetry(f func() (*http.Response, error)) (*http.Response, error
     	}, backoff.WithMaxRetries(bo, 3))
     }
     
    -// HTTPClient returns the HTTP client used to make requests to the API.
    -func (a *API) HTTPClient() (*http.Client, error) {
    -	httpClient, err := a.client()
    -	if err != nil {
    -		return nil, err
    -	}
    -
    -	return httpClient, nil
    +// ExternalHTTPClient returns an HTTP client for requests to non-GitHub hosts.
    +// It must not carry GitHub authentication credentials.
    +func (a *API) ExternalHTTPClient() (*http.Client, error) {
    +	return a.externalClient()
     }
    
  • internal/codespaces/codespaces.go+3 3 modified
    @@ -39,7 +39,7 @@ func connectionReady(codespace *api.Codespace) bool {
     type apiClient interface {
     	GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
     	StartCodespace(ctx context.Context, name string) error
    -	HTTPClient() (*http.Client, error)
    +	ExternalHTTPClient() (*http.Client, error)
     }
     
     type progressIndicator interface {
    @@ -66,12 +66,12 @@ func GetCodespaceConnection(ctx context.Context, progress progressIndicator, api
     	progress.StartProgressIndicatorWithLabel("Connecting to codespace")
     	defer progress.StopProgressIndicator()
     
    -	httpClient, err := apiClient.HTTPClient()
    +	externalHttpClient, err := apiClient.ExternalHTTPClient()
     	if err != nil {
     		return nil, fmt.Errorf("error getting http client: %w", err)
     	}
     
    -	return connection.NewCodespaceConnection(ctx, codespace, httpClient)
    +	return connection.NewCodespaceConnection(ctx, codespace, externalHttpClient)
     }
     
     // waitUntilCodespaceConnectionReady waits for a Codespace to be running and is able to be connected to.
    
  • internal/codespaces/codespaces_test.go+2 2 modified
    @@ -202,8 +202,8 @@ func (m *mockApiClient) GetCodespace(ctx context.Context, name string, includeCo
     	return m.onGetCodespace()
     }
     
    -func (m *mockApiClient) HTTPClient() (*http.Client, error) {
    -	panic("Not implemented")
    +func (m *mockApiClient) ExternalHTTPClient() (*http.Client, error) {
    +	return nil, nil
     }
     
     type mockProgressIndicator struct{}
    
  • pkg/cmd/attestation/api/client.go+12 11 modified
    @@ -5,6 +5,7 @@ import (
     	"fmt"
     	"io"
     	"net/http"
    +	neturl "net/url"
     	"strings"
     	"time"
     
    @@ -67,18 +68,18 @@ type Client interface {
     }
     
     type LiveClient struct {
    -	githubAPI  githubApiClient
    -	httpClient httpClient
    -	host       string
    -	logger     *ioconfig.Handler
    +	githubAPI          githubApiClient
    +	externalHttpClient httpClient
    +	host               string
    +	logger             *ioconfig.Handler
     }
     
    -func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClient {
    +func NewLiveClient(hc *http.Client, externalClient *http.Client, host string, l *ioconfig.Handler) *LiveClient {
     	return &LiveClient{
    -		githubAPI:  api.NewClientFromHTTP(hc),
    -		host:       strings.TrimSuffix(host, "/"),
    -		httpClient: hc,
    -		logger:     l,
    +		githubAPI:          api.NewClientFromHTTP(hc),
    +		host:               strings.TrimSuffix(host, "/"),
    +		externalHttpClient: externalClient,
    +		logger:             l,
     	}
     }
     
    @@ -121,7 +122,7 @@ func (c *LiveClient) buildRequestURL(params FetchParams) (string, error) {
     	// ref: https://github.com/cli/go-gh/blob/d32c104a9a25c9de3d7c7b07a43ae0091441c858/example_gh_test.go#L96
     	url = fmt.Sprintf("%s?per_page=%d", url, perPage)
     	if params.PredicateType != "" {
    -		url = fmt.Sprintf("%s&predicate_type=%s", url, params.PredicateType)
    +		url = fmt.Sprintf("%s&predicate_type=%s", url, neturl.QueryEscape(params.PredicateType))
     	}
     	return url, nil
     }
    @@ -225,7 +226,7 @@ func (c *LiveClient) getBundle(url string) (*bundle.Bundle, error) {
     	var sgBundle *bundle.Bundle
     	bo := backoff.NewConstantBackOff(getAttestationRetryInterval)
     	err := backoff.Retry(func() error {
    -		resp, err := c.httpClient.Get(url)
    +		resp, err := c.externalHttpClient.Get(url)
     		if err != nil {
     			return fmt.Errorf("request to fetch bundle from URL failed: %w", err)
     		}
    
  • pkg/cmd/attestation/api/client_test.go+26 26 modified
    @@ -28,17 +28,17 @@ func NewClientWithMockGHClient(hasNextPage bool) Client {
     			githubAPI: mockAPIClient{
     				OnRESTWithNext: fetcher.OnRESTSuccessWithNextPage,
     			},
    -			httpClient: httpClient,
    -			logger:     l,
    +			externalHttpClient: httpClient,
    +			logger:             l,
     		}
     	}
     
     	return &LiveClient{
     		githubAPI: mockAPIClient{
     			OnRESTWithNext: fetcher.OnRESTSuccess,
     		},
    -		httpClient: httpClient,
    -		logger:     l,
    +		externalHttpClient: httpClient,
    +		logger:             l,
     	}
     }
     
    @@ -137,8 +137,8 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) {
     		githubAPI: mockAPIClient{
     			OnRESTWithNext: fetcher.OnRESTWithNextNoAttestations,
     		},
    -		httpClient: httpClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: httpClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	attestations, err := c.GetByDigest(testFetchParamsWithRepo)
    @@ -167,8 +167,8 @@ func TestGetByDigest_Error(t *testing.T) {
     func TestFetchBundleFromAttestations_BundleURL(t *testing.T) {
     	httpClient := &mockHttpClient{}
     	client := LiveClient{
    -		httpClient: httpClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: httpClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	att1 := makeTestAttestation()
    @@ -184,8 +184,8 @@ func TestFetchBundleFromAttestations_BundleURL(t *testing.T) {
     func TestFetchBundleFromAttestations_MissingBundleAndBundleURLFields(t *testing.T) {
     	httpClient := &mockHttpClient{}
     	client := LiveClient{
    -		httpClient: httpClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: httpClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	// If both the BundleURL and Bundle fields are empty, the function should
    @@ -207,8 +207,8 @@ func TestFetchBundleFromAttestations_FailOnTheSecondAttestation(t *testing.T) {
     	}
     
     	c := &LiveClient{
    -		httpClient: mockHTTPClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: mockHTTPClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	att1 := makeTestAttestation()
    @@ -223,8 +223,8 @@ func TestFetchBundleFromAttestations_FailAfterRetrying(t *testing.T) {
     	mockHTTPClient := &reqFailHttpClient{}
     
     	c := &LiveClient{
    -		httpClient: mockHTTPClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: mockHTTPClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	a := makeTestAttestation()
    @@ -239,8 +239,8 @@ func TestFetchBundleFromAttestations_FallbackToBundleField(t *testing.T) {
     	mockHTTPClient := &mockHttpClient{}
     
     	c := &LiveClient{
    -		httpClient: mockHTTPClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: mockHTTPClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	// If the bundle URL is empty, the code will fallback to the bundle field
    @@ -257,8 +257,8 @@ func TestGetBundle(t *testing.T) {
     	mockHTTPClient := &mockHttpClient{}
     
     	c := &LiveClient{
    -		httpClient: mockHTTPClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: mockHTTPClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	b, err := c.getBundle("https://mybundleurl.com")
    @@ -276,8 +276,8 @@ func TestGetBundle_SuccessfulRetry(t *testing.T) {
     	}
     
     	c := &LiveClient{
    -		httpClient: mockHTTPClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: mockHTTPClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	b, err := c.getBundle("mybundleurl")
    @@ -290,8 +290,8 @@ func TestGetBundle_SuccessfulRetry(t *testing.T) {
     func TestGetBundle_PermanentBackoffFail(t *testing.T) {
     	mockHTTPClient := &invalidBundleClient{}
     	c := &LiveClient{
    -		httpClient: mockHTTPClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: mockHTTPClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	b, err := c.getBundle("mybundleurl")
    @@ -307,8 +307,8 @@ func TestGetBundle_RequestFail(t *testing.T) {
     	mockHTTPClient := &reqFailHttpClient{}
     
     	c := &LiveClient{
    -		httpClient: mockHTTPClient,
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: mockHTTPClient,
    +		logger:             io.NewTestHandler(),
     	}
     
     	b, err := c.getBundle("mybundleurl")
    @@ -360,8 +360,8 @@ func TestGetAttestationsRetries(t *testing.T) {
     		githubAPI: mockAPIClient{
     			OnRESTWithNext: fetcher.FlakyOnRESTSuccessWithNextPageHandler(),
     		},
    -		httpClient: &mockHttpClient{},
    -		logger:     io.NewTestHandler(),
    +		externalHttpClient: &mockHttpClient{},
    +		logger:             io.NewTestHandler(),
     	}
     
     	testFetchParamsWithRepo.Limit = 30
    
  • pkg/cmd/attestation/download/download.go+6 1 modified
    @@ -84,14 +84,19 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman
     				return err
     			}
     
    +			externalClient, err := f.ExternalHttpClient()
    +			if err != nil {
    +				return err
    +			}
    +
     			if opts.Hostname == "" {
     				opts.Hostname, _ = ghauth.DefaultHost()
     			}
     			if err := auth.IsHostSupported(opts.Hostname); err != nil {
     				return err
     			}
     
    -			opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger)
    +			opts.APIClient = api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger)
     			opts.OCIClient = oci.NewLiveClient()
     			opts.Store = NewLiveStore("")
     
    
  • pkg/cmd/attestation/download/download_test.go+4 5 modified
    @@ -15,7 +15,6 @@ import (
     	"github.com/cli/cli/v2/pkg/cmd/attestation/test"
     	"github.com/cli/cli/v2/pkg/cmdutil"
     
    -	"github.com/cli/cli/v2/pkg/httpmock"
     	"github.com/cli/cli/v2/pkg/iostreams"
     	"github.com/stretchr/testify/assert"
     	"github.com/stretchr/testify/require"
    @@ -39,10 +38,10 @@ func TestNewDownloadCmd(t *testing.T) {
     	f := &cmdutil.Factory{
     		IOStreams: testIO,
     		HttpClient: func() (*http.Client, error) {
    -			reg := &httpmock.Registry{}
    -			client := &http.Client{}
    -			httpmock.ReplaceTripper(client, reg)
    -			return client, nil
    +			return nil, nil
    +		},
    +		ExternalHttpClient: func() (*http.Client, error) {
    +			return nil, nil
     		},
     	}
     
    
  • pkg/cmd/attestation/inspect/inspect.go+8 7 modified
    @@ -86,17 +86,18 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
     				return err
     			}
     
    +			externalClient, err := f.ExternalHttpClient()
    +			if err != nil {
    +				return err
    +			}
    +
     			config := verification.SigstoreConfig{
    -				HttpClient: hc,
    -				Logger:     opts.Logger,
    +				ExternalHttpClient: externalClient,
    +				Logger:             opts.Logger,
     			}
     
     			if ghauth.IsTenancy(opts.Hostname) {
    -				hc, err := f.HttpClient()
    -				if err != nil {
    -					return err
    -				}
    -				apiClient := api.NewLiveClient(hc, opts.Hostname, opts.Logger)
    +				apiClient := api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger)
     				td, err := apiClient.GetTrustDomain()
     				if err != nil {
     					return fmt.Errorf("error getting trust domain, make sure you are authenticated against the host: %w", err)
    
  • pkg/cmd/attestation/trustedroot/trustedroot.go+8 2 modified
    @@ -71,6 +71,12 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
     			if err != nil {
     				return err
     			}
    +
    +			externalClient, err := f.ExternalHttpClient()
    +			if err != nil {
    +				return err
    +			}
    +
     			if ghauth.IsTenancy(opts.Hostname) {
     				c, err := f.Config()
     				if err != nil {
    @@ -81,7 +87,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
     					return fmt.Errorf("not authenticated with %s", opts.Hostname)
     				}
     				logger := io.NewHandler(f.IOStreams)
    -				apiClient := api.NewLiveClient(hc, opts.Hostname, logger)
    +				apiClient := api.NewLiveClient(hc, externalClient, opts.Hostname, logger)
     				td, err := apiClient.GetTrustDomain()
     				if err != nil {
     					return err
    @@ -93,7 +99,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
     				return runF(opts)
     			}
     
    -			if err := getTrustedRoot(tuf.New, opts, hc); err != nil {
    +			if err := getTrustedRoot(tuf.New, opts, externalClient); err != nil {
     				return fmt.Errorf("Failed to verify the TUF repository: %w", err)
     			}
     
    
  • pkg/cmd/attestation/trustedroot/trustedroot_test.go+9 0 modified
    @@ -34,6 +34,9 @@ func TestNewTrustedRootCmd(t *testing.T) {
     			httpmock.ReplaceTripper(client, reg)
     			return client, nil
     		},
    +		ExternalHttpClient: func() (*http.Client, error) {
    +			return nil, nil
    +		},
     	}
     
     	testcases := []struct {
    @@ -120,6 +123,9 @@ func TestNewTrustedRootWithTenancy(t *testing.T) {
     				}, nil
     			},
     			HttpClient: httpClientFunc,
    +			ExternalHttpClient: func() (*http.Client, error) {
    +				return nil, nil
    +			},
     		}
     
     		cmd := NewTrustedRootCmd(f, func(_ *Options) error {
    @@ -148,6 +154,9 @@ func TestNewTrustedRootWithTenancy(t *testing.T) {
     				}, nil
     			},
     			HttpClient: httpClientFunc,
    +			ExternalHttpClient: func() (*http.Client, error) {
    +				return nil, nil
    +			},
     		}
     
     		cmd := NewTrustedRootCmd(f, func(_ *Options) error {
    
  • pkg/cmd/attestation/verification/sigstore.go+6 6 modified
    @@ -31,10 +31,10 @@ type AttestationProcessingResult struct {
     }
     
     type SigstoreConfig struct {
    -	TrustedRoot  string
    -	Logger       *io.Handler
    -	NoPublicGood bool
    -	HttpClient   *http.Client
    +	TrustedRoot        string
    +	Logger             *io.Handler
    +	NoPublicGood       bool
    +	ExternalHttpClient *http.Client
     	// If tenancy mode is not used, trust domain is empty
     	TrustDomain string
     	// TUFMetadataDir
    @@ -76,7 +76,7 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro
     
     	// No custom trusted root is set, so configure Public Good and GitHub verifiers
     	if !config.NoPublicGood {
    -		publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.HttpClient)
    +		publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.ExternalHttpClient)
     		if err != nil {
     			// Log warning but continue - PGI unavailability should not block GitHub attestation verification
     			config.Logger.VerbosePrintf("Warning: failed to initialize Sigstore Public Good verifier: %v\n", err)
    @@ -86,7 +86,7 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro
     		}
     	}
     
    -	github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.HttpClient)
    +	github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.ExternalHttpClient)
     	if err != nil {
     		config.Logger.VerbosePrintf("Warning: failed to initialize GitHub verifier: %v\n", err)
     	} else {
    
  • pkg/cmd/attestation/verify/verify.go+10 5 modified
    @@ -173,6 +173,11 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
     				return err
     			}
     
    +			externalClient, err := f.ExternalHttpClient()
    +			if err != nil {
    +				return err
    +			}
    +
     			opts.OCIClient = oci.NewLiveClient()
     
     			if opts.Hostname == "" {
    @@ -183,13 +188,13 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
     				return err
     			}
     
    -			opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger)
    +			opts.APIClient = api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger)
     
     			config := verification.SigstoreConfig{
    -				HttpClient:   hc,
    -				Logger:       opts.Logger,
    -				NoPublicGood: opts.NoPublicGood,
    -				TrustedRoot:  opts.TrustedRoot,
    +				ExternalHttpClient: externalClient,
    +				Logger:             opts.Logger,
    +				NoPublicGood:       opts.NoPublicGood,
    +				TrustedRoot:        opts.TrustedRoot,
     			}
     
     			// Prepare for tenancy if detected
    
  • pkg/cmd/attestation/verify/verify_test.go+3 0 modified
    @@ -54,6 +54,9 @@ func TestNewVerifyCmd(t *testing.T) {
     			httpmock.ReplaceTripper(client, reg)
     			return client, nil
     		},
    +		ExternalHttpClient: func() (*http.Client, error) {
    +			return nil, nil
    +		},
     	}
     
     	testcases := []struct {
    
  • pkg/cmd/codespace/common.go+1 1 modified
    @@ -82,7 +82,7 @@ type apiClient interface {
     	ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []api.DevContainerEntry, err error)
     	GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error)
     	GetCodespaceBillableOwner(ctx context.Context, nwo string) (*api.User, error)
    -	HTTPClient() (*http.Client, error)
    +	ExternalHTTPClient() (*http.Client, error)
     }
     
     var errNoCodespaces = errors.New("you have no codespaces")
    
  • pkg/cmd/codespace/mock_api.go+37 37 modified
    @@ -26,6 +26,9 @@ import (
     //			EditCodespaceFunc: func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) {
     //				panic("mock out the EditCodespace method")
     //			},
    +//			ExternalHTTPClientFunc: func() (*http.Client, error) {
    +//				panic("mock out the ExternalHTTPClient method")
    +//			},
     //			GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) {
     //				panic("mock out the GetCodespace method")
     //			},
    @@ -53,9 +56,6 @@ import (
     //			GetUserFunc: func(ctx context.Context) (*codespacesAPI.User, error) {
     //				panic("mock out the GetUser method")
     //			},
    -//			HTTPClientFunc: func() (*http.Client, error) {
    -//				panic("mock out the HTTPClient method")
    -//			},
     //			ListCodespacesFunc: func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) {
     //				panic("mock out the ListCodespaces method")
     //			},
    @@ -87,6 +87,9 @@ type apiClientMock struct {
     	// EditCodespaceFunc mocks the EditCodespace method.
     	EditCodespaceFunc func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error)
     
    +	// ExternalHTTPClientFunc mocks the ExternalHTTPClient method.
    +	ExternalHTTPClientFunc func() (*http.Client, error)
    +
     	// GetCodespaceFunc mocks the GetCodespace method.
     	GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error)
     
    @@ -114,9 +117,6 @@ type apiClientMock struct {
     	// GetUserFunc mocks the GetUser method.
     	GetUserFunc func(ctx context.Context) (*codespacesAPI.User, error)
     
    -	// HTTPClientFunc mocks the HTTPClient method.
    -	HTTPClientFunc func() (*http.Client, error)
    -
     	// ListCodespacesFunc mocks the ListCodespaces method.
     	ListCodespacesFunc func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error)
     
    @@ -161,6 +161,9 @@ type apiClientMock struct {
     			// Params is the params argument value.
     			Params *codespacesAPI.EditCodespaceParams
     		}
    +		// ExternalHTTPClient holds details about calls to the ExternalHTTPClient method.
    +		ExternalHTTPClient []struct {
    +		}
     		// GetCodespace holds details about calls to the GetCodespace method.
     		GetCodespace []struct {
     			// Ctx is the ctx argument value.
    @@ -242,9 +245,6 @@ type apiClientMock struct {
     			// Ctx is the ctx argument value.
     			Ctx context.Context
     		}
    -		// HTTPClient holds details about calls to the HTTPClient method.
    -		HTTPClient []struct {
    -		}
     		// ListCodespaces holds details about calls to the ListCodespaces method.
     		ListCodespaces []struct {
     			// Ctx is the ctx argument value.
    @@ -288,6 +288,7 @@ type apiClientMock struct {
     	lockCreateCodespace                sync.RWMutex
     	lockDeleteCodespace                sync.RWMutex
     	lockEditCodespace                  sync.RWMutex
    +	lockExternalHTTPClient             sync.RWMutex
     	lockGetCodespace                   sync.RWMutex
     	lockGetCodespaceBillableOwner      sync.RWMutex
     	lockGetCodespaceRepoSuggestions    sync.RWMutex
    @@ -297,7 +298,6 @@ type apiClientMock struct {
     	lockGetOrgMemberCodespace          sync.RWMutex
     	lockGetRepository                  sync.RWMutex
     	lockGetUser                        sync.RWMutex
    -	lockHTTPClient                     sync.RWMutex
     	lockListCodespaces                 sync.RWMutex
     	lockListDevContainers              sync.RWMutex
     	lockServerURL                      sync.RWMutex
    @@ -425,6 +425,33 @@ func (mock *apiClientMock) EditCodespaceCalls() []struct {
     	return calls
     }
     
    +// ExternalHTTPClient calls ExternalHTTPClientFunc.
    +func (mock *apiClientMock) ExternalHTTPClient() (*http.Client, error) {
    +	if mock.ExternalHTTPClientFunc == nil {
    +		panic("apiClientMock.ExternalHTTPClientFunc: method is nil but apiClient.ExternalHTTPClient was just called")
    +	}
    +	callInfo := struct {
    +	}{}
    +	mock.lockExternalHTTPClient.Lock()
    +	mock.calls.ExternalHTTPClient = append(mock.calls.ExternalHTTPClient, callInfo)
    +	mock.lockExternalHTTPClient.Unlock()
    +	return mock.ExternalHTTPClientFunc()
    +}
    +
    +// ExternalHTTPClientCalls gets all the calls that were made to ExternalHTTPClient.
    +// Check the length with:
    +//
    +//	len(mockedapiClient.ExternalHTTPClientCalls())
    +func (mock *apiClientMock) ExternalHTTPClientCalls() []struct {
    +} {
    +	var calls []struct {
    +	}
    +	mock.lockExternalHTTPClient.RLock()
    +	calls = mock.calls.ExternalHTTPClient
    +	mock.lockExternalHTTPClient.RUnlock()
    +	return calls
    +}
    +
     // GetCodespace calls GetCodespaceFunc.
     func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) {
     	if mock.GetCodespaceFunc == nil {
    @@ -785,33 +812,6 @@ func (mock *apiClientMock) GetUserCalls() []struct {
     	return calls
     }
     
    -// HTTPClient calls HTTPClientFunc.
    -func (mock *apiClientMock) HTTPClient() (*http.Client, error) {
    -	if mock.HTTPClientFunc == nil {
    -		panic("apiClientMock.HTTPClientFunc: method is nil but apiClient.HTTPClient was just called")
    -	}
    -	callInfo := struct {
    -	}{}
    -	mock.lockHTTPClient.Lock()
    -	mock.calls.HTTPClient = append(mock.calls.HTTPClient, callInfo)
    -	mock.lockHTTPClient.Unlock()
    -	return mock.HTTPClientFunc()
    -}
    -
    -// HTTPClientCalls gets all the calls that were made to HTTPClient.
    -// Check the length with:
    -//
    -//	len(mockedapiClient.HTTPClientCalls())
    -func (mock *apiClientMock) HTTPClientCalls() []struct {
    -} {
    -	var calls []struct {
    -	}
    -	mock.lockHTTPClient.RLock()
    -	calls = mock.calls.HTTPClient
    -	mock.lockHTTPClient.RUnlock()
    -	return calls
    -}
    -
     // ListCodespaces calls ListCodespacesFunc.
     func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) {
     	if mock.ListCodespacesFunc == nil {
    
  • pkg/cmd/codespace/ports_test.go+1 1 modified
    @@ -159,7 +159,7 @@ func GetMockApi(allowOrgPorts bool) *apiClientMock {
     		GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
     			return nil, nil
     		},
    -		HTTPClientFunc: func() (*http.Client, error) {
    +		ExternalHTTPClientFunc: func() (*http.Client, error) {
     			return connection.NewMockHttpClient()
     		},
     	}
    
  • pkg/cmd/factory/default.go+11 0 modified
    @@ -34,6 +34,7 @@ func New(appVersion string, invokingAgent string, cfgFunc func() (gh.Config, err
     	f.IOStreams = ios
     	f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler)
     	f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler)
    +	f.ExternalHttpClient = externalHttpClientFunc(ios, appVersion)
     	f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable
     	f.Remotes = remotesFunc(f)    // Depends on Config, and GitClient
     	f.BaseRepo = BaseRepoFunc(f.Remotes)
    @@ -226,6 +227,16 @@ func plainHttpClientFunc(ios *iostreams.IOStreams, appVersion string, invokingAg
     	}
     }
     
    +func externalHttpClientFunc(ios *iostreams.IOStreams, appVersion string) func() (*http.Client, error) {
    +	return func() (*http.Client, error) {
    +		return api.NewExternalHTTPClient(api.ExternalHTTPClientOptions{
    +			AppVersion:  appVersion,
    +			Log:         ios.ErrOut,
    +			LogColorize: ios.ColorEnabled(),
    +		})
    +	}
    +}
    +
     func newGitClient(f *cmdutil.Factory) *git.Client {
     	io := f.IOStreams
     	client := &git.Client{
    
  • pkg/cmd/release/shared/attestation.go+9 9 modified
    @@ -23,10 +23,10 @@ type Verifier interface {
     }
     
     type AttestationVerifier struct {
    -	AttClient   api.Client
    -	HttpClient  *http.Client
    -	IO          *iostreams.IOStreams
    -	TrustedRoot string
    +	AttClient          api.Client
    +	ExternalHttpClient *http.Client
    +	IO                 *iostreams.IOStreams
    +	TrustedRoot        string
     }
     
     func (v *AttestationVerifier) VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) {
    @@ -36,11 +36,11 @@ func (v *AttestationVerifier) VerifyAttestation(art *artifact.DigestedArtifact,
     	}
     
     	verifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
    -		HttpClient:   v.HttpClient,
    -		Logger:       att_io.NewHandler(v.IO),
    -		NoPublicGood: true,
    -		TrustDomain:  td,
    -		TrustedRoot:  v.TrustedRoot,
    +		ExternalHttpClient: v.ExternalHttpClient,
    +		Logger:             att_io.NewHandler(v.IO),
    +		NoPublicGood:       true,
    +		TrustDomain:        td,
    +		TrustedRoot:        v.TrustedRoot,
     	})
     	if err != nil {
     		return nil, err
    
  • pkg/cmd/release/verify-asset/verify_asset.go+10 5 modified
    @@ -83,14 +83,19 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error)
     				return err
     			}
     
    +			externalClient, err := f.ExternalHttpClient()
    +			if err != nil {
    +				return err
    +			}
    +
     			io := f.IOStreams
    -			attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io))
    +			attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io))
     
     			attVerifier := &shared.AttestationVerifier{
    -				AttClient:   attClient,
    -				HttpClient:  httpClient,
    -				IO:          io,
    -				TrustedRoot: opts.TrustedRoot,
    +				AttClient:          attClient,
    +				ExternalHttpClient: externalClient,
    +				IO:                 io,
    +				TrustedRoot:        opts.TrustedRoot,
     			}
     
     			config := &VerifyAssetConfig{
    
  • pkg/cmd/release/verify-asset/verify_asset_test.go+3 0 modified
    @@ -54,6 +54,9 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) {
     				HttpClient: func() (*http.Client, error) {
     					return nil, nil
     				},
    +				ExternalHttpClient: func() (*http.Client, error) {
    +					return nil, nil
    +				},
     				BaseRepo: func() (ghrepo.Interface, error) {
     					return ghrepo.FromFullName("owner/repo")
     				},
    
  • pkg/cmd/release/verify/verify.go+10 5 modified
    @@ -79,14 +79,19 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *co
     				return err
     			}
     
    +			externalClient, err := f.ExternalHttpClient()
    +			if err != nil {
    +				return err
    +			}
    +
     			io := f.IOStreams
    -			attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io))
    +			attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io))
     
     			attVerifier := &shared.AttestationVerifier{
    -				AttClient:   attClient,
    -				HttpClient:  httpClient,
    -				IO:          io,
    -				TrustedRoot: opts.TrustedRoot,
    +				AttClient:          attClient,
    +				ExternalHttpClient: externalClient,
    +				IO:                 io,
    +				TrustedRoot:        opts.TrustedRoot,
     			}
     
     			config := &VerifyConfig{
    
  • pkg/cmd/release/verify/verify_test.go+3 0 modified
    @@ -43,6 +43,9 @@ func TestNewCmdVerify_Args(t *testing.T) {
     				HttpClient: func() (*http.Client, error) {
     					return nil, nil
     				},
    +				ExternalHttpClient: func() (*http.Client, error) {
    +					return nil, nil
    +				},
     				BaseRepo: func() (ghrepo.Interface, error) {
     					return ghrepo.FromFullName("owner/repo")
     				},
    
  • pkg/cmdutil/factory.go+5 1 modified
    @@ -39,5 +39,9 @@ type Factory struct {
     	// auth and other headers. This is meant to be used in situations where the
     	// client needs to specify the headers itself (e.g. during login).
     	PlainHttpClient func() (*http.Client, error)
    -	Remotes         func() (context.Remotes, error)
    +	// ExternalHttpClient is an HTTP client for talking to non-GitHub hosts
    +	// It includes debug logging and a User-Agent header but does not attach any
    +	// authentication tokens or GitHub-specific headers.
    +	ExternalHttpClient func() (*http.Client, error)
    +	Remotes            func() (context.Remotes, error)
     }
    
57480dd7503d

Remove dependency on persistent token

https://github.com/cli/cliWilliam MartinMay 20, 2026Fixed in 2.93.0via ghsa-release-walk
1 file changed · +4 5
  • .github/workflows/detect-spam.yml+4 5 modified
    @@ -4,20 +4,19 @@ on:
         types: [opened]
     
     permissions:
    -  contents: none
    -  issues: write
    -  models: read
    +  contents: read # check out the repo to run the spam-detection scripts.
    +  issues: write # read issue contents (gh issue view), comment, label, and close issues detected as spam.
    +  models: read # run inference via `gh models run` for spam classification.
     
     jobs:
       issue-spam:
         runs-on: ubuntu-latest
    -    environment: cli-automation
         steps:
           - name: Checkout repository
             uses: actions/checkout@v6
           - name: Run spam detection
             env:
    -          GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }}
    +          GH_TOKEN: ${{ github.token }}
               ISSUE_URL: ${{ github.event.issue.html_url }}
             run: |
               ./.github/workflows/scripts/spam-detection/process-issue.sh "$ISSUE_URL"
    

Vulnerability mechanics

Root cause

"The shared HTTP client's host normalization logic collapses any `*.github.com` subdomain to `github.com`, causing authentication tokens to be sent to non-API hosts, and falls back to enterprise tokens for any unrecognized host."

Attack vector

An attacker who controls or can observe traffic to `tuf-repo.github.com`, `tuf-repo-cdn.sigstore.dev`, or `tmaproduction.blob.core.windows.net` could capture the authorization header sent by an authenticated GitHub CLI user when they run `gh attestation`, `gh release verify`, or `gh release verify-asset`. The CLI's shared HTTP client automatically attaches the user's github.com token to requests to `tuf-repo.github.com` because the host normalization logic treats any `*.github.com` subdomain as `github.com`. For the other two external hosts, the client falls back to `GH_ENTERPRISE_TOKEN` or `GITHUB_ENTERPRISE_TOKEN` if those environment variables are set. No authentication or special network position is required beyond being able to observe or intercept the HTTP traffic to those hosts.

Affected code

The vulnerability is in the shared HTTP client authentication layer used by `gh attestation`, `gh release verify`, and `gh release verify-asset` commands. The host normalization logic collapses any `*.github.com` subdomain to `github.com`, causing requests to `tuf-repo.github.com` (a GitHub Pages site) to receive the user's github.com token. For non-GitHub hosts, the resolver falls back to `GH_ENTERPRISE_TOKEN` if set. The fix introduces a separate external HTTP client (`NewExternalHTTPClient`) that does not attach authentication tokens or GitHub-specific headers, and wires it through `api.NewLiveClient`, `SigstoreConfig.ExternalHttpClient`, and `Factory.ExternalHttpClient` [patch_id=3101455].

What the fix does

The fix introduces `NewExternalHTTPClient` in `api/http_client.go` which creates an HTTP client that explicitly sets `AuthToken: "none"` and `SkipDefaultHeaders: true`, ensuring no authorization or GitHub-specific headers are attached to requests to non-GitHub hosts [patch_id=3101455]. This external client is then passed through the call chain: `Factory.ExternalHttpClient` → `api.NewLiveClient` (which stores it as `externalHttpClient`) → used in `getBundle` for fetching artifact bundles from Azure Blob Storage, and into `SigstoreConfig.ExternalHttpClient` for TUF metadata fetches. The old `HttpClient` field on `SigstoreConfig` is renamed to `ExternalHttpClient` to make the distinction clear. Patch 3101454 updates integration tests to use the renamed field and pass `http.DefaultClient` as the external client argument.

Preconditions

  • authUser must be authenticated to github.com (any auth type) or have GH_ENTERPRISE_TOKEN / GITHUB_ENTERPRISE_TOKEN set
  • inputUser must run one of the affected commands: gh attestation, gh release verify, or gh release verify-asset
  • networkThe external hosts (tuf-repo.github.com, tuf-repo-cdn.sigstore.dev, tmaproduction.blob.core.windows.net) must be reachable

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.