2

I'm new here and I'm hoping to get some guidance on the proper way to write this in Apex. I have a webhook coming from Hubspot and I'm receiving it successfully in Salesforce using an apex class exposed as a REST resource. My problem starts when I add a request signature in the header and then validate that in apex.

Hubspot provided examples on how to validating their v2 request signature in Python, Ruby and Node JS. This is the summary:

To verify this signature, perform the following steps:

  1. Create a string that concatenates together the following: Client secret + http method + URI + request body (if present)
  2. Create a SHA-256 hash of the resulting string.
  3. Compare the hash value to the signature.
  4. If they're equal then this request has passed validation. If these values do not match, then this request may have been tampered with in-transit or someone may be spoofing requests to your endpoint.

Notes:

  • The URI used to build the source string must exactly match the original request, including the protocol. If you're having trouble
    validating the signature, ensure that any query parameters are in the exact same order they were listed in the original request.
  • The source string should be UTF-8 encoded before calculating the SHA-256 hash.

Example for a request with a body:

Therefore, the source string would be: yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyPOSThttps://www.example.com/webhook_uri{"example_field":"example_value"}

And the resulting hash would be: 9569219f8ba981ffa6f6f16aa0f48637d35d728c7e4d93d0d52efaa512af7900

I tried out a few code snippets was able to use anonymous apex to get the intended result in their example:

String client_secret = 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy';
String http_method = 'POST';
String http_uri = 'https://www.example.com/webhook_uri';
String request_body = '{"example_field":"example_value"}';
String source_string = client_secret+http_method+http_uri+request_body;

