Validate and Secure GitHub Webhooks In C# With ASP.NET Core MVC

GitHub webhooks is fantastic tool for a website to retrieve information from GitHub repositories and display it to users, the moment something changes. But how can I use it with ASP.NET Core MVC, how does security work in that regards, and how can we prevent other sources to post invalid or fake webhook messages to the public endpoint of our website?

Note: There is a pretty great project for all kinds of webhooks build by the ASP.NET team. Unfortunately, it is not ported to ASP.NET Core yet, but it might be coming soon. This blog post will not be about this library though, it is more an example for learning things or a simple solution if you don't want to integrate another library.

Getting Started

First of all, GitHub needs a public endpoint where it can post the information to. To be able to test webhooks, setup a simple ASP.NET Core website and host it on Azure, for example.

  1. Setup an ASP.NET Core webapi Website. We will need only a single web service endpoint and do not need any actual frontend. To do so, call either create a directory somewhere and call dotnet new webapi, or open Visual Studio and pick the Web API template

    Create New WebApi Project

  2. Create a simple MVC Controller with one HttpPost endpoint which for now just have to return a 200 OK response.

    (You can also just re-use the generated ValuesController)

[Route("[Controller]")]
public class GitHookController : Controller
{
    [HttpPost("")]
    public async Task<IActionResult> Receive()
    {
        return Ok();
    }
}
  1. Publish the website

    I'm not going into too much detail of how to publish or host a site (off topic), but as explained, the site must be public.

    Publish the new WebSite

  2. Setup the webhook

    In the settings menu of your GitHub repository which should send notifications to the website, go to Webhooks and click Add Webhook.

    Setup Webhook

    • The Payload Url should be set to the public endpoint of your website. Using our example code from above, that would be http://<mywebsite>/GitHook.

    • Set Content type to application/json. You can also use other settings here, but JSON is easy to consume later on.

    • Enter a value for the Secret.

      GitHub will use the secret to create a SHA1 hash of the content it sends to the endpoint and send this hash along with the payload. We will use that hash later to validate the request.

      This is a security critical part, choose a strong password for this.

    • Leave everything else unchanged for now

    • Click Add Webhook.

    • Click on the newly created webhook, if everything was setup correctly, at the bottom of the page you should see a valid result with a 200 response code from your service.

Validate the Request

In the controller's post method, the first thing we want to do is, read the header values and the body content of the request GitHub sends us.

There are three headers which are interesting for us:

  • The event name. According to our configuration, this should always be push. Although the test tool sends a ping message for testing. Just keep that in mind before you throw exceptions. Just handle the ping, too.
  • The signature. As already said, this is the hash GitHub computed over the body of the message.
  • Delivery is a unique id GitHub generates for each request.

To get the header values, we can use the Request property of the controller:

using Microsoft.Extensions.Primitives;
...

Request.Headers.TryGetValue("X-GitHub-Event", out StringValues eventName);
Request.Headers.TryGetValue("X-Hub-Signature", out StringValues signature);
Request.Headers.TryGetValue("X-GitHub-Delivery", out StringValues delivery);

To get the content, simply use the Request.Body which is a stream, and get the full text value. We need all the text to re-compute the hash ourselves:

