From 27e5aba60e5569b3a5d740e576e31da0a2033071 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 26 Sep 2026 19:29:57 +0000 Subject: [PATCH] fix(failover): do not follow redirects in remote probes Go resends custom headers such as x-api-key when it follows a redirect, also to another host, so a redirecting upstream could receive the target's API key elsewhere. The probe client now treats a redirect as the response, which fails the probe as a non-2xx status. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto --- core/services/failover/prober.go | 7 ++++++- core/services/failover/prober_test.go | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/core/services/failover/prober.go b/core/services/failover/prober.go index c6d412030..9a407e8a0 100644 --- a/core/services/failover/prober.go +++ b/core/services/failover/prober.go @@ -37,7 +37,12 @@ type DefaultProber struct { } func NewProber(loaded LoadedFunc) *DefaultProber { - return &DefaultProber{HTTP: &http.Client{}, Loaded: loaded} + return &DefaultProber{HTTP: &http.Client{ + // A redirect is a failed probe, not something to follow: Go resends + // custom headers such as x-api-key to any host, and the target's + // API key must reach only the configured upstream. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, Loaded: loaded} } func (p *DefaultProber) Liveness(ctx context.Context, cfg config.ModelConfig, kind Kind, warm bool) error { diff --git a/core/services/failover/prober_test.go b/core/services/failover/prober_test.go index b557c4f96..842934876 100644 --- a/core/services/failover/prober_test.go +++ b/core/services/failover/prober_test.go @@ -125,6 +125,23 @@ var _ = Describe("DefaultProber", func() { Expect(up.auth).To(Equal("Bearer sekret")) }) + It("does not follow a redirect, so the API key never leaves the upstream", func() { + GinkgoT().Setenv("FAILOVER_PROBE_KEY", "sekret") + other := newFakeUpstream() + DeferCleanup(other.srv.Close) + other.models = []string{"argus-llm"} + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.srv.URL+r.URL.Path, http.StatusFound) + })) + DeferCleanup(redirect.Close) + c := proxied("argus-llm", "") + c.Proxy.UpstreamURL = redirect.URL + "/v1/chat/completions" + c.Proxy.APIKeyEnv = "FAILOVER_PROBE_KEY" + c.Proxy.Provider = config.ProxyProviderAnthropic + Expect(p.Liveness(ctx, c, KindRemote, false)).To(MatchError(ContainSubstring("302"))) + Expect(other.paths).To(BeEmpty()) + }) + DescribeTable("remote inference hits the usecase endpoint", func(usecase, path string) { Expect(p.Inference(ctx, proxied("m", "", usecase), KindRemote, false)).To(Succeed())