Blob encrypted = Crypto.generateDigest('SHA-256', Blob.valueOf(source_string);
String myhex = EncodingUtil.convertToHex(encrypted);
System.debug(myhex);

9569219f8ba981ffa6f6f16aa0f48637d35d728c7e4d93d0d52efaa512af7900 <<- result

But when I try to use the same concept with the actual RestRequest, I can't seem to get the signature and my resulting hash to match.

@RestResource(urlMapping='/demo/*')
global with sharing class test2 {
    
    @HttpPost
    global static void handleNotification(){
        
        RestRequest request = RestContext.request;
        RestResponse response = RestContext.response;
        
        //get signature from the header
        String signature = request.headers.get('X-HubSpot-Signature-v3');
        
        //generate my source string
        String client_secret = 'XXXXXXX';
        String http_method = 'POST';
        String http_uri = 'https://myorg.sandbox.my.salesforce-sites.com/services/apexrest/demo';
        String requestbody = request.requestBody.toString();
        String source_string = client_secret+http_method+http_uri+requestbody;
        
        //start encoding
        Blob encrypted = Crypto.generateDigest('SHA-256', Blob.valueOf(source_string));
        String myHex = EncodingUtil.convertToHex(encrypted);
        
        RestContext.response.statusCode = myHex == signature ? 200 : 401;
        //I always get 401
       
    }
}

This is all new and fun but I've been stuck here for the better part of the day and in need of a nudge towards the right direction.

2
  • 1
    Welcome to SFSE! This is a good first question!
    – Derek F
    Commented Jan 26 at 0:03
  • Thank you for the warm welcome! You have been so helpful! Commented Jan 26 at 3:18

1 Answer 1

3

Looks to me like the process for validating the v3 signature (and it appears that you are using v3 based on the header you're trying to get) is different.

  • HMAC-SHA256 instead of SHA256
  • url decode the URI
  • hashing the method + uri + body + timestamp
  • using the client secret as the secret for the HMAC-SHA256 step
  • base64 encoding the result

I don't have any experience with hubspot myself, but the code for this should be something like

RestRequest request = RestContext.request;
        
String signature = request.headers.get('X-HubSpot-Signature-v3');
String timestamp = request.headers.get('X-HubSpot-Request-Timestamp');

// HubSpot says to reject the request if it's over 5 minutes old
Datetime fiveMinutesAgo = Datetime.now().addMinutes(-5);

// Earlier timestamps are lower numbers
// So if fiveMinutesAgo is greater than the given timestamp, we know
//   that the given timestamp is older than 5 minutes
if(fiveMinutesAgo.getTime() > Long.valueOf(timestamp)) {
    // request is too old
    // 400 probably isn't the right status code
    RestContext.response.statusCode = 400;
    return;
}

String client_secret = 'XXXXXXX';
String http_uri = String.format('{0}{1}{2}', new List<String>{
    // .toExternalForm() just gives us a string (.getOrgDomainUrl() returns
    //   a System.URL, and not a string)
    URL.getOrgDomainUrl().toExternalForm(),
    // should contain "/services/apexrest/demo/*" in your case, so you need to 
    //   get rid of the final asterisk
    request.resourcePath.removeEnd('*'),
    // You may or may not need to url-decode this bit of the URI
    request.requestURI
});

// Alternatively, the currentRequestUrl may save you some headaches
//String http_uri = URL.getCurrentRequestUrl().toExternalForm();

// Not a big deal in this case, but I do prefer String.format() over manually
//   concatenating strings
String source_string = String.format('{0}{1}{2}{3}', new List<String>{
    request.httpMethod,
    http_uri,
    request.requestBody.toString(),
    timestamp
});
        
Blob myMACBlob = Crypto.generateMac('hmacSHA256', Blob.valueOf(source_string), Blob.valueOf(client_secret));
String myMACBase64 = EncodingUtil.base64Encode(myMACBlob);

// String comparison using == is case-insensitive in Apex
// Although I imagine it would be a stroke of cosmic misfortune if
//   == would not suffice, it's better to just use String's .equals(), 
//   which is case-sensitive, to eliminate the possibility
RestContext.response.statusCode = signature.equals(myMACBase64) ? 200 : 401;

// An alternate method could be to use the verifyHMac() method
// Instead of encoding our generated MAC, we can simply decode the supplied signature
//Boolean isVerified = Crypto.verifyHMac('hmacSHA256', Blob.valueOf(source_string), Blob.valueOf(client_secret), EncodingUtil.base64Decode(signature));

Note for others

It appears that if you want/need to use the v1 or v2 signature verification, you need to check the X-HubSpot-Signature-Version header and use the signature from X-HubSpot-Signature instead of the one from X-HubSpot-Signature-v3.

The signature version will tell you if you need to use v1 or v2. In the case of v2, the original code given in the question should be correct. But, if you have the ability to use the v3 signature and verification, that's probably preferred.

5
  • the header request includes all 3 of these: X-HubSpot-Signature = some value X-HubSpot-Signature-Version = v2 X-HubSpot-Signature-v3 = some value The way I interpreted the help article was that if "X-HubSpot-Signature-Version" says v2 then I should use v2. I admit I was very confused since there was another header whose key included "v3". Commented Jan 26 at 2:35
  • Adding to my confusion is another one that's just signature without version. I definitely agree that I should try the v3 method and I will. Super appreciate the sample code, I will report back tomorrow! Commented Jan 26 at 2:42
  • Okay, I was too excited and couldn't wait to try it tonight, so I did. I re-read the help doc and realize the first mistake now - I did misinterpret as to which headers and version to use. The rest of the concepts, I am not familiar with so I will be sure to read up on them and expand my knowledge. I first tried your method of getting the uri using URL.getOrgDomainUrl() and removing the asterisk but I still got the 401. (added a missing comma but I think I need to url-decode as you suggested but I have not tired that) Commented Jan 26 at 3:15
  • 1
    Then I tried your alternative method using URL.getCurrentRequestUrl() and it worked!! 200 :) I did try to change one character on the client_secret to do a negative test and it failed beautifully. Thank you for explaining your code really well and sharing alternative ways to write them. I marked this answer as the best. Commented Jan 26 at 3:15
  • @SharinaGrace glad it worked out without too much additional trouble (and I did totally miss a comma in the String.format() for the http_uri, edited to fix). It might be worth putting in a system.debug() or two for you to figure out what the difference between that and getCurrentRequestUrl() is.
    – Derek F
    Commented Jan 26 at 3:19

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .