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 Startedlink
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.
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 calldotnet new webapi
, or open Visual Studio and pick theWeb API
templateCreate a simple MVC Controller with one
HttpPost
endpoint which for now just have to return a200 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();
}
}
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.
Setup the webhook
In the settings menu of your GitHub repository which should send notifications to the website, go to
Webhooks
and clickAdd Webhook
.The
Payload Url
should be set to the public endpoint of your website. Using our example code from above, that would behttp://<mywebsite>/GitHook
.Set
Content type
toapplication/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 Requestlink
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 aping
message for testing. Just keep that in mind before you throw exceptions. Just handle theping
, 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 theReceive
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 Validationlink
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 askey
. Again, it is important the secret used here is the same as GitHub uses. - use the resulting
byte[]
ofComputeHash
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 WebHookslink
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.
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:
Expanding it will actually show us the full request with headers and also the body payload:
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!
Postmanlink
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:
Copy the minified JSON content into the Body
after changing the content type to raw + application/JSON
:
Configuring the Secretlink
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!