Webhook receivers in AspNetCore


DISCLAIMER: The code in this post is by no means production ready. Please treat it as a research rather that anything else. Also please note this is based on a preview of the functionality rather than on an official release.

Although webhooks receivers didn’t make it into AspNetCore 2.1, the library is already in a pretty good shape. It works on top of the well-known AspNetCore.Mvc framework and greatly facilitates building your own receivers. In this article I am using the Microsoft.AspNetCore.WebHooks.Receivers.1.0.0-preview3-final version. The library comes with some out-of-the-box receivers for common webhooks providers like Dropbox, GitHub, WordPress, or Slack just to name the most popular. If you need to consume webhooks from those companies you’re already home. If you need a custom receiver please keep reading. Before we start coding I’d like to establish some background.

If you’re not yet familiar with webhooks the idea is quite simple. It’s like a reverse third-party API. Instead you pulling data from the third-party it is the third-party that pushes data to you so you can react to external events. In order to receive webhook messages you need to provide an exposed receiver’s URL to the pushing third-party. The third party usually provides you with a shared secret you should use to verify messages authenticity.

Webhooks receivers must meet some conditions in order to be secure. Firstly, they need to be exposed over HTTPS. Secondly, there must be a mechanism of verifying authenticity (and the origin) of incoming messages. Also receiver’s URL should be difficult to guess. Whereas setting up HTTPS and a difficult URL is trivial I will show some examples on authenticity verification.

Webhooks receivers should be fast. A good implementation should only verify authenticity of incoming messages, push them into some background processing mechanism (such as a message queue) and immediately return to the caller. It shouldn’t do any long-running processing blocking the call. Ensuring the above also makes your implementation simple leaving less room for errors. Some companies may implement a delivery-retry mechanism if delivery fails but you should ensure it succeeds at the first place.

Webhook receivers in AspNetCore are normal MVC controllers with actions decorated with custom attributes. Let’s declare our one as follows:

public class UnicornWebHookAttribute : WebHookAttribute
{
    public UnicornWebHookAttribute() : base(UnicornConstants.ReceiverName)
    {
    }
}

I simply inherit from WebHookAttribute providing receiver name defined in my constants class:

public static class UnicornConstants
{
    public const string ReceiverName => "jr4o27tr2r472";
    public const string SignatureHeaderName => "X-Sig-Header";
}

Then I use the attribute to decorate my controller actions:

public class WebhookController : ControllerBase
{
    [UnicornWebHook(Id = "teleported")]
    public async Task<IActionResult> Teleported(string @event, object data)
    {
        ///do something with the data
        return Ok();
    }

    [UnicornWebHook(Id = "fallen-asleep")]
    public async Task<IActionResult> FallenAsleep(string @event, object data)
    {
        ///do something with the data
        return Ok();
    }

    [UnicornWebHook(Id = "woken-up")]
    public async Task<IActionResult> WokenUp(string @event, object data)
    {
        ///do something with the data
        return Ok();
    }
}

Webhook receivers support many routing scenarios. In my scenario different kind of events have different URLs. That’s why I am setting the Id property. Given my receiver name is 'jr4o27tr2r472' and hostname 'queil.net' the actions will be routed as follows:

https://queil.net/api/webhooks/incoming/jr4o27tr2r472/teleported https://queil.net/api/webhooks/incoming/jr4o27tr2r472/fallen-asleep https://queil.net/api/webhooks/incoming/jr4o27tr2r472/woken-up

In this routing scenario @event will be null and can be ignored. The message payload is contained in data. For simplicity it’s just object here but you can set it to an actual message class. More information on routing.

The webhook implemented that way is not very secure as we do not know whether the incoming messages are authentic or not. You need to be aware this is just a public URL and (if not IP restricted) everyone that guesses it can just send any messages to it. A typical way to prevent that is to only accept messages sent together with a valid signature. The signature is usually a hash of the message payload combined with some secret information known only to your webhook provider and you. Once a message arrives you should take the payload, the secret, and generate the signature the same way your provider did. If they match you’re happy to accept it. This (+ HTTPS and a non-obvious URL) is the bare minimum as far as security is concerned.

There is the WebHookVerifySignatureFilter class created exactly for this scenario. It comes with a few handy methods you can use like GetRequestHeader (message signature is usually sent as a HTTP header), SecretEqual providing a time consistent comparison of 2 byte arrays (to compare incoming and generated message signatures), or FromBase64 to get a byte array out of a base-64 encoded header. There is also GetSecretKey to retrieve the shared secret from configuration (I’ve only noticed it afterwards so in the example I am using IOptions instead). Also there are a few methods for computing SHA1 and SHA256 hashes. No method for SHA512 but it’s easy enough to implement. My implementation of the filter looks like the following:

