Skip to content

Commit 2b80dfd

Browse files
reject plain text source requests before allowing redirects
1 parent 646d2cc commit 2b80dfd

4 files changed

Lines changed: 66 additions & 1 deletion

File tree

‎internal/handlers/egress_allowlist.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ func (h *EgressAllowlistHandler) HandleResponse(rsp *http.Response, proxyCtx *go
161161
if rsp == nil || proxyCtx == nil || proxyCtx.Req == nil || !isRedirectStatus(rsp.StatusCode) {
162162
return rsp
163163
}
164+
// A plaintext response could be forged by anything on the network path, so
165+
// only a redirect received over TLS from the registry is trusted.
166+
if !strings.EqualFold(proxyCtx.Req.URL.Scheme, "https") {
167+
return rsp
168+
}
164169
source := strings.TrimSuffix(helpers.GetHost(proxyCtx.Req), ".")
165170
if !h.isRedirectSource(source) {
166171
return rsp

‎internal/handlers/egress_allowlist_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1652,6 +1652,7 @@ func TestEgressAllowlist_RedirectTargetNotLearned(t *testing.T) {
16521652
location string
16531653
}{
16541654
{"source is not a credential host", "https://unconfigured.example.com/x", http.StatusFound, target},
1655+
{"plaintext source request", "http://packages.example.com/x", http.StatusFound, target},
16551656
{"source is a statically allowed credential host", "https://github.com/x", http.StatusFound, target},
16561657
{"public registry credential host", "https://registry.npmjs.org/x", http.StatusFound, target},
16571658
{"not a redirect status", "https://packages.example.com/x", http.StatusOK, target},

‎proxy.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ func newProxyWithCacheDir(envSettings config.ProxyEnvSettings, cfg *config.Confi
7777

7878
egressAllowlistHandler := handlers.NewEgressAllowlistHandler(cfg, envSettings, metricsClient)
7979
proxy.OnRequest().DoFunc(egressAllowlistHandler.HandleRequest)
80-
proxy.OnResponse().DoFunc(egressAllowlistHandler.HandleResponse)
8180

8281
enableCache := os.Getenv("PROXY_CACHE") == "true"
8382
cacher, err := cache.New(enableCache, cacheDir)
@@ -152,6 +151,9 @@ func newProxyWithCacheDir(envSettings config.ProxyEnvSettings, cfg *config.Confi
152151
helmRegistryHandler := handlers.NewHelmRegistryHandler(cfg.Credentials, oidcClient)
153152
proxy.OnRequest().DoFunc(helmRegistryHandler.HandleRequest)
154153

154+
// Runs after every handler that can replace a response with an
155+
// authentication retry, so a redirect returned by the retry is learned.
156+
proxy.OnResponse().DoFunc(egressAllowlistHandler.HandleResponse)
155157
proxy.OnResponse().DoFunc(cacher.OnResponse)
156158

157159
return &Proxy{

‎proxy_test.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,63 @@ func TestProxyEgressAllowlistEnforceAllowsPrivateRegistryRedirectTarget(t *testi
390390
assert.Equal(t, http.StatusForbidden, status, "other unlisted hosts stay blocked")
391391
}
392392

393+
// The git server handler retries an auth failure with an alternate credential
394+
// and replaces the response. A redirect returned by that retry must still
395+
// allowlist its target.
396+
func TestProxyEgressAllowlistLearnsRedirectFromAuthRetry(t *testing.T) {
397+
t.Setenv("PROXY_CACHE", "false")
398+
const bucketURL = "https://bucket.example.com/archive.tar.gz?X-Amz-Signature=abc"
399+
var rejected atomic.Int32
400+
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
401+
switch r.Host {
402+
case "git.example.com":
403+
if _, password, _ := r.BasicAuth(); password != "good" {
404+
rejected.Add(1)
405+
w.WriteHeader(http.StatusUnauthorized)
406+
return
407+
}
408+
http.Redirect(w, r, bucketURL, http.StatusFound)
409+
case "bucket.example.com":
410+
_, _ = io.WriteString(w, "archive")
411+
default:
412+
w.WriteHeader(http.StatusTeapot)
413+
}
414+
}))
415+
defer upstream.Close()
416+
417+
cfg := &config.Config{
418+
CA: testProxyConfig.CA,
419+
Experiments: config.Experiments{"proxy-egress-enforce": true},
420+
Credentials: config.Credentials{
421+
{"type": "git_source", "host": "git.example.com", "username": "x-access-token", "password": "bad"},
422+
{"type": "git_source", "host": "git.example.com", "username": "x-access-token", "password": "good"},
423+
},
424+
}
425+
client, proxy := testProxyServer(t, cfg, nil, upstream.Certificate())
426+
closeOnCleanup(t, proxy)
427+
t.Cleanup(client.CloseIdleConnections)
428+
proxyHandler := proxy.Handler.(*Proxy)
429+
t.Cleanup(proxyHandler.Tr.CloseIdleConnections)
430+
proxyHandler.Tr.Proxy = nil
431+
proxyHandler.Tr.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
432+
return (&net.Dialer{}).DialContext(ctx, network, upstream.Listener.Addr().String())
433+
}
434+
435+
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://git.example.com/org/repo/archive/v1.tar.gz", nil)
436+
require.NoError(t, err)
437+
rsp, err := client.Do(req)
438+
require.NoError(t, err)
439+
defer func() {
440+
require.NoError(t, rsp.Body.Close())
441+
}()
442+
body, err := io.ReadAll(rsp.Body)
443+
require.NoError(t, err)
444+
445+
require.Positive(t, rejected.Load(), "the first credential must fail so the retry path is exercised")
446+
assert.Equal(t, http.StatusOK, rsp.StatusCode)
447+
assert.Equal(t, "archive", string(body))
448+
}
449+
393450
func TestProxyHTTPSMITMFixedLengthResponseFraming(t *testing.T) {
394451
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
395452
w.Header().Set("Content-Length", "5")

0 commit comments

Comments
 (0)