using (var reader = new StreamReader(Request.Body))
{
    var txt = await reader.ReadToEndAsync();
    
    if (IsGithubPushAllowed(txt, eventName, signature))
    {
        ...
}

Important to note: we cannot use the MVC model binding for this. We could use [FromBody] object payload parameter in the Receive method to get the deserialized version. Problem is, we want to read the body content later to get the original bytes to validate it against the signature. If we serialize the model again, that eventually produces a different string and the computed hash could be different!

Finally, we have everything to actually validate the request.

Request Signature Validation

Earlier on the GitHub webhook configuration page, we defined a secret for the webhook. Now we need this secret again to hash the content and compare that hash against the signature.

It is very important to get this right, otherwise the validation would fail. The signature GitHub sent us is the "HMAC hex digest" of the payload according to the documentation.

This means, GitHub computed a SHA hash and formatted the bytes of the hash as hex string. The signature is also prefixed with the kind of SHA hash algorithm GitHub used. The signature looks like sha1=<40chars....>. For SHA1, the signature is 40 characters (20 bytes) + 5 (prefix) long.

To compute the same hash:

  • create a new System.Security.Cryptography.HMACSHA1 instance with the secret used as key. Again, it is important the secret used here is the same as GitHub uses.
  • use the resulting byte[] of ComputeHash to convert it to hex string

Full example:

private const string Sha1Prefix = "sha1=";

[HttpPost("")]
public async Task<IActionResult> Receive()
{
    Request.Headers.TryGetValue("X-GitHub-Event", out StringValues eventName);
    Request.Headers.TryGetValue("X-Hub-Signature", out StringValues signature);
    Request.Headers.TryGetValue("X-GitHub-Delivery", out StringValues delivery);

    using (var reader = new StreamReader(Request.Body))
    {
        var txt = await reader.ReadToEndAsync();

        if (IsGithubPushAllowed(txt, eventName, signature))
        {
            return Ok();
        }
    }

    return Unauthorized();
}

private bool IsGithubPushAllowed(string payload, string eventName, string signatureWithPrefix)
{
    if (string.IsNullOrWhiteSpace(payload))
    {
        throw new ArgumentNullException(nameof(payload));
    }
    if (string.IsNullOrWhiteSpace(eventName))
    {
        throw new ArgumentNullException(nameof(eventName));
    }
    if (string.IsNullOrWhiteSpace(signatureWithPrefix))
    {
        throw new ArgumentNullException(nameof(signatureWithPrefix));
    }

    /* test if the eventName is ok if you want 
    if (!eventName.Equals("push", StringComparison.OrdinalIgnoreCase))
    {
        ...
    } */

    if (signatureWithPrefix.StartsWith(Sha1Prefix, StringComparison.OrdinalIgnoreCase))
    {
        var signature = signatureWithPrefix.Substring(Sha1Prefix.Length);
        var secret = Encoding.ASCII.GetBytes(_tokenOptions.Value.ServiceSecret);
        var payloadBytes = Encoding.ASCII.GetBytes(payload);

        using (var hmSha1 = new HMACSHA1(secret))
        {
            var hash = hmSha1.ComputeHash(payloadBytes);

            var hashString = ToHexString(hash);

            if (hashString.Equals(signature))
            {
                return true;
            }
        }
    }

    return false;
}


public static string ToHexString(byte[] bytes)
{
    var builder = new StringBuilder(bytes.Length * 2);
    foreach (byte b in bytes)
    {
        builder.AppendFormat("{0:x2}", b);
    }

    return builder.ToString();
}

Now, we can react on invalid calls by return either 404 NotFound or 401 for unauthorized, both make sense and depends on how you want your API to work.

Test and Develop with WebHooks

To quickly test if the code works, have the Receive method return some message like Ok("works!"). Publish the website again and go back to the GitHub webhook site. Under Recent Deliveries click Redeliver, you should now get the new message back.

Recent Deliveries

Works!

Ok, now we have the basics set up and the webhook is working. How do we actually develop against that? GitHub cannot send us requests into our local development environment, also, we might not want to push commits to the repository every time for debugging purpose.

To get test data, we can use the GitHub webhook configuration UI again. At the bottom of the page, there is a list of recent events sent to our endpoint:

Recent Deliveries

Expanding it will actually show us the full request with headers and also the body payload:

Recent Deliveries

Unfortunately, the content is formatted. But if you have a tool which can minimize it, like Notepad++ with the JavaScript plugin, this will do.

Alternatively, you could also remote debug the website if the host supports it, or just return the body and headers via MVC controller action...

Now we have test data and can call our service as often as we want with it!

Postman

To post the payload with headers, there are many tools you can use, I prefer Postman.

With Postman, it becomes really easy to setup requests and send them to different environments even.

Copy over the header values from the GitHub page:

Recent Deliveries

Copy the minified JSON content into the Body after changing the content type to raw + application/JSON:

Recent Deliveries

Configuring the Secret

Earlier in the code example, I already used some configuration to get the secret. In case you do not want to hardcode the secret and check it into source control, it is advisable to read it from configuration.

A simple way which also works on Azure or other hosts, are environment variables. Although environment variables are not exactly secure, for this example it is enough. An alternative to make it even more secure would be key vaults, like Azure Key Vault or Vault from Hashicorp for example.

In the Startup.cs of the website, make sure to add .AddEnvironmentVariables(); to the configuration builder

var builder = new ConfigurationBuilder()
    .SetBasePath(env.ContentRootPath)
    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
    .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
    .AddEnvironmentVariables();
Configuration = builder.Build();

To get the secret working on Azure, go to the Web App's Application Settings>App Settings tab and enter the variable name ServiceSecret and the value.

For reading and passing the configuration around, I will use Microsoft.Extensions.Options and a simple POCO object.

public class TokenOptions
{
    public string ServiceSecret { get; set; }
}

In ConfigureServices add the options services and configure the POCO.

services.AddOptions();
services.Configure<TokenOptions>(Configuration);

Hint: Make sure the key you use as an environment variable can be bound to the POCO. Alternatively, just read the value from configuration directly with Configuration.GetValue<string>(key).

Final step is to inject IOptions<TokenOptions> to the controller and use it later in the code like _tokenOptions.Value.ServiceSecret.

private readonly IOptions<TokenOptions> _tokenOptions;

public GitHookController(IOptions<TokenOptions> tokenOptions)
{
    _tokenOptions = tokenOptions ?? throw new ArgumentNullException(nameof(tokenOptions));
}

Republish the site and see if it works!


That's it. From now on we can play with the received data, derserialize the body payload into POCOs or work with the Linq version from Newtonsoft.Json.

Hint: You can also copy and paste JSON and have Visual Studio generate classes for you Edit>Paste Special>Paste JSON as classes. The quality of this is low though and you might also receive very different results from GitHub depending on the event type. But it is a start.

The full project is also available on GitHub.

What are your thoughts? Let me know in the comments!