Skip to content

Commit dc1f38c

Browse files
authored
fix(sonar): never send the API token to a redirect target (#1170)
* fix(sonar): never send the API token to a redirect target kosli attest sonar authenticates through a transport that attaches the Sonar API token to every request it sends. That includes the requests Go issues while following redirects, so the stdlib's own cross-host header stripping never applied: a Sonar host that 302s to another host handed the token to that host. Go's default of ten hops made the window wide. The client now sets a CheckRedirect policy that refuses a redirect to a different host or from https to http, and stops after five hops. Hosts are compared lowercased with the scheme's default port removed, so a reverse proxy emitting an explicit :443 in Location still counts as the same host. Same-host redirects (a path rewrite, http -> https on the same host) still work and stay authenticated. Refusing beats following without the token: every SonarQube endpoint needs it, so an unauthenticated hop would only fail later with a less useful error. The refusal names the usual causes (an SSO or proxy login redirect, a non-canonical server URL) and the setting to change. The client also gets a 60 second deadline covering the whole chain, redirects and the Basic retry included. The hop cap bounds how many redirects are followed, the deadline bounds how long they may take, so a host that accepts the connection and never answers cannot hold the run. SonarQube API responses are small JSON documents, so a healthy server is nowhere near it. With same-host redirects now a first-class path, the Bearer/Basic probe no longer treats a 3xx as proof that Bearer worked. It used to cache Bearer on any non-401, so a Server < 10.0 that redirected /api/ce/task to /api/ce/task/ got a 401 on the second hop with no Basic fallback. A redirect is now returned undecided and the final response settles the scheme. Fixes kosli-dev/server#6880 * test(sonar): release the parked handler before the test server closes `t.Cleanup` runs LIFO and `httptest.Server.Close` blocks until every handler returns, so registering the release before the server meant Close ran first while /hang was still parked. The test only finished because the server cancels the request context when the timed-out client drops the connection, which is the behaviour under test. Registering the release after the server removes that dependency. * docs(sonar): say the 3xx guard also returns 300 and 304 undecided The comment described only the redirects the client follows. The guard also catches 300 and 304, which are handed straight back to the caller with the scheme still undecided.
1 parent 11306cd commit dc1f38c

3 files changed

Lines changed: 334 additions & 2 deletions

File tree

internal/sonar/auth.go

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"fmt"
66
"io"
77
"net/http"
8+
"net/url"
89
"strings"
910
"sync"
11+
"time"
1012
)
1113

