Ghost: Securing Webhooks

Ghost: Securing Webhooks
Photo by Elin Melaas / Unsplash

Life in security-first environments shifts your perspective on many things, even if you don't want that. This story explains how to control the Ghost blog and webhooks message exchange.

You may have read my resentment post about moving from Zappier to Webmethods.io. In the middle of the implementation, I realized that Ghost custom integration protects only inbound API calls and does not offer documentation on outbound integrations.

Custom Event Listener Form

As you can see, all you have is a small form with a name, blog event, webhook URI, and a mysterious secret. As soon as I enable integration flow endpoint protection, communication between my development site and the flow breaks.

The secret value has nothing to do with the Webmethod.io security key and does not impact the message's content. This makes me suspect my lack of knowledge in the area is much greater than I expected. It has taken some research on Standard Webhooks (I don't think the name makes them an industry standard) and a beneficial resource - Webhooks FYI.

At this moment, I realized that the only way to verify the message's origin and content authenticity was to use the message header X-Ghost-Signature. The platform's source code confirmed that the engine uses an HMAC signature to compute the header value from the body, nonce, and secret key (from the form).

Message Headers from Ghost Webhook Event

Webmethods.io Integration doesn't have HMAC actions but offers a custom Node.js action with whitelisted packages and input/output interfaces. The code below receives an event body, the secret, and the HMAC sha256 header to confirm the source and integrity of the incoming event.


var request = require ("request");
var crypto = require ("crypto");

module.exports = function(){

    this.id = "ghost-verify-event"; 

    this.label = "Ghost Signature Validator"; 

    this.help = "The action validates the source of the event and message integrity."; 

    this.input = {
        "title": "Test Secret",
        "type": "object",
        "properties": {
            "webhook_secret": {
                "title": "WebHook Secret",// displayed as field label  
                "type": "string",
                "format": "password",
                "description":"Enter the same value, used for ˝host webhook.",// description of field
                "minLength": 1 // define as required
            },
            "webhook_signature": {
                "title": "WebHook Signature",// displayed as field label  
                "type": "string",
                "description":"Value of the X-Ghost-Signature header",// description of field
                "minLength": 1 // define as required
            },
            "webhook_body": {
                "title": "WebHook Request",// displayed as field label  
                "type": "any",
                "format": "textarea",
                "description":"The body of the request",// description of field
                "minLength": 1 // define as required
            }
        }
    }; 

    this.output = {
        "title" : "output",
        "type" : "object",
        "properties":{
            "status":{
                "title":"status",
                "type" :"boolean"
            }
        }
    }; 


    this.execute = function(input,output){
        parsedSignature = {};
        input.webhook_signature.split(",")
         .forEach(part => {
             const [key,value] = part.trim().split("=");
             parsedSignature[key]=value  ;
         });
        $log(`Hash in Header : [${parsedSignature.sha256}]`);
        var tyHash = Buffer.from(parsedSignature.sha256);
        var myHash = Buffer.from(
                        crypto.createHmac("sha256",input.webhook_secret) _  
      .update(`${JSON.stringify(input.webhook_body)}${parsedSignature.t}`) _
                                 .digest("hex"));
        $log(`Received hash  : ${JSON.stringify(tyHash)}`);
        $log(`Calculated hash: ${JSON.stringify(myHash)}`);
        
        return output(null, {"status": crypto.timingSafeEqual(myHash,tyHash)}); 
    };
};

Ghost WebHook Signature Validator

The code describes input (three properties), output (One boolean result), and action executor. This part uses input values to:

  • Parse the X-Ghost-Signature header
  • Compute the new SHA-256 hash using the body, webhook secret, and the incoming nonce t.
  • Compare signature and computed has using function crypto.timeSafeEqual.

Now, my integration's first action uses a secret key to validate the message header and ensure that my social network accounts are not flooded with spam or worse.