diff --git a/docs/reference/filters.md b/docs/reference/filters.md index 026031bea0..68b721059e 100644 --- a/docs/reference/filters.md +++ b/docs/reference/filters.md @@ -2065,6 +2065,49 @@ so if requests on average have 100KB and the maximum memory is set to 100MB, on The filter also honors the `skip-request-body-parse` of the corresponding [configuration](https://www.openpolicyagent.org/docs/latest/envoy-introduction/#configuration) that the OPA plugin uses. +### awsSigv4 + +This filter signs request using [AWS Sig V4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html_) algorithm. The requests must provide following headers in order for this filter to generate a valid signature. +- `x-amz-accesskey` header must contain a valid AWS access key +- `x-amz-secret` header must contain a valid secret for AWS client being used. +- `x-amz-time` header must contain the time in RFC3339 format which this filter can use to generate signature and `X-Amz-Date` header on signed request. This time stamp is considered as the time stamp of generated signature. +- `x-amz-session` must contain valid AWS session token ([see](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html#using-temp-creds-sdk)) to be set as `X-Amz-Security-Token` in signed request when `DisableSessionToken` parameter defined on route is set to false. + +Filter removes these headers after reading the values. Once the signature is generated, it is appended to existing Authorization header or if there is no exisiting Authorization header, added as new and forwarded to AWS service. + +awsSigv4 filter can be defined on a route as `awsSigv4(", "", , , )` + +An example of route with awsSigv4 filter is + `editorRoute: * -> awsSigv4("dynamodb" , "us-east-1", false, false, false) -> "https://dynamodb.us-east-1.amazonaws.com";` + +This filter expects +- `Service` An aws service name. Please refer valid service names from service endpoint. + For example if service endpoint is https://dynamodb.us-east-1.amazonaws.com, then service is dynamodb + +- `Region` AWS region where service is located. Please refer valid service names from service endpoint. + For example if service endpoint is https://dynamodb.us-east-1.amazonaws.com, then region is us-east-1. + +- `DisableHeaderHoisting` Disables the Signer's moving HTTP header key/value pairs from the HTTP request header to the request's query string. This is most commonly used + with pre-signed requests preventing headers from being added to the request's query string. + +- `DisableURIPathEscaping` Disables the automatic escaping of the URI path of the request for the siganture's canonical string's path. For services that do not need additional + escaping then use this to disable the signer escaping the path. S3 is an example of a service that does not need additional escaping. + http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + +- `DisableSessionToken` Disables setting the session token on the request as part of signing through X-Amz-Security-Token. This is needed for variations of v4 that + present the token elsewhere. + + + +#### Memory consideration +This filter reads the body in memory. This is needed to generate signature as per Signature V4 specs. Special considerations need to be taken when operating the skipper with concurrent requests. + + +#### Overwriting io.ReadCloser +This filter resets `read` and `close` implementations of body to default. So in case a filter before this filter has some custom implementations of thse methods, they would be overwritten. + + + ## Cookie Handling ### dropRequestCookie diff --git a/filters/awssigner/awssigv4/doc.go b/filters/awssigner/awssigv4/doc.go new file mode 100644 index 0000000000..61146ce30b --- /dev/null +++ b/filters/awssigner/awssigv4/doc.go @@ -0,0 +1,53 @@ +/* + +Package awssigv4 signs requests using aws signature version 4 mechanism. see https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html + +# Filter awsSigv4 +awsSigv4 filter can be defined on a route as `awsSigv4(", "", , , )` + +An example of route with awsSigv4 filter is + `editorRoute: * -> awsSigv4("dynamodb" , "us-east-1", false, false, false) -> "https://dynamodb.us-east-1.amazonaws.com";` + + This filter expects + - Service + An aws service name. Please refer valid service names from service endpoint. + For example if service endpoint is https://dynamodb.us-east-1.amazonaws.com, then service is dynamodb + + - Region + AWS region where service is located. Please refer valid service names from service endpoint. + For example if service endpoint is https://dynamodb.us-east-1.amazonaws.com, then region is us-east-1. + + - DisableHeaderHoisting + Disables the Signer's moving HTTP header key/value pairs from the HTTP request header to the request's query string. This is most commonly used + with pre-signed requests preventing headers from being added to the request's query string. + + - DisableURIPathEscaping + Disables the automatic escaping of the URI path of the request for the siganture's canonical string's path. For services that do not need additional + escaping then use this to disable the signer escaping the path. S3 is an example of a service that does not need additional escaping. + http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + + - DisableSessionToken + Disables setting the session token on the request as part of signing through X-Amz-Security-Token. This is needed for variations of v4 that + present the token elsewhere. + + The filter also expects following headers to be present to sign the request + - x-amz-accesskey + A valid AWS access key + - x-amz-secret + A valid AWS secret + - x-amz-session + A valid session token [see](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html#using-temp-creds-sdk) + - x-amz-time + A RFC3339 format time stamp which should be considered as time stamp of signing the request. + + Filter removes these headers after reading the values and does not alter the signature produced. Once the signature is generated, it is appended to Authorization header and forwarded to AWS service. + +# Memory consideration +This filter reads the body in memory. This is needed to generate signature as per Signature V4 specs. Special considerations need to be taken when operating the skipper with concurrent requests. + + +# Overwriting io.ReadCloser +This filter resets `read` and `close` implementations of body to default. So in case a filter before this filter has some custom implementations of thse methods, they would be overwritten. +*/ + +package awssigv4 diff --git a/filters/awssigner/awssigv4/httpsigner.go b/filters/awssigner/awssigv4/httpsigner.go new file mode 100644 index 0000000000..d998a2a41e --- /dev/null +++ b/filters/awssigner/awssigv4/httpsigner.go @@ -0,0 +1,377 @@ +package awssigv4 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "hash" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + internal "github.com/zalando/skipper/filters/awssigner/internal" +) + +type keyDerivator interface { + DeriveKey(credential internal.Credentials, service, region string, signingTime internal.SigningTime) []byte +} + +// Signer applies AWS v4 signing to given request. Use this to sign requests +// that need to be signed with AWS V4 Signatures. +type Signer struct { + options SignerOptions + keyDerivator keyDerivator +} + +type httpSigner struct { + Request *http.Request + ServiceName string + Region string + Time internal.SigningTime + Credentials internal.Credentials + KeyDerivator keyDerivator + IsPreSign bool + PayloadHash string + DisableHeaderHoisting bool + DisableURIPathEscaping bool + DisableSessionToken bool +} + +func NewSigner(optFns ...func(signer *SignerOptions)) *Signer { + options := SignerOptions{} + + for _, fn := range optFns { + fn(&options) + } + + return &Signer{options: options, keyDerivator: internal.NewSigningKeyDeriver()} +} + +func (s *httpSigner) buildCanonicalHeaders(host string, rule internal.Rule, header http.Header, length int64) (signed http.Header, signedHeaders, canonicalHeadersStr string) { + signed = make(http.Header) + + var headers []string + const hostHeader = "host" + headers = append(headers, hostHeader) + signed[hostHeader] = append(signed[hostHeader], host) + + const contentLengthHeader = "content-length" + if length > 0 { + headers = append(headers, contentLengthHeader) + signed[contentLengthHeader] = append(signed[contentLengthHeader], strconv.FormatInt(length, 10)) + } + + for k, v := range header { + if !rule.IsValid(k) { + continue // ignored header + } + if strings.EqualFold(k, contentLengthHeader) { + // prevent signing already handled content-length header. + continue + } + + lowerCaseKey := strings.ToLower(k) + if _, ok := signed[lowerCaseKey]; ok { + // include additional values + signed[lowerCaseKey] = append(signed[lowerCaseKey], v...) + continue + } + + headers = append(headers, lowerCaseKey) + signed[lowerCaseKey] = v + } + sort.Strings(headers) + + signedHeaders = strings.Join(headers, ";") + + var canonicalHeaders strings.Builder + n := len(headers) + const colon = ':' + for i := 0; i < n; i++ { + if headers[i] == hostHeader { + canonicalHeaders.WriteString(hostHeader) + canonicalHeaders.WriteRune(colon) + canonicalHeaders.WriteString(internal.StripExcessSpaces(host)) + } else { + canonicalHeaders.WriteString(headers[i]) + canonicalHeaders.WriteRune(colon) + // Trim out leading, trailing, and dedup inner spaces from signed header values. + values := signed[headers[i]] + for j, v := range values { + cleanedValue := strings.TrimSpace(internal.StripExcessSpaces(v)) + canonicalHeaders.WriteString(cleanedValue) + if j < len(values)-1 { + canonicalHeaders.WriteRune(',') + } + } + } + canonicalHeaders.WriteRune('\n') + } + canonicalHeadersStr = canonicalHeaders.String() + + return signed, signedHeaders, canonicalHeadersStr +} + +func (s *httpSigner) Build() (signedRequest, error) { + req := s.Request + + query := req.URL.Query() + headers := req.Header + + s.setRequiredSigningFields(headers, query) + + // Sort Each Query Key's Values + for key := range query { + sort.Strings(query[key]) + } + + internal.SanitizeHostForHeader(req) + + credentialScope := s.buildCredentialScope() + credentialStr := s.Credentials.AccessKeyID + "/" + credentialScope + if s.IsPreSign { + query.Set(internal.AmzCredentialKey, credentialStr) + } + + unsignedHeaders := headers + if s.IsPreSign && !s.DisableHeaderHoisting { + var urlValues url.Values + urlValues, unsignedHeaders = buildQuery(internal.AllowedQueryHoisting, headers) + for k := range urlValues { + query[k] = urlValues[k] + } + } + //this is not valid way to extract host as skipper never recieves aws host in req.URL. We should set it explicitly + /*host := req.URL.Host + if len(req.Host) > 0 { + host = req.Host + }*/ + host := s.ServiceName + "." + s.Region + ".amazonaws.com" + + signedHeaders, signedHeadersStr, canonicalHeaderStr := s.buildCanonicalHeaders(host, internal.IgnoredHeaders, unsignedHeaders, s.Request.ContentLength) + + if s.IsPreSign { + query.Set(internal.AmzSignedHeadersKey, signedHeadersStr) + } + + var rawQuery strings.Builder + rawQuery.WriteString(strings.Replace(query.Encode(), "+", "%20", -1)) + + canonicalURI := internal.GetURIPath(req.URL) + if !s.DisableURIPathEscaping { + canonicalURI = internal.EscapePath(canonicalURI, false) + } + + canonicalString := s.buildCanonicalString( + req.Method, + canonicalURI, + rawQuery.String(), + signedHeadersStr, + canonicalHeaderStr, + ) + + strToSign := s.buildStringToSign(credentialScope, canonicalString) + signingSignature, err := s.buildSignature(strToSign) + if err != nil { + return signedRequest{}, err + } + + if s.IsPreSign { + rawQuery.WriteString("&X-Amz-Signature=") + rawQuery.WriteString(signingSignature) + } else { + headers[internal.AuthorizationHeader] = append(headers[internal.AuthorizationHeader][:0], buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature)) + } + + req.URL.RawQuery = rawQuery.String() + + return signedRequest{ + Request: req, + SignedHeaders: signedHeaders, + CanonicalString: canonicalString, + StringToSign: strToSign, + PreSigned: s.IsPreSign, + }, nil + +} + +func (s *httpSigner) buildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders string) string { + return strings.Join([]string{ + method, + uri, + query, + canonicalHeaders, + signedHeaders, + s.PayloadHash, + }, "\n") +} + +func (s *httpSigner) buildSignature(strToSign string) (string, error) { + key := s.KeyDerivator.DeriveKey(s.Credentials, s.ServiceName, s.Region, s.Time) + return hex.EncodeToString(internal.HMACSHA256(key, []byte(strToSign))), nil +} + +type signedRequest struct { + Request *http.Request + SignedHeaders http.Header + CanonicalString string + StringToSign string + PreSigned bool +} + +func (s *httpSigner) buildStringToSign(credentialScope, canonicalRequestString string) string { + return strings.Join([]string{ + internal.SigningAlgorithm, + s.Time.TimeFormat(), + credentialScope, + hex.EncodeToString(makeHash(sha256.New(), []byte(canonicalRequestString))), + }, "\n") +} + +func makeHash(hash hash.Hash, b []byte) []byte { + hash.Reset() + hash.Write(b) + return hash.Sum(nil) +} + +func buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature string) string { + const credential = "Credential=" + const signedHeaders = "SignedHeaders=" + const signature = "Signature=" + const commaSpace = ", " + + var parts strings.Builder + parts.Grow(len(internal.SigningAlgorithm) + 1 + + len(credential) + len(credentialStr) + 2 + + len(signedHeaders) + len(signedHeadersStr) + 2 + + len(signature) + len(signingSignature), + ) + parts.WriteString(internal.SigningAlgorithm) + parts.WriteRune(' ') + parts.WriteString(credential) + parts.WriteString(credentialStr) + parts.WriteString(commaSpace) + parts.WriteString(signedHeaders) + parts.WriteString(signedHeadersStr) + parts.WriteString(commaSpace) + parts.WriteString(signature) + parts.WriteString(signingSignature) + return parts.String() +} + +func buildQuery(r internal.Rule, header http.Header) (url.Values, http.Header) { + query := url.Values{} + unsignedHeaders := http.Header{} + for k, h := range header { + if r.IsValid(k) { + query[k] = h + } else { + unsignedHeaders[k] = h + } + } + + return query, unsignedHeaders +} + +func (s *httpSigner) buildCredentialScope() string { + return internal.BuildCredentialScope(s.Time, s.Region, s.ServiceName) +} + +func (s *httpSigner) setRequiredSigningFields(headers http.Header, query url.Values) { + amzDate := s.Time.TimeFormat() + + if s.IsPreSign { + query.Set(internal.AmzAlgorithmKey, internal.SigningAlgorithm) + sessionToken := s.Credentials.SessionToken + if !s.DisableSessionToken && len(sessionToken) > 0 { + query.Set("X-Amz-Security-Token", sessionToken) + } + + query.Set(internal.AmzDateKey, amzDate) + return + } + + headers[internal.AmzDateKey] = append(headers[internal.AmzDateKey][:0], amzDate) + + if !s.DisableSessionToken && len(s.Credentials.SessionToken) > 0 { + headers[internal.AmzSecurityTokenKey] = append(headers[internal.AmzSecurityTokenKey][:0], s.Credentials.SessionToken) + } +} + +// The passed in request will be modified in place. +func (s Signer) SignHTTP(credentials internal.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(options *SignerOptions)) error { + options := s.options + + for _, fn := range optFns { + fn(&options) + } + + signer := &httpSigner{ + Request: r, + PayloadHash: payloadHash, + ServiceName: service, + Region: region, + Credentials: credentials, + Time: internal.NewSigningTime(signingTime.UTC()), + DisableHeaderHoisting: options.DisableHeaderHoisting, + DisableURIPathEscaping: options.DisableURIPathEscaping, + DisableSessionToken: options.DisableSessionToken, + KeyDerivator: s.keyDerivator, + } + + _, err := signer.Build() + return err +} + +func (s *httpSigner) BuildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders string) string { + return strings.Join([]string{ + method, + uri, + query, + canonicalHeaders, + signedHeaders, + s.PayloadHash, + }, "\n") +} + +func (s *httpSigner) BuildStringToSign(credentialScope, canonicalRequestString string) string { + return strings.Join([]string{ + internal.SigningAlgorithm, + s.Time.TimeFormat(), + credentialScope, + hex.EncodeToString(makeHash(sha256.New(), []byte(canonicalRequestString))), + }, "\n") +} + +type SignerOptions struct { + // Disables the Signer's moving HTTP header key/value pairs from the HTTP + // request header to the request's query string. This is most commonly used + // with pre-signed requests preventing headers from being added to the + // request's query string. + DisableHeaderHoisting bool + + // Disables the automatic escaping of the URI path of the request for the + // siganture's canonical string's path. For services that do not need additional + // escaping then use this to disable the signer escaping the path. + // + // S3 is an example of a service that does not need additional escaping. + // + // http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + DisableURIPathEscaping bool + + // The logger to send log messages to. + Ctx context.Context + + // Enable logging of signed requests. + // This will enable logging of the canonical request, the string to sign, and for presigning the subsequent + // presigned URL. + LogSigning bool + + // Disables setting the session token on the request as part of signing + // through X-Amz-Security-Token. This is needed for variations of v4 that + // present the token elsewhere. + DisableSessionToken bool +} diff --git a/filters/awssigner/awssigv4/sigv4.go b/filters/awssigner/awssigv4/sigv4.go new file mode 100644 index 0000000000..db525c79f2 --- /dev/null +++ b/filters/awssigner/awssigv4/sigv4.go @@ -0,0 +1,193 @@ +package awssigv4 + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + "github.com/zalando/skipper/filters" + internal "github.com/zalando/skipper/filters/awssigner/internal" +) + +type awsSigV4Spec struct{} + +const accessKeyHeader = "x-amz-accesskey" +const secretHeader = "x-amz-secret" +const sessionHeader = "x-amz-session" +const timeHeader = "x-amz-time" + +type awsSigV4Filter struct { + region string + service string + disableHeaderHoisting bool + disableURIPathEscaping bool + disableSessionToken bool +} + +func New() filters.Spec { + return &awsSigV4Spec{} +} + +func (*awsSigV4Spec) Name() string { + return filters.AWSSigV4Name +} + +func (c *awsSigV4Spec) CreateFilter(args []interface{}) (filters.Filter, error) { + if len(args) != 5 { + return nil, filters.ErrInvalidFilterParameters + } + + region, ok := args[0].(string) + if !ok { + return nil, filters.ErrInvalidFilterParameters + } + + service, ok := args[1].(string) + if !ok { + return nil, filters.ErrInvalidFilterParameters + } + + disableHeaderHoistingStr, ok := args[2].(string) + if !ok { + return nil, filters.ErrInvalidFilterParameters + } + + disableURIPathEscapingStr, ok := args[3].(string) + if !ok { + return nil, filters.ErrInvalidFilterParameters + } + + disableSessionTokenStr, ok := args[4].(string) + if !ok { + return nil, filters.ErrInvalidFilterParameters + } + + disableHeaderHoisting, err := strconv.ParseBool(disableHeaderHoistingStr) + if err != nil { + return nil, filters.ErrInvalidFilterParameters + } + + disableURIPathEscaping, err := strconv.ParseBool(disableURIPathEscapingStr) + if err != nil { + return nil, filters.ErrInvalidFilterParameters + } + + disableSessionToken, err := strconv.ParseBool(disableSessionTokenStr) + if err != nil { + return nil, filters.ErrInvalidFilterParameters + } + + return &awsSigV4Filter{ + region: region, + service: service, + disableHeaderHoisting: disableHeaderHoisting, + disableURIPathEscaping: disableURIPathEscaping, + disableSessionToken: disableSessionToken, + }, nil +} + +/* +sigV4Filter is a request filter that signs the request. +In case a non empty is body is present in request, +the body is read and signed. The body is later reassigned to request. Operators should ensure +that body size by all requests at any point of time is not more than the memory limit of skipper. +*/ +func (f *awsSigV4Filter) Request(ctx filters.FilterContext) { + req := ctx.Request() + + logger := log.WithContext(req.Context()) + + signer := NewSigner() + + accessKey := getAndRemoveHeader(ctx, accessKeyHeader, req) + if accessKey == "" { + return + } + + secretKey := getAndRemoveHeader(ctx, secretHeader, req) + if secretKey == "" { + return + } + sessionToken := "" + if !f.disableSessionToken { + sessionToken = getAndRemoveHeader(ctx, sessionHeader, req) + if sessionToken == "" { + return + } + } + + timeStr := getAndRemoveHeader(ctx, timeHeader, req) + if timeStr == "" { + return + } + + time, err := time.Parse(time.RFC3339, timeStr) + + if err != nil { + logger.Log(log.ErrorLevel, "time was not in RFC3339 format") + return + } + + hashedBody, body, err := hashRequest(ctx, req.Body) + if err != nil { + logger.Log(log.ErrorLevel, fmt.Sprintf("error occured while hashing the body %s", err.Error())) + return + } + creds := internal.Credentials{ + AccessKeyID: accessKey, + SessionToken: sessionToken, + SecretAccessKey: secretKey, + } + + optfn := func(options *SignerOptions) { + options.DisableHeaderHoisting = f.disableHeaderHoisting + options.DisableSessionToken = f.disableSessionToken + options.DisableURIPathEscaping = f.disableURIPathEscaping + options.Ctx = ctx.Request().Context() + } + //modifies request inplace + signer.SignHTTP(creds, req, hashedBody, f.service, f.region, time, optfn) + + ctx.Request().Body = io.NopCloser(body) //ATTN: custom close() and read() set by skipper or previous filters are lost +} + +func (f *awsSigV4Filter) Response(ctx filters.FilterContext) {} + +func hashRequest(ctx filters.FilterContext, body io.Reader) (string, io.Reader, error) { + h := sha256.New() + if body == nil { + body = http.NoBody + _, err := io.Copy(h, body) + if err != nil { + + return "", nil, err + } + return hex.EncodeToString(h.Sum(nil)), nil, nil + } else { + var buf bytes.Buffer + tee := io.TeeReader(body, &buf) + _, err := io.Copy(h, tee) + if err != nil { + return "", nil, err + } + return hex.EncodeToString(h.Sum(nil)), &buf, nil + } +} + +func getAndRemoveHeader(ctx filters.FilterContext, headerName string, req *http.Request) string { + logger := log.WithContext(ctx.Request().Context()) + headerValue := req.Header.Get(headerName) + if headerValue == "" { + logger.Logf(log.ErrorLevel, "%q header is missing", headerName) + return "" + } else { + req.Header.Del(headerName) + return headerValue + } +} diff --git a/filters/awssigner/awssigv4/sigv4_test.go b/filters/awssigner/awssigv4/sigv4_test.go new file mode 100644 index 0000000000..fe8ecf57d9 --- /dev/null +++ b/filters/awssigner/awssigv4/sigv4_test.go @@ -0,0 +1,461 @@ +package awssigv4 + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/zalando/skipper/filters" + internal "github.com/zalando/skipper/filters/awssigner/internal" + "github.com/zalando/skipper/filters/filtertest" +) + +func TestSignRequest(t *testing.T) { + var testCredentials = internal.Credentials{AccessKeyID: "AKID", SecretAccessKey: "SECRET", SessionToken: "SESSION"} + req, body := buildRequest("dynamodb", "us-east-1", "{}") + signer := NewSigner() + ctx := &filtertest.Context{ + FRequest: &http.Request{}, + } + optfn := func(options *SignerOptions) { + options.Ctx = ctx.FRequest.Context() + } + err := signer.SignHTTP(testCredentials, req, body, "dynamodb", "us-east-1", time.Unix(0, 0), optfn) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + expectedDate := "19700101T000000Z" + expectedSig := "AWS4-HMAC-SHA256 Credential=AKID/19700101/us-east-1/dynamodb/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-security-token;x-amz-target, Signature=a518299330494908a70222cec6899f6f32f297f8595f6df1776d998936652ad9" + + q := req.Header + if e, a := expectedSig, q.Get("Authorization"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { + t.Errorf("expect %v, got %v", e, a) + } +} + +func TestBuildCanonicalRequest(t *testing.T) { + req, _ := buildRequest("dynamodb", "us-east-1", "{}") + req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" + + ctx := &httpSigner{ + ServiceName: "dynamodb", + Region: "us-east-1", + Request: req, + Time: internal.NewSigningTime(time.Now()), + KeyDerivator: internal.NewSigningKeyDeriver(), + } + + build, err := ctx.Build() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := "https://example.org/bucket/key-._~,!@#$%^&*()?Foo=a&Foo=m&Foo=o&Foo=z" + if e, a := expected, build.Request.URL.String(); e != a { + t.Errorf("expect %v, got %v", e, a) + } +} + +func TestSigner_SignHTTP_NoReplaceRequestBody(t *testing.T) { + req, bodyHash := buildRequest("dynamodb", "us-east-1", "{}") + req.Body = io.NopCloser(bytes.NewReader([]byte{})) + var testCredentials = internal.Credentials{AccessKeyID: "AKID", SecretAccessKey: "SECRET", SessionToken: "SESSION"} + s := NewSigner() + + origBody := req.Body + ctx := &filtertest.Context{ + FRequest: &http.Request{ + Header: http.Header{ + "X-Foo": []string{"foo"}, + }, + }, + } + optfn := func(options *SignerOptions) { + options.Ctx = ctx.FRequest.Context() + } + err := s.SignHTTP(testCredentials, req, bodyHash, "dynamodb", "us-east-1", time.Now(), optfn) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + if req.Body != origBody { + t.Errorf("expect request body to not be chagned") + } +} + +/* +This test is being skipped since for skipper, we cannot dervive AWS host from req. +see https://github.com/zalando/skipper/pull/3070/files#diff-59e00c1e2a1a8ea3f9e5b4111f5c0b56cd7f81b1b14d8148f1dae146958d2c45R154 +for change. We still keep this test to debug any unusual behaviours +*/ +func TestRequestHost(t *testing.T) { + t.Skip() + req, _ := buildRequest("dynamodb", "us-east-1", "{}") + req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" + req.Host = "myhost" + + query := req.URL.Query() + query.Set("X-Amz-Expires", "5") + req.URL.RawQuery = query.Encode() + + ctx := &httpSigner{ + ServiceName: "dynamodb", + Region: "us-east-1", + Request: req, + Time: internal.NewSigningTime(time.Now()), + KeyDerivator: internal.NewSigningKeyDeriver(), + } + + build, err := ctx.Build() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !strings.Contains(build.CanonicalString, "host:"+req.Host) { + t.Errorf("canonical host header invalid") + } +} + +func TestSign_buildCanonicalHeadersContentLengthPresent(t *testing.T) { + body := `{"description": "this is a test"}` + req, _ := buildRequest("dynamodb", "us-east-1", body) + req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" + req.Host = "myhost" + + contentLength := fmt.Sprintf("%d", len([]byte(body))) + req.Header.Add("Content-Length", contentLength) + + query := req.URL.Query() + query.Set("X-Amz-Expires", "5") + req.URL.RawQuery = query.Encode() + + ctx := &httpSigner{ + ServiceName: "dynamodb", + Region: "us-east-1", + Request: req, + Time: internal.NewSigningTime(time.Now()), + KeyDerivator: internal.NewSigningKeyDeriver(), + } + + build, err := ctx.Build() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !strings.Contains(build.CanonicalString, "content-length:"+contentLength+"\n") { + t.Errorf("canonical header content-length invalid") + } +} + +func TestSign_buildCanonicalHeaders(t *testing.T) { + serviceName := "mockAPI" + region := "mock-region" + endpoint := "https://" + serviceName + "." + region + ".amazonaws.com" + + req, err := http.NewRequest("POST", endpoint, nil) + if err != nil { + t.Fatalf("failed to create request, %v", err) + } + + req.Header.Set("FooInnerSpace", " inner space ") + req.Header.Set("FooLeadingSpace", " leading-space") + req.Header.Add("FooMultipleSpace", "no-space") + req.Header.Add("FooMultipleSpace", "\ttab-space") + req.Header.Add("FooMultipleSpace", "trailing-space ") + req.Header.Set("FooNoSpace", "no-space") + req.Header.Set("FooTabSpace", "\ttab-space\t") + req.Header.Set("FooTrailingSpace", "trailing-space ") + req.Header.Set("FooWrappedSpace", " wrapped-space ") + + ctx := &httpSigner{ + ServiceName: serviceName, + Region: region, + Request: req, + Time: internal.NewSigningTime(time.Date(2021, 10, 20, 12, 42, 0, 0, time.UTC)), + KeyDerivator: internal.NewSigningKeyDeriver(), + } + + build, err := ctx.Build() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expectCanonicalString := strings.Join([]string{ + `POST`, + `/`, + ``, + `fooinnerspace:inner space`, + `fooleadingspace:leading-space`, + `foomultiplespace:no-space,tab-space,trailing-space`, + `foonospace:no-space`, + `footabspace:tab-space`, + `footrailingspace:trailing-space`, + `foowrappedspace:wrapped-space`, + `host:mockAPI.mock-region.amazonaws.com`, + `x-amz-date:20211020T124200Z`, + ``, + `fooinnerspace;fooleadingspace;foomultiplespace;foonospace;footabspace;footrailingspace;foowrappedspace;host;x-amz-date`, + ``, + }, "\n") + if diff := cmpDiff(expectCanonicalString, build.CanonicalString); diff != "" { + t.Errorf("expect match, got\n%s", diff) + } +} + +func TestSigV4(t *testing.T) { + sigV4 := awsSigV4Filter{ + region: "us-east-1", + service: "dynamodb", + disableHeaderHoisting: false, + disableURIPathEscaping: false, + disableSessionToken: false, + } + + tests := []struct { + accessKey string + secret string + session string + timeOfSigning string + expectedSignature string + name string + }{ + { + accessKey: "", + secret: "some-invalid-secret", + session: "some-invalid-session", + timeOfSigning: "2012-11-01T22:08:41+00:00", + expectedSignature: "", + name: "No_access_key_supplied", + }, + { + accessKey: "some-invalid-accesskey", + secret: "", + session: "some-invalid-session", + timeOfSigning: "2012-11-01T22:08:41+00:00", + expectedSignature: "", + name: "No_secret_key_provided", + }, + { + accessKey: "some-access-key", + secret: "some-invalid-secret", + session: "", + timeOfSigning: "2012-11-01T22:08:41+00:00", + expectedSignature: "", + name: "No_session_key_supplied", + }, + { + accessKey: "some-access-key", + secret: "some-invalid-secret", + session: "some-invalid-session", + timeOfSigning: "", + expectedSignature: "", + name: "No_time_of_signing_supplied", + }, + { + accessKey: "some-access-key", + secret: "some-invalid-secret", + session: "some-invalid-session", + timeOfSigning: "2012-11-01T22:08:41+00", + expectedSignature: "", + name: "incorrect_format_of_time_of_signing_supplied", + }, + { + accessKey: "AKID", + secret: "SECRET", + session: "SESSION", + timeOfSigning: "1970-01-01T00:00:00Z", + expectedSignature: "AWS4-HMAC-SHA256 Credential=AKID/19700101/us-east-1/dynamodb/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-security-token;x-amz-target, Signature=a518299330494908a70222cec6899f6f32f297f8595f6df1776d998936652ad9", + name: "all_headers_supplied", + }, + } + + for _, test := range tests { + expectedSig := test.expectedSignature + headers := &http.Header{} + headers.Add("x-amz-accesskey", test.accessKey) + headers.Add("x-amz-secret", test.secret) + headers.Add("x-amz-session", test.session) + headers.Add("x-amz-time", test.timeOfSigning) + + headers.Add("X-Amz-Target", "prefix.Operation") + headers.Add("Content-Type", "application/x-amz-json-1.0") + headers.Add("X-Amz-Meta-Other-Header", "some-value=!@#$%^&* (+)") + headers.Add("X-Amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + headers.Add("X-Amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + ctx := buildfilterContext(sigV4.service, sigV4.region, "POST", strings.NewReader("{}"), "//example.org/bucket/key-._~,!@#$%^&*()", *headers) + + sigV4.Request(ctx) + + signature := ctx.Request().Header.Get(internal.AuthorizationHeader) + b, _ := io.ReadAll(ctx.Request().Body) + assert.Equal(t, string(b), "{}") // test that body remains intact + assert.Equal(t, expectedSig, signature, fmt.Sprintf("%s - test failed", test.name)) + } + +} + +func TestSigV4WithDisabledSessionToken(t *testing.T) { + + tests := []struct { + accessKey string + secret string + session string + timeOfSigning string + expectedSignature string + name string + disableSessionToken bool + }{ + { + accessKey: "some-token", + secret: "some-invalid-secret", + session: "", + timeOfSigning: "2012-11-01T22:08:41+00:00", + expectedSignature: "", + disableSessionToken: false, + name: "session_token_expected_but_not_supplied", + }, + { + accessKey: "AKID", + secret: "SECRET", + session: "", + timeOfSigning: "1970-01-01T00:00:00Z", + expectedSignature: "AWS4-HMAC-SHA256 Credential=AKID/19700101/us-east-1/dynamodb/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-session;x-amz-target, Signature=70ec16babd243ae915f100d0b63d5a0da2ff63c31d8631f1048b0441ab26743a", + disableSessionToken: true, + name: "session_token_not_expected_and_not_supplied", // x-amz-session header is treated as normal header and used to calculate signature + }, + { + accessKey: "AKID", + secret: "SECRET", + session: "SESSION", + timeOfSigning: "1970-01-01T00:00:00Z", + expectedSignature: "AWS4-HMAC-SHA256 Credential=AKID/19700101/us-east-1/dynamodb/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-security-token;x-amz-target, Signature=a4366bfb9558097b242ac243bf0e099267cbb657362495b5031c509644b2c3e9", + disableSessionToken: false, + name: "session_token_expected_and_supplied", // x-amz-session header is treated as session header and is removed after reading + }, + { + accessKey: "AKID", + secret: "SECRET", + session: "SESSION", + timeOfSigning: "1970-01-01T00:00:00Z", + expectedSignature: "AWS4-HMAC-SHA256 Credential=AKID/19700101/us-east-1/dynamodb/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-session;x-amz-target, Signature=2ee2d824eaead96cdec798f87530ce5269b59679038945608cfedf2d3622ad86", + disableSessionToken: true, + name: "session_token_not_expected_and_supplied", // x-amz-session header is treated as normal header and used to calculate signature + }, + } + for _, test := range tests { + sigV4 := awsSigV4Filter{ + region: "us-east-1", + service: "dynamodb", + disableHeaderHoisting: false, + disableURIPathEscaping: false, + } + sigV4.disableSessionToken = test.disableSessionToken + expectedSig := test.expectedSignature + headers := &http.Header{} + headers.Add("x-amz-accesskey", test.accessKey) + headers.Add("x-amz-secret", test.secret) + headers.Add("x-amz-session", test.session) + headers.Add("x-amz-time", test.timeOfSigning) + + headers.Add("X-Amz-Target", "prefix.Operation") + headers.Add("Content-Type", "application/x-amz-json-1.0") + headers.Add("X-Amz-Meta-Other-Header", "some-value=!@#$%^&* (+)") + headers.Add("X-Amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + headers.Add("X-Amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + ctx := buildfilterContext(sigV4.service, sigV4.region, "POST", strings.NewReader("{}"), "", *headers) + + sigV4.Request(ctx) + + signature := ctx.Request().Header.Get(internal.AuthorizationHeader) + b, _ := io.ReadAll(ctx.Request().Body) + assert.Equal(t, string(b), "{}") // test that body remains intact + assert.Equal(t, expectedSig, signature, fmt.Sprintf("%s - test failed", test.name)) + } + +} + +func TestSigV4WithNoBody(t *testing.T) { + sigV4 := awsSigV4Filter{ + region: "us-east-1", + service: "dynamodb", + disableHeaderHoisting: false, + disableURIPathEscaping: false, + disableSessionToken: false, + } + expectedSignature := "AWS4-HMAC-SHA256 Credential=AKID/19700101/us-east-1/dynamodb/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=f779ebb258e1692ca97a514c3a9fb99ad1b6f8869608f43ff66bc00d2c0400f5" + headers := &http.Header{} + headers.Add("x-amz-accesskey", "AKID") + headers.Add("x-amz-secret", "SECRET") + headers.Add("x-amz-session", "SESSION") + headers.Add("x-amz-time", "1970-01-01T00:00:00Z") + ctx := buildfilterContext(sigV4.service, sigV4.region, "POST", strings.NewReader(""), "", *headers) + sigV4.Request(ctx) + signature := ctx.Request().Header.Get(internal.AuthorizationHeader) + b, _ := io.ReadAll(ctx.Request().Body) + assert.Equal(t, string(b), "") // test that body remains intact + assert.Equal(t, expectedSignature, signature, "test with no body has failed") +} + +func buildfilterContext(serviceName string, region string, method string, body *strings.Reader, queryParams string, headers http.Header) filters.FilterContext { + endpoint := "https://" + serviceName + "." + region + ".amazonaws.com" + + r, _ := http.NewRequest(method, endpoint, body) + r.URL.Opaque = queryParams + r.Header = headers + return &filtertest.Context{FRequest: r} +} + +func cmpDiff(e, a interface{}) string { + if !reflect.DeepEqual(e, a) { + return fmt.Sprintf("%v != %v", e, a) + } + return "" +} + +func buildRequestWithBodyReader(serviceName, region string, body io.Reader) (*http.Request, string) { + var bodyLen int + + type lenner interface { + Len() int + } + if lr, ok := body.(lenner); ok { + bodyLen = lr.Len() + } + + endpoint := "https://" + serviceName + "." + region + ".amazonaws.com" + req, _ := http.NewRequest("POST", endpoint, body) + req.URL.Opaque = "//example.org/bucket/key-._~,!@#$%^&*()" + req.Header.Set("X-Amz-Target", "prefix.Operation") + req.Header.Set("Content-Type", "application/x-amz-json-1.0") + + if bodyLen > 0 { + req.ContentLength = int64(bodyLen) + } + + req.Header.Set("X-Amz-Meta-Other-Header", "some-value=!@#$%^&* (+)") + req.Header.Add("X-Amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + req.Header.Add("X-amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + + h := sha256.New() + _, _ = io.Copy(h, body) + payloadHash := hex.EncodeToString(h.Sum(nil)) + + return req, payloadHash +} + +func buildRequest(serviceName, region, body string) (*http.Request, string) { + reader := strings.NewReader(body) + return buildRequestWithBodyReader(serviceName, region, reader) +} diff --git a/filters/awssigner/internal/cache.go b/filters/awssigner/internal/cache.go new file mode 100644 index 0000000000..4244592eba --- /dev/null +++ b/filters/awssigner/internal/cache.go @@ -0,0 +1,96 @@ +package awssigner + +import ( + "strings" + "sync" + "time" +) + +type derivedKeyCache struct { + values map[string]derivedKey + mutex sync.RWMutex +} + +type derivedKey struct { + AccessKey string + Date time.Time + Credential []byte +} + +// SigningKeyDeriver derives a signing key from a set of credentials +type SigningKeyDeriver struct { + cache derivedKeyCache +} + +func NewSigningKeyDeriver() *SigningKeyDeriver { + return &SigningKeyDeriver{ + cache: newDerivedKeyCache(), + } +} + +// DeriveKey returns a derived signing key from the given credentials to be used with SigV4 signing. +func (k *SigningKeyDeriver) DeriveKey(credential Credentials, service, region string, signingTime SigningTime) []byte { + return k.cache.getSigningKey(credential, service, region, signingTime) +} + +// copied from https://github.com/aws/aws-sdk-go-v2/blob/v1.25.0/aws/signer/internal/v4/cache.go#L11 +func lookupKey(service, region string) string { + var s strings.Builder + s.Grow(len(region) + len(service) + 3) + s.WriteString(region) + s.WriteRune('/') + s.WriteString(service) + return s.String() +} + +func (s *derivedKeyCache) get(key string, credentials Credentials, signingTime time.Time) ([]byte, bool) { + cacheEntry, ok := s.retrieveFromCache(key) + if ok && cacheEntry.AccessKey == credentials.AccessKeyID && isSameDay(signingTime, cacheEntry.Date) { + return cacheEntry.Credential, true + } + return nil, false +} + +func (s *derivedKeyCache) retrieveFromCache(key string) (derivedKey, bool) { + if v, ok := s.values[key]; ok { + return v, true + } + return derivedKey{}, false +} + +func (s *derivedKeyCache) getSigningKey(credentials Credentials, service, region string, signingTime SigningTime) []byte { + key := lookupKey(service, region) + s.mutex.RLock() + if cred, ok := s.get(key, credentials, signingTime.Time); ok { + s.mutex.RUnlock() + return cred + } + s.mutex.RUnlock() + + s.mutex.Lock() + defer s.mutex.Unlock() + if cred, ok := s.get(key, credentials, signingTime.Time); ok { + return cred + } + cred := deriveKey(credentials.SecretAccessKey, service, region, signingTime) + entry := derivedKey{ + AccessKey: credentials.AccessKeyID, + Date: signingTime.Time, + Credential: cred, + } + s.values[key] = entry + return cred +} + +func deriveKey(secret, service, region string, t SigningTime) []byte { + hmacDate := HMACSHA256([]byte("AWS4"+secret), []byte(t.ShortTimeFormat())) + hmacRegion := HMACSHA256(hmacDate, []byte(region)) + hmacService := HMACSHA256(hmacRegion, []byte(service)) + return HMACSHA256(hmacService, []byte("aws4_request")) +} + +func newDerivedKeyCache() derivedKeyCache { + return derivedKeyCache{ + values: make(map[string]derivedKey), + } +} diff --git a/filters/awssigner/internal/constants.go b/filters/awssigner/internal/constants.go new file mode 100644 index 0000000000..af1e18ab03 --- /dev/null +++ b/filters/awssigner/internal/constants.go @@ -0,0 +1,25 @@ +package awssigner + +const AuthorizationHeader = "Authorization" +const doubleSpace = " " + +// AmzSignedHeadersKey is the set of headers signed for the request +const AmzSignedHeadersKey = "X-Amz-SignedHeaders" + +// AmzCredentialKey is the access key ID and credential scope +const AmzCredentialKey = "X-Amz-Credential" + +// TimeFormat is the time format to be used in the X-Amz-Date header or query parameter +const TimeFormat = "20060102T150405Z" + +const SigningAlgorithm = "AWS4-HMAC-SHA256" + +// ShortTimeFormat is the shorten time format used in the credential scope +const ShortTimeFormat = "20060102" + +const AmzAlgorithmKey = "X-Amz-Algorithm" + +const AmzDateKey = "X-Amz-Date" + +// AmzSecurityTokenKey indicates the security token to be used with temporary credentials +const AmzSecurityTokenKey = "X-Amz-Security-Token" diff --git a/filters/awssigner/internal/credentials.go b/filters/awssigner/internal/credentials.go new file mode 100644 index 0000000000..fab8e4eb70 --- /dev/null +++ b/filters/awssigner/internal/credentials.go @@ -0,0 +1,36 @@ +package awssigner + +import ( + "path" + "time" +) + +// Credentials is the type to represent AWS credentials +type Credentials struct { + // AccessKeyID is AWS Access key ID + AccessKeyID string + + // SecretAccessKey is AWS Secret Access Key + SecretAccessKey string + + // SessionToken is AWS Session Token + SessionToken string + + // Source of the AWS credentials + Source string + + // CanExpire states if the AWS credentials can expire or not. + CanExpire bool + + // Expires is the time the AWS credentials will expire at. Should be ignored if CanExpire is false. + Expires time.Time +} + +// BuildCredentialScope builds part of credential string to be used as X-Amz-Credential header or query parameter. +func BuildCredentialScope(signingTime SigningTime, region, service string) string { + return path.Join( + signingTime.ShortTimeFormat(), + region, + service, + "aws4_request") +} diff --git a/filters/awssigner/internal/headers.go b/filters/awssigner/internal/headers.go new file mode 100644 index 0000000000..7289846f56 --- /dev/null +++ b/filters/awssigner/internal/headers.go @@ -0,0 +1,233 @@ +package awssigner + +import ( + "net/http" + "sort" + "strconv" + "strings" +) + +// AllowedQueryHoisting is a whitelist for Build query headers. The boolean value +// represents whether or not it is a pattern. +var AllowedQueryHoisting = InclusiveRules{ + DenyList{RequiredSignedHeaders}, + Patterns{"X-Amz-"}, +} + +// InclusiveRules rules allow for rules to depend on one another +type InclusiveRules []Rule + +// IsValid will return true if all rules are true +func (r InclusiveRules) IsValid(value string) bool { + for _, rule := range r { + if !rule.IsValid(value) { + return false + } + } + return true +} + +// RequiredSignedHeaders is a whitelist for Build canonical headers. +var RequiredSignedHeaders = Rules{ + AllowList{ + MapRule{ + "Cache-Control": struct{}{}, + "Content-Disposition": struct{}{}, + "Content-Encoding": struct{}{}, + "Content-Language": struct{}{}, + "Content-Md5": struct{}{}, + "Content-Type": struct{}{}, + "Expires": struct{}{}, + "If-Match": struct{}{}, + "If-Modified-Since": struct{}{}, + "If-None-Match": struct{}{}, + "If-Unmodified-Since": struct{}{}, + "Range": struct{}{}, + "X-Amz-Acl": struct{}{}, + "X-Amz-Copy-Source": struct{}{}, + "X-Amz-Copy-Source-If-Match": struct{}{}, + "X-Amz-Copy-Source-If-Modified-Since": struct{}{}, + "X-Amz-Copy-Source-If-None-Match": struct{}{}, + "X-Amz-Copy-Source-If-Unmodified-Since": struct{}{}, + "X-Amz-Copy-Source-Range": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Algorithm": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-Md5": struct{}{}, + "X-Amz-Grant-Full-control": struct{}{}, + "X-Amz-Grant-Read": struct{}{}, + "X-Amz-Grant-Read-Acp": struct{}{}, + "X-Amz-Grant-Write": struct{}{}, + "X-Amz-Grant-Write-Acp": struct{}{}, + "X-Amz-Metadata-Directive": struct{}{}, + "X-Amz-Mfa": struct{}{}, + "X-Amz-Request-Payer": struct{}{}, + "X-Amz-Server-Side-Encryption": struct{}{}, + "X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Algorithm": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Key": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Key-Md5": struct{}{}, + "X-Amz-Storage-Class": struct{}{}, + "X-Amz-Website-Redirect-Location": struct{}{}, + "X-Amz-Content-Sha256": struct{}{}, + "X-Amz-Tagging": struct{}{}, + }, + }, + Patterns{"X-Amz-Meta-"}, +} + +// Patterns is a list of strings to match against +type Patterns []string + +// IsValid for Patterns checks each pattern and returns if a match has +// been found +func (p Patterns) IsValid(value string) bool { + for _, pattern := range p { + if HasPrefixFold(value, pattern) { + return true + } + } + return false +} + +func HasPrefixFold(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[0:len(prefix)], prefix) +} + +type AllowList struct { + Rule +} + +// DenyList is a generic Rule for blacklisting +type DenyList struct { + Rule +} + +// IsValid for AllowList checks if the value is within the AllowList +func (b DenyList) IsValid(value string) bool { + return !b.Rule.IsValid(value) +} + +// IsValid will iterate through all rules and see if any rules +// apply to the value and supports nested rules +func (r Rules) IsValid(value string) bool { + for _, rule := range r { + if rule.IsValid(value) { + return true + } + } + return false +} + +// IsValid for the MapRule satisfies whether it exists in the map +func (m MapRule) IsValid(value string) bool { + _, ok := m[value] + return ok +} + +// IsValid for AllowList checks if the value is within the AllowList +func (w AllowList) IsValid(value string) bool { + return w.Rule.IsValid(value) +} + +// IsValid for ExcludeList checks if the value is not within the ExcludeList +func (b ExcludeList) IsValid(value string) bool { + return !b.Rule.IsValid(value) +} + +func BuildCanonicalHeaders(host string, rule Rule, header http.Header, length int64) (signed http.Header, signedHeaders, canonicalHeadersStr string) { + signed = make(http.Header) + + var headers []string + const hostHeader = "host" + headers = append(headers, hostHeader) + signed[hostHeader] = append(signed[hostHeader], host) + + const contentLengthHeader = "content-length" + if length > 0 { + headers = append(headers, contentLengthHeader) + signed[contentLengthHeader] = append(signed[contentLengthHeader], strconv.FormatInt(length, 10)) + } + + for k, v := range header { + if !rule.IsValid(k) { + continue // ignored header + } + if strings.EqualFold(k, contentLengthHeader) { + // prevent signing already handled content-length header. + continue + } + + lowerCaseKey := strings.ToLower(k) + if _, ok := signed[lowerCaseKey]; ok { + // include additional values + signed[lowerCaseKey] = append(signed[lowerCaseKey], v...) + continue + } + + headers = append(headers, lowerCaseKey) + signed[lowerCaseKey] = v + } + sort.Strings(headers) + + signedHeaders = strings.Join(headers, ";") + + var canonicalHeaders strings.Builder + n := len(headers) + const colon = ':' + for i := 0; i < n; i++ { + if headers[i] == hostHeader { + canonicalHeaders.WriteString(hostHeader) + canonicalHeaders.WriteRune(colon) + canonicalHeaders.WriteString(StripExcessSpaces(host)) + } else { + canonicalHeaders.WriteString(headers[i]) + canonicalHeaders.WriteRune(colon) + // Trim out leading, trailing, and dedup inner spaces from signed header values. + values := signed[headers[i]] + for j, v := range values { + cleanedValue := strings.TrimSpace(StripExcessSpaces(v)) + canonicalHeaders.WriteString(cleanedValue) + if j < len(values)-1 { + canonicalHeaders.WriteRune(',') + } + } + } + canonicalHeaders.WriteRune('\n') + } + canonicalHeadersStr = canonicalHeaders.String() + + return signed, signedHeaders, canonicalHeadersStr +} + +// SanitizeHostForHeader removes default port from host and updates request.Host +func SanitizeHostForHeader(r *http.Request) { + host := getHost(r) + port := portOnly(host) + if port != "" && isDefaultPort(r.URL.Scheme, port) { + r.Host = stripPort(host) + } +} + +type Rule interface { + IsValid(value string) bool +} + +type Rules []Rule + +type ExcludeList struct { + Rule +} + +// MapRule generic Rule for maps +type MapRule map[string]struct{} + +var IgnoredHeaders = Rules{ + ExcludeList{ + MapRule{ + "Authorization": struct{}{}, + "User-Agent": struct{}{}, + "X-Amzn-Trace-Id": struct{}{}, + "Expect": struct{}{}, + }, + }, +} diff --git a/filters/awssigner/internal/hmac.go b/filters/awssigner/internal/hmac.go new file mode 100644 index 0000000000..14c19a3508 --- /dev/null +++ b/filters/awssigner/internal/hmac.go @@ -0,0 +1,12 @@ +package awssigner + +import ( + "crypto/hmac" + "crypto/sha256" +) + +func HMACSHA256(key []byte, data []byte) []byte { + hash := hmac.New(sha256.New, key) + hash.Write(data) + return hash.Sum(nil) +} diff --git a/filters/awssigner/internal/time.go b/filters/awssigner/internal/time.go new file mode 100644 index 0000000000..330896299a --- /dev/null +++ b/filters/awssigner/internal/time.go @@ -0,0 +1,50 @@ +package awssigner + +import ( + "time" +) + +type SigningTime struct { + time.Time + timeFormat string + shortTimeFormat string +} + +func (m *SigningTime) TimeFormat() string { + return m.format(&m.timeFormat, TimeFormat) +} + +// ShortTimeFormat provides a time formatted of 20060102. +func (m *SigningTime) ShortTimeFormat() string { + return m.format(&m.shortTimeFormat, ShortTimeFormat) +} + +func (m *SigningTime) format(target *string, format string) string { + if len(*target) > 0 { + return *target + } + v := m.Time.Format(format) + *target = v + return v +} + +func isSameDay(x, y time.Time) bool { + xYear, xMonth, xDay := x.Date() + yYear, yMonth, yDay := y.Date() + + if xYear != yYear { + return false + } + + if xMonth != yMonth { + return false + } + + return xDay == yDay +} + +func NewSigningTime(t time.Time) SigningTime { + return SigningTime{ + Time: t, + } +} diff --git a/filters/awssigner/internal/uri.go b/filters/awssigner/internal/uri.go new file mode 100644 index 0000000000..6c3304de71 --- /dev/null +++ b/filters/awssigner/internal/uri.go @@ -0,0 +1,122 @@ +package awssigner + +import ( + "bytes" + "fmt" + "net" + "net/http" + "net/url" + "strings" +) + +var noEscape [256]bool + +func InitializeEscape() { + for i := 0; i < len(noEscape); i++ { + // AWS expects every character except these to be escaped + noEscape[i] = (i >= 'A' && i <= 'Z') || + (i >= 'a' && i <= 'z') || + (i >= '0' && i <= '9') || + i == '-' || + i == '.' || + i == '_' || + i == '~' + } +} + +// EscapePath escapes part of a URL path in Amazon style. +func EscapePath(path string, encodeSep bool) string { + InitializeEscape() //TODO : is getting initialized every time + var buf bytes.Buffer + for i := 0; i < len(path); i++ { + c := path[i] + if noEscape[c] || (c == '/' && !encodeSep) { + buf.WriteByte(c) + } else { + fmt.Fprintf(&buf, "%%%02X", c) + } + } + return buf.String() +} + +func GetURIPath(u *url.URL) string { + var uriPath string + + if len(u.Opaque) > 0 { + const schemeSep, pathSep, queryStart = "//", "/", "?" + + opaque := u.Opaque + // Cut off the query string if present. + if idx := strings.Index(opaque, queryStart); idx >= 0 { + opaque = opaque[:idx] + } + + // Cutout the scheme separator if present. + if strings.Index(opaque, schemeSep) == 0 { + opaque = opaque[len(schemeSep):] + } + + // capture URI path starting with first path separator. + if idx := strings.Index(opaque, pathSep); idx >= 0 { + uriPath = opaque[idx:] + } + } else { + uriPath = u.EscapedPath() + } + + if len(uriPath) == 0 { + uriPath = "/" + } + + return uriPath +} + +// Hostname returns u.Host, without any port number. +// +// If Host is an IPv6 literal with a port number, Hostname returns the +// IPv6 literal without the square brackets. IPv6 literals may include +// a zone identifier. +// +// Copied from the Go 1.8 standard library (net/url) +func stripPort(hostport string) string { + colon := strings.IndexByte(hostport, ':') + if colon == -1 { + return hostport + } + if i := strings.IndexByte(hostport, ']'); i != -1 { + return strings.TrimPrefix(hostport[:i], "[") + } + return hostport[:colon] +} + +// Returns true if the specified URI is using the standard port +// (i.e. port 80 for HTTP URIs or 443 for HTTPS URIs) +func isDefaultPort(scheme, port string) bool { + if port == "" { + return true + } + + lowerCaseScheme := strings.ToLower(scheme) + if (lowerCaseScheme == "http" && port == "80") || (lowerCaseScheme == "https" && port == "443") { + return true + } + + return false +} + +// Returns host from request +func getHost(r *http.Request) string { + if r.Host != "" { + return r.Host + } + + return r.URL.Host +} + +func portOnly(hostport string) string { + _, port, err := net.SplitHostPort(hostport) + if err != nil { + return "" + } + return port +} diff --git a/filters/awssigner/internal/utils.go b/filters/awssigner/internal/utils.go new file mode 100644 index 0000000000..5505f70bd0 --- /dev/null +++ b/filters/awssigner/internal/utils.go @@ -0,0 +1,42 @@ +package awssigner + +import ( + "strings" +) + +func StripExcessSpaces(str string) string { + var j, k, l, m, spaces int + // Trim trailing spaces + for j = len(str) - 1; j >= 0 && str[j] == ' '; j-- { + } + + // Trim leading spaces + for k = 0; k < j && str[k] == ' '; k++ { + } + str = str[k : j+1] + + // Strip multiple spaces. + j = strings.Index(str, doubleSpace) + if j < 0 { + return str + } + + buf := []byte(str) + for k, m, l = j, j, len(buf); k < l; k++ { + if buf[k] == ' ' { + if spaces == 0 { + // First space. + buf[m] = buf[k] + m++ + } + spaces++ + } else { + // End of multiple spaces. + spaces = 0 + buf[m] = buf[k] + m++ + } + } + + return string(buf[:m]) +} diff --git a/filters/filters.go b/filters/filters.go index 612de0b723..1c554a95db 100644 --- a/filters/filters.go +++ b/filters/filters.go @@ -362,6 +362,7 @@ const ( OpaServeResponseName = "opaServeResponse" OpaServeResponseWithReqBodyName = "opaServeResponseWithReqBody" TLSName = "tlsPassClientCertificates" + AWSSigV4Name = "awsSigv4" // Undocumented filters HealthCheckName = "healthcheck"