1214
// authScheme selects how the SonarQube API token is presented to the server.
@@ -109,6 +111,11 @@ func (a *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
109111
if err != nil {
110112
return nil, err
111113
}
114+
// A 3xx says nothing about the scheme: leave it undecided and let the next hop
115+
// (or the caller, for 300/304) decide.
116+
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
117+
return resp, nil
118+
}
112119
if resp.StatusCode == http.StatusUnauthorized {
113120
drainAndClose(resp)
114121
resp, err = a.send(req, schemeBasic)
@@ -139,12 +146,58 @@ func drainAndClose(resp *http.Response) {
139146
_ = resp.Body.Close()
140147
}
141148

149+
const (
150+
// Go follows ten by default; no real SonarQube deployment needs more than a few.
151+
maxSonarRedirects = 5
152+
153+
// Total deadline per request, redirects and the Basic retry included. Sonar
154+
// responses are small JSON documents, so a healthy server is nowhere near it.
155+
sonarClientTimeout = 60 * time.Second
156+
)
157+
158+
// sonarRedirectPolicy refuses redirects that would carry the API token to another
159+
// host or onto plain http. authTransport re-attaches the token on every hop, so the
160+
// stdlib's cross-host header stripping never applies; and following without the
161+
// token would only fail later, since every SonarQube endpoint needs it.
162+
func sonarRedirectPolicy(req *http.Request, via []*http.Request) error {
163+
// via includes the initial request.
164+
if len(via) > maxSonarRedirects {
165+
return fmt.Errorf("stopped after %d redirects", maxSonarRedirects)
166+
}
167+
prev := via[len(via)-1].URL
168+
if canonicalHost(req.URL) != canonicalHost(prev) {
169+
return fmt.Errorf("cross-host redirect from %s to %s refused: the SonarQube API token is only sent to the configured host.\n"+
170+
"This usually means SonarQube redirected an unauthenticated request to a login page, or the configured server URL is not the instance's canonical URL. "+
171+
"If %s is the SonarQube API, point --sonar-server-url (or the scanner's sonar.host.url, which report-task.txt records) at it directly",
172+
prev.Host, req.URL.Host, req.URL.Host)
173+
}
174+
if prev.Scheme == "https" && req.URL.Scheme != "https" {
175+
return fmt.Errorf("redirect from https to http on %s refused: the SonarQube API token would be sent in plain text", prev.Host)
176+
}
177+
return nil
178+
}
179+
180+
// canonicalHost lowercases the hostname and drops the scheme's default port, so an
181+
// explicit :443 in a Location header is still the same host.
182+
func canonicalHost(u *url.URL) string {
183+
host := strings.ToLower(u.Hostname())
184+
port := u.Port()
185+
if port == "" || (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
186+
return host
187+
}
188+
return host + ":" + port
189+
}
190+
142191
// newAuthedClient builds an HTTP client that authenticates SonarQube requests with
143192
// the given token, presenting it as Bearer (SonarQube Cloud and Server >= 10.0) and
144193
// falling back to Basic for a self-hosted Server < 10.0. The token is trimmed of
145194
// surrounding whitespace (e.g. a trailing newline from a secret file).
146195
func newAuthedClient(token string, mode authScheme) *http.Client {
147-
return &http.Client{Transport: &authTransport{token: strings.TrimSpace(token), mode: mode}}
196+
return &http.Client{
197+
Transport: &authTransport{token: strings.TrimSpace(token), mode: mode},
198+
CheckRedirect: sonarRedirectPolicy,
199+
Timeout: sonarClientTimeout,
200+
}
148201
}
149202

150203
// sonarResponseError turns a SonarQube response that could not be parsed as the

internal/sonar/auth_internal_test.go

Lines changed: 256 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
package sonar
22

3-
import "testing"
3+
import (
4+
"errors"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"strings"
9+
"sync/atomic"
10+
"testing"
11+
"time"
12+
)
413

514
func TestIsSonarCloudHost(t *testing.T) {
615
cases := []struct {
@@ -24,3 +33,249 @@ func TestIsSonarCloudHost(t *testing.T) {
2433
}
2534
}
2635
}
36+
37+
// The client-level tests below run on 127.0.0.1 and differ only by port, so
38+
// hostname, case and default-port handling are pinned here.
39+
func TestSonarRedirectPolicy(t *testing.T) {
40+
cases := []struct {
41+
name string
42+
prev string
43+
next string
44+
wantErr string // "" means the redirect is followed
45+
}{
46+
{"different hostname", "https://sonar.example.com/x", "https://evil.example.com/x", "cross-host redirect"},
47+
{"subdomain of the configured host", "https://sonar.example.com/x", "https://api.sonar.example.com/x", "cross-host redirect"},
48+
{"different port", "https://sonar.example.com/x", "https://sonar.example.com:8443/x", "cross-host redirect"},
49+
{"https to http downgrade", "https://sonar.example.com/x", "http://sonar.example.com/x", "https to http"},
50+
{"hostname differs only by case", "https://sonar.example.com/x", "https://SONAR.Example.com/y", ""},
51+
{"explicit default https port added", "https://sonar.example.com/x", "https://sonar.example.com:443/x", ""},
52+
{"explicit default http port dropped", "http://sonar.example.com:80/x", "http://sonar.example.com/x", ""},
53+
{"http to https upgrade", "http://sonar.example.com/x", "https://sonar.example.com/x", ""},
54+
{"path rewrite", "https://sonar.example.com/old", "https://sonar.example.com/new", ""},
55+
}
56+
for _, c := range cases {
57+
t.Run(c.name, func(t *testing.T) {
58+
err := sonarRedirectPolicy(mustRequest(t, c.next), []*http.Request{mustRequest(t, c.prev)})
59+
switch {
60+
case c.wantErr == "" && err != nil:
61+
t.Errorf("expected the redirect to be followed, got: %v", err)
62+
case c.wantErr != "" && err == nil:
63+
t.Errorf("expected the redirect to be refused with %q, got nil", c.wantErr)
64+
case c.wantErr != "" && !strings.Contains(err.Error(), c.wantErr):
65+
t.Errorf("expected error containing %q, got: %v", c.wantErr, err)
66+
}
67+
})
68+
}
69+
}
70+
71+
func TestSonarRedirectPolicy_LimitMessageMatchesRedirectsFollowed(t *testing.T) {
72+
via := make([]*http.Request, 0, maxSonarRedirects+1)
73+
for range maxSonarRedirects + 1 {
74+
via = append(via, mustRequest(t, "https://sonar.example.com/loop"))
75+
}
76+
err := sonarRedirectPolicy(mustRequest(t, "https://sonar.example.com/loop"), via)
77+
if err == nil || !strings.Contains(err.Error(), "stopped after 5 redirects") {
78+
t.Errorf("expected the limit error to name %d redirects, got: %v", maxSonarRedirects, err)
79+
}
80+
if err := sonarRedirectPolicy(mustRequest(t, "https://sonar.example.com/loop"), via[:maxSonarRedirects]); err != nil {
81+
t.Errorf("expected the %dth redirect to still be followed, got: %v", maxSonarRedirects, err)
82+
}
83+
}
84+
85+
type authRecorder struct {
86+
hits atomic.Int32
87+
auth atomic.Value
88+
}
89+
90+
func (r *authRecorder) handler() http.HandlerFunc {
91+
return func(w http.ResponseWriter, req *http.Request) {
92+
r.auth.Store(req.Header.Get("Authorization"))
93+
r.hits.Add(1)
94+
w.WriteHeader(http.StatusOK)
95+
}
96+
}
97+
98+
func newTestServer(t *testing.T, h http.Handler) *httptest.Server {
99+
t.Helper()
100+
srv := httptest.NewServer(h)
101+
t.Cleanup(srv.Close)
102+
return srv
103+
}
104+
105+
// server#6880: the transport re-attaches the token on every hop, so a cross-host
106+
// redirect must not be followed at all.
107+
func TestAuthedClient_CrossHostRedirect_DoesNotLeakToken(t *testing.T) {
108+
target := &authRecorder{}
109+
targetSrv := newTestServer(t, target.handler())
110+
sonarSrv := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111+
http.Redirect(w, r, targetSrv.URL+"/api/ce/task", http.StatusFound)
112+
}))
113+
114+
client := newAuthedClient("SUPER-SECRET-SONAR-TOKEN", schemeBearer)
115+
resp, err := client.Get(sonarSrv.URL + "/api/ce/task")
116+
if err == nil {
117+
drainAndClose(resp)
118+
}
119+
if n := target.hits.Load(); n != 0 {
120+
t.Fatalf("redirect target must never be contacted, got %d request(s) with Authorization %q", n, target.auth.Load())
121+
}
122+
if err == nil {
123+
t.Fatal("expected the cross-host redirect to be refused, got a response")
124+
}
125+
if !strings.Contains(err.Error(), "cross-host redirect") {
126+
t.Errorf("expected a cross-host redirect error, got: %v", err)
127+
}
128+
}
129+
130+
func TestAuthedClient_SameHostRedirect_IsFollowedWithToken(t *testing.T) {
131+
target := &authRecorder{}
132+
mux := http.NewServeMux()
133+
mux.Handle("/new", target.handler())
134+
mux.HandleFunc("/old", func(w http.ResponseWriter, r *http.Request) {
135+
http.Redirect(w, r, "/new", http.StatusFound)
136+
})
137+
srv := newTestServer(t, mux)
138+
139+
client := newAuthedClient("tok", schemeBearer)
140+
resp, err := client.Get(srv.URL + "/old")
141+
if err != nil {
142+
t.Fatalf("expected the same-host redirect to be followed, got: %v", err)
143+
}
144+
drainAndClose(resp)
145+
if resp.StatusCode != http.StatusOK {
146+
t.Fatalf("expected 200 from the redirect target, got %d", resp.StatusCode)
147+
}
148+
if got := target.auth.Load(); got != bearerHeaderValue("tok") {
149+
t.Errorf("expected the redirected request to carry the Bearer token, got %q", got)
150+
}
151+
}
152+
153+
func TestAuthedClient_RedirectThenUnauthorized_StillFallsBackToBasic(t *testing.T) {
154+
target := &authRecorder{}
155+
mux := http.NewServeMux()
156+
mux.HandleFunc("/api/ce/task", func(w http.ResponseWriter, r *http.Request) {
157+
http.Redirect(w, r, "/api/ce/task/", http.StatusFound)
158+
})
159+
mux.HandleFunc("/api/ce/task/", func(w http.ResponseWriter, r *http.Request) {
160+
// Server < 10.0 accepts only Basic.
161+
if !strings.HasPrefix(r.Header.Get("Authorization"), "Basic ") {
162+
w.WriteHeader(http.StatusUnauthorized)
163+
return
164+
}
165+
target.handler()(w, r)
166+
})
167+
srv := newTestServer(t, mux)
168+
169+
client := newAuthedClient("tok", schemeAuto)
170+
resp, err := client.Get(srv.URL + "/api/ce/task")
171+
if err != nil {
172+
t.Fatalf("expected the redirected request to succeed via Basic, got: %v", err)
173+
}
174+
drainAndClose(resp)
175+
if resp.StatusCode != http.StatusOK {
176+
t.Fatalf("expected 200 after the Basic fallback on the redirected hop, got %d", resp.StatusCode)
177+
}
178+
if got := target.auth.Load(); got != basicHeaderValue("tok") {
179+
t.Errorf("expected the redirected request to be retried with Basic, got %q", got)
180+
}
181+
}
182+
183+
func TestAuthedClient_RedirectLoop_StopsAtLimit(t *testing.T) {
184+
var hits atomic.Int32
185+
srv := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
186+
hits.Add(1)
187+
http.Redirect(w, r, "/loop", http.StatusFound)
188+
}))
189+
190+
client := newAuthedClient("tok", schemeBearer)
191+
resp, err := client.Get(srv.URL + "/loop")
192+
if err == nil {
193+
drainAndClose(resp)
194+
t.Fatal("expected the redirect loop to be refused, got a response")
195+
}
196+
if !strings.Contains(err.Error(), "redirects") {
197+
t.Errorf("expected a redirect-limit error, got: %v", err)
198+
}
199+
if n := hits.Load(); n != maxSonarRedirects+1 {
200+
t.Errorf("expected the initial request plus %d followed redirects, got %d requests", maxSonarRedirects, n)
201+
}
202+
}
203+
204+
func TestAuthedClient_HangingRedirectTarget_TimesOut(t *testing.T) {
205+
release := make(chan struct{})
206+
mux := http.NewServeMux()
207+
mux.HandleFunc("/old", func(w http.ResponseWriter, r *http.Request) {
208+
http.Redirect(w, r, "/hang", http.StatusFound)
209+
})
210+
mux.HandleFunc("/hang", func(w http.ResponseWriter, r *http.Request) {
211+
select {
212+
case <-release:
213+
case <-r.Context().Done():
214+
}
215+
})
216+
srv := newTestServer(t, mux)
217+
// Cleanups run LIFO: release the parked handler before srv.Close, which waits for it.
218+
t.Cleanup(func() { close(release) })
219+
220+
client := newAuthedClient("tok", schemeBearer)
221+
if client.Timeout != sonarClientTimeout {
222+
t.Fatalf("expected the client deadline to be %v, got %v", sonarClientTimeout, client.Timeout)
223+
}
224+
client.Timeout = 200 * time.Millisecond // keep the test fast; the wiring is asserted above
225+
226+
start := time.Now()
227+
resp, err := client.Get(srv.URL + "/old")
228+
if err == nil {
229+
drainAndClose(resp)
230+
t.Fatal("expected the hanging redirect target to time out, got a response")
231+
}
232+
var urlErr *url.Error
233+
if !errors.As(err, &urlErr) || !urlErr.Timeout() {
234+
t.Errorf("expected a timeout error, got: %v", err)
235+
}
236+
if elapsed := time.Since(start); elapsed > 5*time.Second {
237+
t.Errorf("expected the deadline to cut the request short, waited %v", elapsed)
238+
}
239+
}
240+
241+
// GetCETaskData reuses one *http.Request across polls. http.Client forks the request
242+
// before attaching its deadline, so the caller's request stays usable; pin that for
243+
// authTransport, which is not a *http.Transport.
244+
func TestAuthedClient_ReusedRequestAcrossPolls_IsNotCancelledByTimeout(t *testing.T) {
245+
var polls atomic.Int32
246+
srv := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247+
polls.Add(1)
248+
_, _ = w.Write([]byte(`{"task":{"status":"PENDING"}}`))
249+
}))
250+
251+
client := newAuthedClient("tok", schemeAuto)
252+
if client.Timeout == 0 {
253+
t.Fatal("this test only means something with a client deadline set")
254+
}
255+
req := mustRequest(t, srv.URL+"/api/ce/task?id=AYx")
256+
for i := 1; i <= 3; i++ {
257+
resp, err := client.Do(req)
258+
if err != nil {
259+
t.Fatalf("poll %d on the reused request failed: %v", i, err)
260+
}
261+
drainAndClose(resp)
262+
if resp.StatusCode != http.StatusOK {
263+
t.Fatalf("poll %d: expected 200, got %d", i, resp.StatusCode)
264+
}
265+
}
266+
if n := polls.Load(); n != 3 {
267+
t.Errorf("expected 3 polls to reach the server, got %d", n)
268+
}
269+
if err := req.Context().Err(); err != nil {
270+
t.Errorf("the caller's request context must stay live across polls, got: %v", err)
271+
}
272+
}
273+
274+
func mustRequest(t *testing.T, rawURL string) *http.Request {
275+
t.Helper()
276+
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
277+
if err != nil {
278+
t.Fatal(err)
279+
}
280+
return req
281+
}

