Patching Go’s leaky HTTP clients
In November 2023 we discovered an issue in the Go standard library’s net/http.Client
that allowed an attacker who controls redirect targets on a server to exfiltrate authentication secrets. Soon after, we discovered a similar issue in net/http/cookiejar.Jar
. The issues, collectively designated CVE-2023-45289, have now been fixed in Go 1.22.1 and Go 1.21.8, released on March 5, 2024. This blog post dives into the technical details behind those two bugs and the patch that addresses them. No published versions of Mattermost were affected by the discovered vulnerability for different reasons that are explained below.
What’s vulnerable
The issues affect Go-based HTTP clients in two slightly different configurations. If you’re using Go to make authenticated requests to an HTTP-based API, chances are that you’re at risk of being bitten by one of the bugs.
Sensitive header values leaked to 3rd parties
The first vulnerability manifests itself in code that looks like the following:
r, _ := http.NewRequest("GET", serverURL, nil)
r.Header.Set("Authorization", "secret auth header value")
http.DefaultClient.Do(r)
If the server at serverURL
responds with a 3xx
status code and a redirection target in the Location
header, the default client follows that redirection. But a carefully crafted redirection target also gets the value of the Authorization
header forwarded to it, even if the target is not on the same host as the original URL.
Cookies leaked to 3rd parties
The second vulnerability is very similar:
r, _ := http.NewRequest("GET", serverURL, nil)
http.DefaultClient.Jar, _ = cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
http.DefaultClient.Do(r)
Assuming a similarly crafted redirection as in the previous case, the client forwards any cookies set by the original host to the target host.
Full demonstrations of the issues can be found on the Go Playground here and here.
Assessing the risk
As you may have noticed above, the exploitability of the vulnerabilities is dependent on server behavior: whether or not an attacker is able to coerce the server into redirecting to an arbitrary URL. This type of server-side issue is also known as an open redirection vulnerability. Typically open redirection vulnerabilities are considered low-severity, mainly exploitable for phishing purposes. Sometimes open redirections are also intentional.
Whether intentional or not, an open redirection in an HTTP API suddenly turns into a high-severity issue when combined with a client vulnerable to CVE-2023-45289. And especially when dealing with 3rd-party APIs, it’s not always easy ruling out the possibility of open redirections hiding somewhere.
What’s more, the cookie portion of CVE-2023-45289 can also be exploited without an open redirection; as long as you use the same net/http/cookiejar.Jar
instance for both cookie-authenticated requests and unauthenticated, attacker-controlled requests, an attacker will be able to get at your cookies.
Ask these questions to determine whether you’re vulnerable:
- Are you using Go’s
net/http.Client
? If not, you’re safe, but don’t forget about indirect uses, such as those in the API client libraries you import. - Are any of your HTTP requests authenticated with cookies or the Authorization header? If not, you’re safe.
- Is any of the upstream server software you connect to controlled and maintained by you? If yes, continue with the questions below, otherwise skip forward.
- Does the server ever redirect? Inspect the source code to confirm. If the answer is no, skip forward.
- Are any of the redirections externally controlled? If yes, the client that connects to this server is likely vulnerable.
- Is any of the upstream server software you connect to controlled and maintained by a third party? If yes, for any non-trivial server you should assume the worst: the client that connects to it is likely vulnerable.
- If you use cookie authentication, are you using the same cookie jar to connect to multiple servers? If yes, continue with the questions below.
- Is it possible for an attacker to control any of the URLs you connect to? If yes, the client that uses this cookie jar is likely vulnerable.
Mitigating the vulnerability
The easiest way to ensure you’re not affected by these vulnerabilities is to upgrade to Go version 1.22.1 or 1.21.8. But if you’re stuck on an older version, you may still be able to make some code changes to work around the issue.
Your options include preventing a vulnerable client from following redirections, carefully compartmentalizing the use of cookie jars, patching any open redirection issues in upstream servers, and sanitizing inputs to ensure an attacker can’t control your clients’ behavior. None of these approaches are foolproof, however, and at the very least you should understand the vulnerabilities thoroughly before attempting them.
The Authorization
header part of CVE-2023-45289 only affects Go 1.21 and newer, so downgrading to 1.20 may be another option, but since the latest release already also contains other security fixes that were not backported to 1.20, that approach is not recommended.
Where it went wrong
The first part of CVE-2023-45289 was introduced in this change that touches the net/http.shouldCopyHeaderOnRedirect
function. That function is called every time a client follows a redirection, determining whether headers set on the original request should be copied over to the redirected request. The logic is simple: If the header is one of a small number of headers deemed sensitive, such as Authorization
, only copy it if the redirected host is the same host as the original or its subdomain. Otherwise, copy the header unconditionally.
This is what the change looks like:
func shouldCopyHeaderOnRedirect(headerKey string, initial, dest *url.URL) bool {
switch CanonicalHeaderKey(headerKey) {
case "Authorization", "Www-Authenticate", "Cookie", "Cookie2":
// Permit sending auth/cookie headers from "foo.com"
// to "sub.foo.com".
// Note that we don't send all cookies to subdomains
// automatically. This function is only used for
// Cookies set explicitly on the initial outgoing
// client request. Cookies automatically added via the
// CookieJar mechanism continue to follow each
// cookie's scope as set by Set-Cookie. But for
// outgoing requests with the Cookie header set
// directly, we don't know their scope, so we assume
// it's for *.domain.com.
- ihost := canonicalAddr(initial)
- dhost := canonicalAddr(dest)
+ ihost := idnaASCIIFromURL(initial)
+ dhost := idnaASCIIFromURL(dest)
return isDomainOrSubdomain(dhost, ihost)
}
// All other headers are copied:
return true
}
The only thing changed is the way hosts are canonicalized: canonicalAddr
versus the new idnaASCIIFromURL
. The concrete difference between the two is that the original canonicalAddr
function returns the host with the port number, whereas the new function only returns the host – the intent of the change, of course, being that the logic should be relaxed to allow copying regardless of port number.
Internally, canonicalAddr
uses net.JoinHostPort
to include the port in the return value, and idnaAsciiFromURL
, of course, does not. What’s the big deal? Let’s look at the documentation:
// JoinHostPort combines host and port into a network address of the
// form "host:port". If host contains a colon, as found in literal
// IPv6 addresses, then JoinHostPort returns "[host]:port".
//
// See func Dial for a description of the host and port parameters.
Since idnaAsciiFromURL
doesn’t call net.JoinHostPort
, it returns IPv6 hosts without a port number, but also without the surrounding square brackets! And this is an issue because the isDomainOrSubdomain
function that does the actual matching uses suffixes to detect subdomains:
// If sub is "foo.example.com" and parent is "example.com",
// that means sub must end in "."+parent.
// Do it without allocating.
if !strings.HasSuffix(sub, parent) {
return false
}
As long as there are square brackets around IPv6 addresses, they cannot be confused with domain names, because an IPv6 address will always end with the ]
character, and no domain name does. But when you remove the square brackets, everything falls apart:
isDomainOrSubdomain(`2001:DB8::1%.example.com`, `example.com`) // true
The %.example.com
suffix in 2001:DB8::1%.example.com
is a zone identifier, and a perfectly valid one at that. But the isDomainOrSubdomain
function, based on nothing but suffixes, gets confused.
The second part of CVE-2023-45289 is almost identical in its root cause: the net/http/cookiejar.hasDotSuffix
method is used to match cookies to hosts, and as the name suggests, it performs simple suffix-based matching:
// hasDotSuffix reports whether s ends in "."+suffix.
func hasDotSuffix(s, suffix string) bool {
return len(s) > len(suffix) && s[len(s)-len(suffix)-1] == '.' && s[len(s)-len(suffix):] == suffix
}
The cookie jar implementation never used net.JoinHostPort
, so older versions of Go are also vulnerable.
How it was fixed
The fix to both vulnerabilities can be found in Go’s 1.22 branch here and in 1.21 here. The first part of the fix, in net/http/client.go
, looks like this:
func isDomainOrSubdomain(sub, parent string) bool {
if sub == parent {
return true
}
+ // If sub contains a :, it's probably an IPv6 address (and is definitely not a hostname).
+ // Don't check the suffix in this case, to avoid matching the contents of a IPv6 zone.
+ // For example, "::1%.www.example.com" is not a subdomain of "www.example.com".
+ if strings.ContainsAny(sub, ":%") {
+ return false
+ }
// If sub is "foo.example.com" and parent is "example.com",
// that means sub must end in "."+parent.
// Do it without allocating.
if !strings.HasSuffix(sub, parent) {
return false
}
return sub[len(sub)-len(parent)-1] == '.'
}
The check and the comment above it are fairly self-explanatory. The fix is simple and effective.
The second part uses an identical check but places it in a separate function used for detecting IP addresses and handling them in a separate code path:
// isIP reports whether host is an IP address.
func isIP(host string) bool {
+ if strings.ContainsAny(host, ":%") {
+ // Probable IPv6 address.
+ // Hostnames can't contain : or %, so this is definitely not a valid host.
+ // Treating it as an IP is the more conservative option, and avoids the risk
+ // of interpeting ::1%.www.example.com as a subtomain of www.example.com.
+ return true
+ }
return net.ParseIP(host) != nil
}
This same function was used before, but it would fail with IP addresses that include a zone identifier: net.ParseIP
expects an IP address without a zone, and returns nil
if it encounters a non-empty zone identifier.
Mattermost products not affected
Mattermost products use Go’s HTTP clients heavily, from fetching OpenGraph metadata to implementing OAuth and integrating with external services like GitHub and Jira. No Mattermost products were affected by these vulnerabilities, however, for two main reasons:
- Mattermost policy for the main product is to stay on the second-to-latest major release of Go at all times. Because of this, when we discovered the issue, we still hadn’t upgraded to Go 1.21; Go 1.22.0 hadn’t been released yet. And when 1.22.0 did come out, we made the decision to hold off on upgrading until this issue had been fixed.
- Mattermost’s Go HTTP clients do not use
net/http/cookiejar
, so the second part of the issue was never relevant to us.
Disclosure timeline
Nov 23, 2023 | Mattermost discovers vulnerability in net/http.Client and reports it to the Go security team |
Nov 28, 2023 | Go security team acknowledges report |
Nov 30, 2023 | Mattermost discovers additional vulnerability in net/http/cookiejar.Jar and reports it to the Go security team |
Jan 16, 2024 | Mattermost asks the Go security team for a status update |
Jan 16, 2024 | Go security team confirms patches are ready, scheduled for release on the 1.22 release date, Feb 6. |
Feb 2, 2024 | Go security team notifies Mattermost of delays, patches will be released on March 5 instead. |
Feb 29, 2024 | Go 1.22.1 and Go 1.21.8 pre-announcement on mailing lists |
Mar 5, 2024 | Patches released |