Under the hood of ASP.NET Core WebHooks - Signature Validation

In general WebHooks don't use any kind of authentication or authorization. The delivery is based on unique (preferably unguessable) URLs. But, in unlikely circumstances of such a URL leaking it would expose the receiver to unwanted content. This is why it has become a standard practice for WebHooks to send a signature (hash) of content in dedicated header so receiver can validate it as consistency check. In most cases the signature is a HMAC hash of payload, where a shared secret value is used as key. Validating such signature will be subject of my fourth post in Under the hood of ASP.NET Core WebHooks series:

The signature generation and verification is not officially standardized, which results in implementation differences between various WebHooks. Because of that, instead of providing 'one size fits all' solution, ASP.NET Core WebHooks approach is for every receiver to have its own implementation. This approach is supported by WebHookVerifySignatureFilter base class which gives developers a set of shared tools. With its help the signature can be verified in few, not too complicated steps.

Step 1 - Ensuring secure connection

This step is not directly connected to signature validation. It is also not required. But from privacy perspective it's strongly encouraged. In order to prevent exposing the payload to third parties, WebHooks requests should be using HTTPS. Ensuring that connection is secure is as simple as calling EnsureSecureConnection method. If check fails the method will return an IActionResult which should be used to short-circuit the execution.

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public override string ReceiverName => "websub";

    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        IActionResult secureConnectionCheckResult = EnsureSecureConnection(ReceiverName,
            context.HttpContext.Request);
        if (secureConnectionCheckResult != null)
        {
            context.Result = secureConnectionCheckResult;
            return;
        }

        ...
    }
}

The behaviour of EnsureSecureConnection is configurable through WebHooks:DisableHttpsCheck configuration key (also the check is disabled when ASPNETCORE_ENVIRONMENT is set to development).

Step 2 - Retrieving signature generated by sender

Retrieving desired header value is as simple as calling GetRequestHeader method. It will even prepare erroneous response if the header is not present.

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        if (HttpMethods.IsPost(context.HttpContext.Request.Method))
        {
            string signatureHeader = GetRequestHeader(request, "X-Hub-Signature",
                out IActionResult erroneousResult);
            if (erroneousResult != null)
            {
                context.Result = erroneousResult;
                return;
            }

            ...
        }

        ...
    }
}

But the header value rarely contains only the signature. Usually it carries additional information, like for example identifier of used algorithm. The task of parsing the header value is left to the developers. If the value can be split into tokens based on separators and you are looking for inspiration, the TrimmingTokenizer is a good place to start. It's used by several receivers but not publicly exposed.

After extracting signature from header value there is one more thing which needs to be done. The signature must be encoded to Base64 or hexadecimal representation so it can be put into header. Decoding is covered by FromBase64 and FromHex methods (there are also CreateBadBase64EncodingResult and CreateBadHexEncodingResult methods for creating responses when decoding fails).

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        if (HttpMethods.IsPost(context.HttpContext.Request.Method))
        {
            ...

            byte[] expectedSignature = FromHex(parsedSignature, "X-Hub-Signature");
            if (expectedSignature == null)
            {
                context.Result = CreateBadHexEncodingResult("X-Hub-Signature");
                return;
            }

            ...
        }

        ...
    }
}

Step 3 - Computing your own signature

The WebHookVerifySignatureFilter provides ready to use implementation of signature calculation for SHA-1 and SHA-256 algorithms. If one of those algorithms is sufficient for given receiver needs it is enough to call ComputeRequestBodySha1HashAsync or ComputeRequestBodySha1HashAsync and pass request and the secret value as parameters. If different algorithms are needed (for example SHA-384 or SHA-512) it requires a little bit more effort (which makes one wish for more generic implementation, maybe with option for providing HMAC instance through parameter). The additional effort involves managing the request body stream. The current stream position must be pointing to its beginning before and after the hashing. If the stream doesn't support seeking, it requires enabling request buffering.

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        if (HttpMethods.IsPost(context.HttpContext.Request.Method))
        {
            ...

            byte[] actualSignature = await ComputeRequestBodySha512HashAsync(request,
                Encoding.UTF8.GetBytes(secret));
            if (actualSignature == null)
            {
                context.Result = new BadRequestResult();
                return;
            }

            ...
        }

        ...
    }

    private static async Task<byte[]> ComputeRequestBodySha512HashAsync(HttpRequest request,
        byte[] secret)
    {
        await PrepareRequestBody(request);

        using (HMACSHA512 hasher = new HMACSHA512(secret))
        {
            try
            {
                Stream inputStream = request.Body;

                int bytesRead;
                byte[] buffer = new byte[4096];

                while ((bytesRead = await inputStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
                {
                    hasher.TransformBlock(buffer, inputOffset: 0, inputCount: bytesRead,
                        outputBuffer: null, outputOffset: 0);
                }

                hasher.TransformFinalBlock(Array.Empty(), inputOffset: 0, inputCount: 0);

                return hasher.Hash;
            }
            finally
            {
                request.Body.Seek(0L, SeekOrigin.Begin);
            }
        }
    }

    private static async Task PrepareRequestBody(HttpRequest request)
    {
        if (!request.Body.CanSeek)
        {
            request.EnableBuffering();

            await request.Body.DrainAsync(CancellationToken.None);
        }

        request.Body.Seek(0L, SeekOrigin.Begin);
    }
}

Step 4 - Comparing signatures

We have actual and expected signatures, all that is left is to compare them. It is important not to forget about the security while doing this. Naive implementation could left the receiver open for timing attacks. Here we should use SecretEqual method which provides a time consistent comparison.

public class WebSubWebHookSecurityFilter : WebHookVerifySignatureFilter, IAsyncResourceFilter
{
    ...

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        ...

        if (HttpMethods.IsPost(context.HttpContext.Request.Method))
        {
            ...

            if (!SecretEqual(expectedSignature, actualSignature))
            {
                context.Result = CreateBadSignatureResult("X-Hub-Signature");
                return;
            }
        }

        ...
    }
}

Of course CreateBadSignatureResult is provided for us as well.