internal/sonar/sonar_auth_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,27 @@ func TestGetSonarResults_Forbidden_NonJSON_RendersActualStatus(t *testing.T) {
325325
}
326326
}
327327
}
328+
329+
// End-to-end check for server#6880 through GetSonarResults.
330+
func TestGetSonarResults_CrossHostRedirect_TokenStaysOnConfiguredHost(t *testing.T) {
331+
target := &fakeSonar{acceptsBearer: true, acceptsBasic: true}
332+
targetSrv := httptest.NewServer(target.handler())
333+
defer targetSrv.Close()
334+
335+
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
336+
http.Redirect(w, r, targetSrv.URL+r.URL.RequestURI(), http.StatusFound)
337+
}))
338+
defer redirector.Close()
339+
340+
sc := sonar.NewSonarConfig("SUPER-SECRET-SONAR-TOKEN", t.TempDir(), redirector.URL+"/api/ce/task?id=AYx", "", "", "", "", "", 5)
341+
_, err := sc.GetSonarResults(discardLogger())
342+
if got := target.authHeaders(); len(got) != 0 {
343+
t.Fatalf("the redirect target must never receive a request, got Authorization headers %v", got)
344+
}
345+
if err == nil {
346+
t.Fatal("expected an error when the SonarQube host redirects to another host")
347+
}
348+
if !strings.Contains(err.Error(), "cross-host redirect") {
349+
t.Errorf("expected a cross-host redirect error, got: %v", err)
350+
}
351+
}

0 commit comments

Comments
 (0)