From 30009599e4e5ffc06451a0ea46d1ca12b7211050 Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Wed, 9 Sep 2026 11:25:43 +0100 Subject: [PATCH] b2: fix account token and SSE-C key leaking on redirects from --b2-download-url GHSA-gpw7-77cq-9f72 CVE-PENDING Every file download sends the live account authorization token and, when configured, the SSE-C customer key headers to the download host. With --b2-download-url that host is a CDN or proxy the user does not operate, and the http client followed its redirects with Go's default policy: - Go only strips Authorization when the redirect target is not the same host or a subdomain, and never looks at the scheme, so a same-host HTTPS to HTTP redirect replayed the full-capability B2 token in cleartext. - Go has no idea that the X-Bz-Server-Side-Encryption-Customer-* headers carry the customer encryption key, so it forwarded them to any redirect target including a different host. The B2 client now uses rest.StripHeadersOnCrossHostRedirectFn, so it refuses to follow a redirect which downgrades HTTPS to HTTP and strips the Authorization and SSE-C headers from any redirect which leaves the host and port of the original request, matching the protection already given to the S3, WebDAV and HTTP backends. Fixes GHSA-gpw7-77cq-9f72 --- backend/b2/b2.go | 26 ++++++++++++++- backend/b2/b2_internal_test.go | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/backend/b2/b2.go b/backend/b2/b2.go index 22c5e079a..1b2515ed9 100644 --- a/backend/b2/b2.go +++ b/backend/b2/b2.go @@ -191,6 +191,11 @@ If the custom endpoint rewrites the requests for authentication, e.g., in Cloudflare Workers, this header needs to be handled properly. Leave blank if you want to use the endpoint provided by Backblaze. +Rclone will not follow a redirect from this endpoint which downgrades +HTTPS to HTTP, and will not send the "Authorization" header or the +SSE-C key headers once a redirect has left the host and port of this +endpoint. + The URL provided here SHOULD have the protocol and SHOULD NOT have a trailing slash or specify the /file/bucket subpath as rclone will request files with "{download_url}/file/{bucket_name}/{path}". @@ -529,6 +534,25 @@ func (f *Fs) setRoot(root string) { f.rootBucket, f.rootDirectory = bucket.Split(f.root) } +// redirectSecretHeaders are the request headers carrying secrets that +// must not be forwarded when a redirect leaves the original host. +var redirectSecretHeaders = []string{ + "Authorization", + sseAlgorithmHeader, + sseKeyHeader, + sseMd5Header, +} + +// newClient makes the http client used for all B2 requests. +// +// The download_url option sends the requests to a host the user does +// not control so it strips headers on cross host redirects. +func newClient(ctx context.Context) *http.Client { + client := fshttp.NewClient(ctx) + client.CheckRedirect = rest.StripHeadersOnCrossHostRedirectFn(redirectSecretHeaders...) + return client +} + // NewFs constructs an Fs from the path, bucket:path func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) { // Parse config into Options struct @@ -581,7 +605,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e name: name, opt: *opt, ci: ci, - srv: rest.NewClient(fshttp.NewClient(ctx)).SetErrorHandler(errorHandler), + srv: rest.NewClient(newClient(ctx)).SetErrorHandler(errorHandler), cache: bucket.NewCache(), _bucketID: make(map[string]string, 1), _bucketType: make(map[string]string, 1), diff --git a/backend/b2/b2_internal_test.go b/backend/b2/b2_internal_test.go index 2579928be..f4de7c560 100644 --- a/backend/b2/b2_internal_test.go +++ b/backend/b2/b2_internal_test.go @@ -4,6 +4,8 @@ import ( "context" "crypto/sha1" "fmt" + "net/http" + "net/http/httptest" "path" "sort" "strings" @@ -617,3 +619,60 @@ func (f *Fs) InternalTest(t *testing.T) { } var _ fstests.InternalTester = (*Fs)(nil) + +// redirectTestHeaders is a deliberately literal copy of +// redirectSecretHeaders so that a header dropped from the production +// list fails TestRedirectSecretHeaders rather than silently losing +// test coverage. +var redirectTestHeaders = map[string]string{ + "Authorization": "secret-token", + "X-Bz-Server-Side-Encryption-Customer-Algorithm": "AES256", + "X-Bz-Server-Side-Encryption-Customer-Key": "secret-key", + "X-Bz-Server-Side-Encryption-Customer-Key-Md5": "secret-md5", +} + +func TestRedirectSecretHeaders(t *testing.T) { + want := make([]string, 0, len(redirectTestHeaders)) + for header := range redirectTestHeaders { + want = append(want, header) + } + sort.Strings(want) + got := append([]string(nil), redirectSecretHeaders...) + sort.Strings(got) + assert.Equal(t, want, got) +} + +// redirectTestRequest makes a GET request to rawURL carrying the +// secret headers plus a harmless marker header. +func redirectTestRequest(t *testing.T, rawURL string) *http.Request { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + require.NoError(t, err) + for header, value := range redirectTestHeaders { + req.Header.Set(header, value) + } + req.Header.Set("X-Bz-Test-Mode", "marker") + return req +} + +func TestClientStripsSecretHeadersOnCrossHostRedirect(t *testing.T) { + client := newClient(context.Background()) + + redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for header := range redirectTestHeaders { + assert.Empty(t, r.Header.Get(header), "%s should have been stripped", header) + } + assert.Equal(t, "marker", r.Header.Get("X-Bz-Test-Mode")) + w.WriteHeader(http.StatusOK) + })) + defer redirectServer.Close() + + initialServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, redirectServer.URL, http.StatusTemporaryRedirect) + })) + defer initialServer.Close() + + resp, err := client.Do(redirectTestRequest(t, initialServer.URL)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.NoError(t, resp.Body.Close()) +}