mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 01:58:44 +08:00
Close AWS app signer SSRF via X-Forwarded-Host parser differential (#67620)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user