public class UnicornSignatureFilter : WebHookVerifySignatureFilter, 
                                      IAsyncResourceFilter
{
    private readonly byte[] _secret;
    public UnicornSignatureFilter(IOptions<UnicornConfig> options,
                                  IConfiguration configuration,
                                  IHostingEnvironment hostingEnvironment,
                                  ILoggerFactory loggerFactory) 
         : base(configuration, hostingEnvironment, loggerFactory)
    {
        _secret = Encoding.UTF8.GetBytes(options.Value.SharedSecret);
    }

    public override string ReceiverName => UnicornConstants.ReceiverName;

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context, 
                                               ResourceExecutionDelegate next)
    {
        if (context == null) throw new ArgumentNullException(nameof(context));
        if (next == null) throw new ArgumentNullException(nameof(next));

        var request = context.HttpContext.Request;
        if (!HttpMethods.IsPost(request.Method))
        {
            await next();
            return;
        }

        var errorResult = EnsureSecureConnection(ReceiverName, request);
        if (errorResult != null)
        {
            context.Result = errorResult;
            return;
        }

        var header = GetRequestHeader(request, 
                                      UnicornConstants.SignatureHeaderName, 
                                      out errorResult);
        if (errorResult != null)
        {
            context.Result = errorResult;
            return;
        }

        byte[] payload;
        using (var ms = new MemoryStream())
        {
            request.EnableRewind();
            await request.Body.CopyToAsync(ms);
            payload = ms.ToArray();
            request.Body.Position = 0;
        }

        if (payload == null || payload.Length == 0)
        {
            context.Result = new BadRequestObjectResult("No payload");
            return;
        }

        var digest = FromBase64(header, UnicornConstants.SignatureHeaderName);
        var secretPlusJson = _secret.Concat(payload).ToArray();

        using (var sha512 = new SHA512Managed())
        {
            if (!SecretEqual(sha512.ComputeHash(secretPlusJson), digest))
            {
                context.Result = 
                    new BadRequestObjectResult("Signature verification failed");
                return;
            }
        }

        await next();
    }
}

The implementation is quite straightforward. First I ensure the request is a POST over HTTPS. Then I retrieve the signature header (the header’s name is defined in my constants). Then I read the payload, compute its hash in the way agreed with the third-party and then compare it with the incoming one. If everything is ok I pass the execution further returning some error messages otherwise.

To make use of the filter just created we need to add it to a metadata class:

public class UnicornMetadata : WebHookMetadata, IWebHookFilterMetadata
{
    private readonly UnicornVerifySignatureFilter _verifySignatureFilter;

    public UnicornMetadata(UnicornVerifySignatureFilter verifySignatureFilter)
        : base(UnicornConstants.ReceiverName)
    {
        _verifySignatureFilter = verifySignatureFilter;
    }
    public override WebHookBodyType BodyType => WebHookBodyType.Json;

    public void AddFilters(WebHookFilterMetadataContext context)
    {
        context.Results.Add(_verifySignatureFilter);
    }
}

I am injecting the filter into my metadata class adding it to the metadata context. Also note I am setting metadata’s BodyType to JSON as my incoming requests have a JSON body indeed. Then the metadata class needs to be registered as follows:

public static class UnicornServiceCollectionSetup
{
    public static void AddUnicornServices(IServiceCollection services)
    {
        WebHookMetadata.Register<UnicornMetadata>(services);
        services.AddSingleton<UnicornVerifySignatureFilter>();
    }
}

The last bit is to include the above method in an IMvcCoreBuilder extension method:

public static class UnicornMvcCoreBuilderExtensions
{
    public static IMvcCoreBuilder AddUnicornWebHooks(this IMvcCoreBuilder builder)
    {
        UnicornServiceCollectionSetup.AddUnicornServices(builder.Services);
        return builder.AddWebHooks().AddJsonFormatters();
    }
}

Once this is done you need to add both MvcCore and your webhook method to ConfigureServices of your app’s startup class:

    services.AddMvcCore()
            .AddUnicornWebHooks();

Once you run the project it navigates to the browser showing you 404. Do not get misled as this is expected. In order to test the receiver you’ll need a tool sending HTTP requests (like Postman or curl) and the right URL. Your test URL depends on the routing scenario but by default (and I haven’t found a way of changing that easily) it will look like:

http[s]://localhost:{port}/api/webhooks/incoming/{receiver-name}/{id}

Setting ASPNETCORE_ENVIRONMENT to Development disables the HTTPS check so do not be surprised it works locally with HTTP. I recommend to test everything with HTTPS though (it’s easy enough as .NET Core provides self-signed development certs out of the box). Sending to the receiver you act as the third-party so you must sign your messages and add the right HTTP header with the computed signature. If everything is coded correctly your calls should first reach the verify signature filter and then the right controller action.

That would be it. This is just a quick overview and a basic example. If you require more information check this introduction out. It comes with some more detailed examples and explanations.