khinester
khinester

Reputation: 3520

How to Validate HubSpot Webhooks in CloudFront + API Gateway Architecture?

I am implementing a request verification solution for webhooks sent from HubSpot to an API Gateway behind a CloudFront distribution. According to HubSpot's documentation (https://developers.hubspot.com/docs/guides/apps/authentication/validating-requests#validate-the-v3-request-signature), we can validate that requests originate from HubSpot by creating an HMAC SHA-256 hash using the body of the request, a secret, and a specific header.

Current Architecture:

My Approach:

I plan to use the following:

  1. CloudFront Key-Value Store: To store the HubSpot secret.
  2. CloudFront Function: To fetch the secret from the Key-Value Store and attach it as a header to the request forwarded to API Gateway.
  3. API Gateway Authorizer Lambda: To validate the request by:
    • Computing an HMAC SHA-256 hash based on the secret, request body, and headers.
    • Comparing the computed hash with the one sent by HubSpot.

The Issue:

API Gateway's Lambda authorizer expects an AuthorizationToken field in the request event. However, the webhook from HubSpot doesn't send this field. Instead, it includes the required headers in the request.

To validate these requests, I have explored:

For now my authorizer function just checks the headers

package main

import (
    "encoding/json"
    "errors"
    "net/http"

    "go.uber.org/zap"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

var logger *zap.Logger

func init() {
    var err error
    logger, err = zap.NewProduction()
    if err != nil {
        panic("failed to initialize zap logger: " + err.Error())
    }
}

func main() {
    defer logger.Sync()
    lambda.Start(handler)
}

func generatePolicy(principalId, effect, resource string) events.APIGatewayCustomAuthorizerResponse {
    authResponse := events.APIGatewayCustomAuthorizerResponse{PrincipalID: principalId}

    if effect != "" && resource != "" {
        authResponse.PolicyDocument = events.APIGatewayCustomAuthorizerPolicy{
            Version: "2012-10-17",
            Statement: []events.IAMPolicyStatement{
                {
                    Action:   []string{"execute-api:Invoke"},
                    Effect:   effect,
                    Resource: []string{resource},
                },
            },
        }
    }
    return authResponse
}

func handler(event events.APIGatewayCustomAuthorizerRequest) (events.APIGatewayCustomAuthorizerResponse, error) {
    requiredHeaders := []string{"X-HubSpot-Signature-v3", "X-HubSpot-Request-Timestamp", "x-hubspot-client-secret"}

    logger.Info("authorization token received", zap.String("token", event.AuthorizationToken))

    // Parse the token as JSON containing the headers
    headers := make(map[string]string)
    err := json.Unmarshal([]byte(event.AuthorizationToken), &headers)
    if err != nil {
        logger.Error("invalid token format; expected JSON headers", zap.Error(err))
        return generatePolicy("user", "Deny", event.MethodArn), errors.New("unauthorized: invalid token format")
    }

    logger.Info("extracted headers from token", zap.Any("headers", headers))

    // Normalize and validate headers
    normalizedHeaders := make(map[string]string)
    for key, value := range headers {
        normalizedHeaders[http.CanonicalHeaderKey(key)] = value
    }

    for _, header := range requiredHeaders {
        if _, ok := normalizedHeaders[http.CanonicalHeaderKey(header)]; !ok {
            logger.Warn("missing required header", zap.String("header", header))
            return generatePolicy("user", "Deny", event.MethodArn), nil
        }
    }

    logger.Info("all required headers are present")
    return generatePolicy("user", "Allow", event.MethodArn), nil
}

When I send a request, all the headers are being passed correctly but I get an error:

{"level":"info","ts":1735301608.268554,"caller":"infra/hubspot.go:88","msg":"Received response","status":"500 Internal Server Error","headers":{"Content-Length":["16"],"Content-Type":["application/json"],"Date":["Fri, 27 Dec 2024 12:13:28 GMT"],"Via":["1.1 ****.cloudfront.net (CloudFront), 1.1 ***.cloudfront.net (CloudFront)"],"X-Amz-Apigw-Id":["Dcz8UGpMjoEEaRA="],"X-Amz-Cf-Id":["***"],"X-Amz-Cf-Pop":["LHR50-P4","LHR50-P7"],"X-Amzn-Errortype":["AuthorizerConfigurationException"],"X-Amzn-Requestid":["1cf63117-3b21-49ea-8160-5d73eb9f3758"],"X-Cache":["Error from cloudfront"]}}
{"level":"info","ts":1735301608.268808,"caller":"infra/hubspot.go:96","msg":"Response body","body":"{\"message\":null}"}

Here is part of the stack.ts that sets up the authorizer on API Gateway:

...
    // Add authoriser
    const hubSpotValidateV3RequestSignatureMetadata = loadLambdaMetadata(
      path.join(__dirname, '../../../src/lambda/hubspot-signature-validator'),
    )
    const hubSpotValidateV3RequestSignatureFn = createLambdaFunction(
      this,
      'hubspot-signature-validator',
      hubSpotValidateV3RequestSignatureMetadata,
    )
    const hubSpotValidateV3RequestSignature = new RequestAuthorizer(this, 'HubSpotValidateV3RequestSignature', {
      handler: hubSpotValidateV3RequestSignatureFn,
      identitySources: [
        IdentitySource.header('X-HubSpot-Signature-v3'),
        IdentitySource.header('X-HubSpot-Request-Timestamp'),
        IdentitySource.header('x-hubspot-client-secret'),
      ],
    });
    
    const hubspotResource = api.root.addResource('hs');
    hubspotResource.addMethod(
      'POST',
      new AwsIntegration({
        service: 'events',
        action: 'PutEvents',
        integrationHttpMethod: 'POST',
        options: {
          credentialsRole: apiGatewayToEventBridgeRole,
          passthroughBehavior: PassthroughBehavior.WHEN_NO_TEMPLATES,
          requestTemplates: {
            'application/json': `
              #set($context.requestOverride.header.X-Amz-Target = "AWSEvents.PutEvents")
              #set($context.requestOverride.header.Content-Type = "application/x-amz-json-1.1")
              {
                "Entries": [
                  {
                    "Source": "tld.domain.hubspot",
                    "DetailType": "API Call",
                    "Detail": "$util.escapeJavaScript($input.body)",
                    "EventBusName": "${localBus.eventBusName}"
                  }
                ]
              }
            `,
          },
          integrationResponses: [
            {
              // Map success response
              statusCode: '200',
              responseTemplates: {
                'application/json': `
                  {
                    "message": "success",
                    "requestId": "$context.requestId"
                  }
                `,
              },
            },
            {
              // Map client error responses (bad request)
              statusCode: '400',
              selectionPattern: '4\\d{2}.*',
              responseTemplates: {
                'application/json': '{"status": "error", "message": "Bad request"}',
              },
            },
            {
              // Map unauthorized responses
              statusCode: '401',
              selectionPattern: '401.*',
              responseTemplates: {
                'application/json': '{"status": "error", "message": "Unauthorized"}',
              },
            },
            {
              // Map EventBridge error response
              statusCode: '500',
              selectionPattern: '.*UnknownOperationException.*',
              responseTemplates: {
                'application/json': '{"status": "error", "message": "Unknown operation error"}',
              },
            },
            {
              // Map generic server errors
              statusCode: '500',
              selectionPattern: '5\\d{2}.*',
              responseTemplates: {
                'application/json': '{"status": "error", "message": "$input.path(\'$.ErrorMessage\')"}',
              },
            },
          ],
        },
      }),
      {
        authorizer: hubSpotValidateV3RequestSignature,
        methodResponses: [
          {
            statusCode: '200',
            responseModels: {
              'application/json': Model.EMPTY_MODEL,
            },
          },
          {
            statusCode: '400',
            responseModels: {
              'application/json': Model.ERROR_MODEL,
            },
          },
          {
            statusCode: '500',
            responseModels: {
              'application/json': Model.ERROR_MODEL,
            },
          },
        ],
      }
    );

Questions:

  1. What is the best practice for implementing such a verification mechanism using CloudFront and API Gateway?
  2. Should I re-purpose the AuthorizationToken field to include the required headers, or is there a better way to validate HubSpot's webhook requests within this architecture?

Any guidance or examples would be greatly appreciated!

Upvotes: 0

Views: 32

Answers (0)

Related Questions