diff --git a/lib/srv/app/aws/endpoints.go b/lib/srv/app/aws/endpoints.go index e479b6ddd1a..b2f10018de6 100644 --- a/lib/srv/app/aws/endpoints.go +++ b/lib/srv/app/aws/endpoints.go @@ -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 diff --git a/lib/srv/app/aws/endpoints_test.go b/lib/srv/app/aws/endpoints_test.go index 7a1116be354..6b114bc5786 100644 --- a/lib/srv/app/aws/endpoints_test.go +++ b/lib/srv/app/aws/endpoints_test.go @@ -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) + }) + } + }) }