Close AWS app signer SSRF via X-Forwarded-Host parser differential (#67620)

This commit is contained in:
Alex Carrithers
2026-06-11 11:03:40 +00:00
committed by GitHub
parent aedcd1d851
commit 76372472d2
2 changed files with 108 additions and 6 deletions
+27 -6
View File
@@ -20,6 +20,7 @@ package aws
import (
"net/http"
"net/url"
"strings"
"github.com/gravitational/trace"
@@ -30,17 +31,35 @@ import (
awsutils "github.com/gravitational/teleport/lib/utils/aws"
)
// resolveEndpoint resolves the endpoint by creating the URL
// from valid "X-Forwarded-Host" header and extracting aws-service and
// aws-region from the authorization header.
// resolveEndpoint resolves the AWS endpoint from a valid "X-Forwarded-Host"
// header and extracts the AWS service and region from the authorization header.
// The forwarded host is accepted only if it parses as the exact HTTPS URL shape
// we later dial: no userinfo, path, query, or fragment, an AWS endpoint
// hostname, and either no port or the default HTTPS port. The returned endpoint
// URL is built from the same parsed URL that passed validation so validation and
// forwarding cannot diverge.
func resolveEndpoint(r *http.Request, authHeader string) (*common.AWSResolvedEndpoint, error) {
forwardedHost, err := libutils.GetSingleHeader(r.Header, "X-Forwarded-Host")
if err != nil {
return nil, trace.BadParameter("proxied requests must include X-Forwarded-Host header")
}
if !awsapiutils.IsAWSEndpoint(forwardedHost) {
return nil, trace.BadParameter("invalid AWS endpoint %v", forwardedHost)
// Parse the host once, with the scheme we actually dial, so validation and
// forwarding can never disagree. Validating the raw header separately from
// the "https://"+host we forward to allows a parser differential: e.g.
// "attacker.example.com://s3.amazonaws.com" validates as host
// s3.amazonaws.com but dials attacker.example.com (an SSRF primitive).
u, err := url.Parse("https://" + forwardedHost)
if err != nil || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return nil, trace.BadParameter("invalid AWS endpoint %q", forwardedHost)
}
switch u.Port() {
case "", "443":
default:
return nil, trace.BadParameter("invalid AWS endpoint %q", forwardedHost)
}
if !awsapiutils.IsAWSEndpoint(u.Hostname()) {
return nil, trace.BadParameter("invalid AWS endpoint %q", forwardedHost)
}
awsAuthHeader, err := awsutils.ParseSigV4(r.Header.Get(authHeader))
@@ -49,7 +68,9 @@ func resolveEndpoint(r *http.Request, authHeader string) (*common.AWSResolvedEnd
}
return &common.AWSResolvedEndpoint{
URL: "https://" + forwardedHost,
// Build from the validated parse, not the raw header, so the dialed
// host is exactly the one validated above.
URL: (&url.URL{Scheme: "https", Host: u.Host}).String(),
SigningRegion: awsAuthHeader.Region,
SigningName: awsAuthHeader.Service,
}, nil
+81
View File
@@ -60,4 +60,85 @@ func TestResolveEndpoints(t *testing.T) {
require.Equal(t, "some-service", endpoint.SigningName)
require.Equal(t, "https://some-service.us-east-1.amazonaws.com", endpoint.URL)
})
// Reject X-Forwarded-Host values that try to exploit a parser differential
// between endpoint validation and the URL used to dial the upstream. These
// must not resolve to an attacker-controlled host. See the AWS app signer
// SSRF finding (X-Forwarded-Host validated as *.amazonaws.com but dialed as
// an attacker host via a url.Parse scheme differential).
t.Run("rejects host/validation parser differentials", func(t *testing.T) {
for _, forwardedHost := range []string{
// scheme differential: parses as scheme="attacker.example.com",
// host="s3.amazonaws.com" for the validator but dials attacker host.
"attacker.example.com://s3.amazonaws.com",
// same trick with an explicit port on the attacker host.
"attacker.example.com:8080://s3.amazonaws.com",
// userinfo spoof: real host is attacker.example.com.
"s3.amazonaws.com@attacker.example.com",
// trailing path / query / fragment after a valid-looking host.
"s3.amazonaws.com/../@attacker.example.com",
"s3.amazonaws.com#@attacker.example.com",
// disallowed port.
"s3.amazonaws.com:8080",
// not an AWS endpoint at all.
"attacker.example.com",
} {
t.Run(forwardedHost, func(t *testing.T) {
req, err := http.NewRequest("GET", "http://localhost", nil)
require.NoError(t, err)
req.Header.Set("X-Forwarded-Host", forwardedHost)
err = signer.SignHTTP(t.Context(), creds, req, awsutils.EmptyPayloadHash, "s3", region, now)
require.NoError(t, err)
_, err = resolveEndpoint(req, awsutils.AuthorizationHeader)
require.Error(t, err)
})
}
})
// For every X-Forwarded-Host that resolveEndpoint accepts, the host that
// urlForResolvedEndpoint subsequently dials must be the same validated
// *.amazonaws.com host. The validate path and the forward path can never
// diverge.
t.Run("accepted endpoint dials the validated host", func(t *testing.T) {
for _, tt := range []struct {
forwardedHost string
wantHost string
}{
{
forwardedHost: "s3.amazonaws.com",
wantHost: "s3.amazonaws.com",
},
{
forwardedHost: "some-service.us-east-1.amazonaws.com",
wantHost: "some-service.us-east-1.amazonaws.com",
},
{
forwardedHost: "some-service.us-east-1.amazonaws.com:443",
wantHost: "some-service.us-east-1.amazonaws.com:443",
},
{
forwardedHost: "example.amazonaws.com.cn",
wantHost: "example.amazonaws.com.cn",
},
} {
t.Run(tt.forwardedHost, func(t *testing.T) {
req, err := http.NewRequest("GET", "http://localhost/some/path?q=1", nil)
require.NoError(t, err)
req.Header.Set("X-Forwarded-Host", tt.forwardedHost)
err = signer.SignHTTP(t.Context(), creds, req, awsutils.EmptyPayloadHash, "s3", region, now)
require.NoError(t, err)
endpoint, err := resolveEndpoint(req, awsutils.AuthorizationHeader)
require.NoError(t, err)
dialURL, err := urlForResolvedEndpoint(req, endpoint)
require.NoError(t, err)
require.Equal(t, tt.wantHost, dialURL.Host)
require.Equal(t, "https", dialURL.Scheme)
})
}
})
}