Skip to content

[azcore] Default http.Client sets no CheckRedirect, so key/SAS credential headers are forwarded on cross-host redirects #27340

Description

@mohammedelbadawe12-cpu

Bug Report

  • Import path: github.com/Azure/azure-sdk-for-go/sdk/azcore (specifically sdk/azcore/runtime)
  • SDK version: v1.22.0 (latest published; go list -m output below). Also confirmed present on main today.
  • Go version: go version go1.26.5 windows/amd64
$ go list -m github.com/Azure/azure-sdk-for-go/sdk/azcore
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0

What happened?

azcore's default http.Client is constructed without a CheckRedirect function, so Go's standard
library follows up to 10 redirects automatically. On a cross-host redirect the stdlib strips only
four headers — Authorization, Www-Authenticate, Cookie, Cookie2 — and re-sends everything
else verbatim to the new host.

That means Azure's key- and SAS-style credential headers are forwarded to the redirect target,
because none of them are named Authorization:

Header Set by Contents
Api-Key, Ocp-Apim-Subscription-Key key-credential policies / APIM-fronted services long-lived key
aeg-sas-key, aeg-sas-token sdk/messaging/eventgrid/azeventgrid/client_custom.go Event Grid SAS
x-ms-copy-source sdk/storage/azblob/.../zz_blob_client.go (+ appendblob, pageblob) source blob URL, commonly including its ?...&sig= SAS
x-ms-copy-source-authorization same an authorization value in a header the stdlib does not strip
Referer Go stdlib, automatically the full previous URL — leaks a SAS in the query string even when no credential header is set

sdk/azcore/runtime/transport_default_http_client.go:

defaultHTTPClient = &http.Client{
    Transport: defaultTransport,
}

No CheckRedirect, so http.Client uses defaultCheckRedirect (follow, max 10 hops). This is the
only HTTP client construction in the module, and there is no redirect handling anywhere in the repo
grep -ri checkredirect and find -iname '*redirect*' both return nothing.

What did you expect or want to happen?

That credentials attached by the pipeline are not sent to a host other than the one they were
intended for — which is what the other five Azure SDKs already do. Each of these was read from
current main:

SDK Redirect control
.NET AllowAutoRedirect = false at every transport; RedirectPolicy off by default and strips Authorization on every redirect; BearerTokenAuthenticationPolicy made host-bound in Azure.Core 1.59.0
Java RedirectPolicy.java clears the authorization header before following, with the reason named in the comment
Python dedicated SensitiveHeaderCleanupPolicy; DEFAULT_SENSITIVE_HEADERS = {"Authorization", "x-ms-authorization-auxiliary"}
Rust .redirect(reqwest::redirect::Policy::none())
JS allowCrossOriginRedirects = false by default
Go (none)

Two of those seem especially relevant here:

  • Python already protects a non-Authorization credential header (x-ms-authorization-auxiliary),
    so the team has already concluded that stdlib-level Authorization stripping isn't sufficient for
    Azure's x-ms-* credentials. That's the same gap Go leaves open.
  • Azure.Core 1.59.0 (2026-06-09) shipped a Bugs Fixed entry for this class in .NET
    (Azure/azure-sdk-for-net#59606,
    "[Azure.Core] Do not flow token on cross-host redirect"): RedirectPolicy stripped
    Authorization, but BearerTokenAuthenticationPolicy re-added the cached token when the redirect
    target was a different host.

The difference worth flagging: #59606's defect only manifested with a non-default opt-in
(HttpPipelineTransportOptions.IsClientRedirectEnabled = true). In Go, redirect-following is on by
default
via the standard library and there is no policy of any kind, so the same structural gap is
reachable in the default configuration. .NET's non-bearer policies
(AzureKeyCredentialPolicy, AzureSasCredentialSynchronousPolicy) have the same shape as Go's, but
they're unreachable there precisely because auto-redirect is off.

How can we reproduce it?

Self-contained — no Azure resources and no network access. Two local servers: a "service" that answers
302 with a Location pointing at an "attacker" host, and the attacker host, which prints every
header it receives. The request goes through azcore's default transport.

Output (re-run today against azcore v1.22.0 / go1.26.5)
=== VARIANT A: azcore DEFAULT transport, cross-host 302 (http) ===
  attacker host WAS contacted. Headers it received:
    Api-Key:                     SUPER-SECRET-API-KEY-abc123   <<<<<< CREDENTIAL LEAKED
    Ocp-Apim-Subscription-Key:   sv=2021-08-06&sig=SUPER-SECRET-SAS-SIGNATURE   <<<<<< CREDENTIAL LEAKED
    Referer:                     http://localhost:11367/resource
    X-Ms-Copy-Source:            https://victim.blob.core.windows.net/c/b?sv=2021-08-06&sig=SUPER-SECRET-SAS-SIGNATURE   <<<<<< CREDENTIAL LEAKED
    [stripped] Authorization was NOT forwarded

=== VARIANT B: https, distinct hostnames, cross-host 302 ===
  https://service.contoso.test -> 302 -> https://attacker.evil.test
  (identical: Api-Key, Ocp-Apim-Subscription-Key, X-Ms-Copy-Source all forwarded)

=== VARIANT C: SAS in URL query string, cross-host 302, no credential header set ===
    Referer:  https://victim.blob.core.windows.test/container/blob?sv=2021-08-06&sig=SUPER-SECRET-SAS-SIGNATURE   <<<<<< CREDENTIAL LEAKED
    [stripped] Authorization was NOT forwarded
    [stripped] Api-Key was NOT forwarded          (not set in this variant)

Control: Authorization is correctly stripped in every variant, which shows the harness can
observe stripping — so the forwarded headers are a real result rather than a test artifact. In
variant C the credential headers are absent because they were not set, confirming the harness reports
only what actually arrives.

I'm happy to attach the full harness (go.mod + a single main.go) or open a PR with it as a test,
whichever is more useful to you.

Suggested fix

Set an explicit CheckRedirect on defaultHTTPClient:

defaultHTTPClient = &http.Client{
    Transport: defaultTransport,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        return http.ErrUseLastResponse
    },
}

This matches Rust (Policy::none()) and .NET (AllowAutoRedirect = false), and the Azure REST
guidelines discourage 3xx from services anyway. If following redirects needs to be preserved, the
Python/Java shape works too: on a hop where (scheme, host, port) changes, drop the Azure credential
headers and Referer.

Happy to send a PR for either shape if you'd prefer that to an issue.

Anything we should know about your environment?

Nothing unusual — Windows, Go 1.26.5, module mode, no proxy.

Scope, stated honestly

I have not demonstrated an open redirect or Location injection on a live Azure first-party
service, and I did not test against any real Azure endpoint. What I'm reporting is that azcore
provides no protection if a cross-host redirect occurs, in a codebase where every sibling SDK
deliberately provides one. If the position is that no in-scope service can be made to emit a
cross-host 3xx, that reduces the practical impact considerably and this is just a
defence-in-depth/consistency gap — which still seems worth closing given the one-line fix.

I searched existing issues and PRs before filing: no issue or PR in this repo mentions
CheckRedirect, and #20830 ("Enhance tests for redirect policy", closed) refers to a policy that
doesn't exist in the Go SDK. Apologies if I missed a prior discussion.

Metadata

Metadata

Labels

Azure.CoreClientThis issue points to a problem in the data-plane of the library.customer-reportedIssues that are reported by GitHub users external to the Azure organization.needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK teamquestionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions