Reputation: 3520
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.
I plan to use the following:
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:
X-HubSpot-Signature-v3
) into the AuthorizationToken
field in CloudFront before forwarding to API Gateway.APIGatewayCustomAuthorizerRequest
doesn't include headers.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,
},
},
],
}
);
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