diff --git a/doc/manual/src/webhooks.md b/doc/manual/src/webhooks.md index 2b26cd612..82d278a93 100644 --- a/doc/manual/src/webhooks.md +++ b/doc/manual/src/webhooks.md @@ -1,13 +1,40 @@ # Webhooks -Hydra can be notified by github's webhook to trigger a new evaluation when a -jobset has a github repo in its input. -To set up a github webhook go to `https://github.com///settings` and in the `Webhooks` tab +## GitHub +Hydra can be notified by GitHub's webhook to trigger a new evaluation when a +jobset has a GitHub repo in its input. + +GitHub's webhook can be triggered on [various events](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads). Hydra recognizes the following events. + + - [`push`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#push): triggers a new evaluation for every jobset that have the GitHub repository as a "Git Checkout" input. + - [`create`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#create) and [`delete`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#deleta): triggers a new evaluation for every jobset that have the GitHub repository as a "github_refs" input. + - [`pull_request`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#pull_request): triggers a new evaluation for every jobset that have the GitHub repository as a "githubpulls" input. + +### Guide + +To set up a GitHub webhook go to `https://github.com///settings` and in the `Webhooks` tab click on `Add webhook`. -- In `Payload URL` fill in `https:///api/push-github`. +- In `Payload URL` fill in `https:///api/webhook-github`. - In `Content type` switch to `application/json`. -- The `Secret` field can stay empty. -- For `Which events would you like to trigger this webhook?` keep the default option for events on `Just the push event.`. +- The `Secret` field can stay empty (see below to configure a secret). +- For `Which events would you like to trigger this webhook?` either keep the default option, or select the ones you are interested in (see above for the supported events). Then add the hook with `Add webhook`. + +### Securing GitHub's webhooks +Secrets for webhooks can be configured by adding `github_webhook` keys in your Hydra configuration. +Each `github_webhook` provides a secret (`secret`, a string) for a certain range of repository name (`repo`, a regex) and repository owner (`owner`, a regex). + +For instance below we declare one secret, `foo`, for the repositories whose owner is `someone` or `someother` and is named `somerepo`. + +**IMPORTANT**: note that secrets should **never** be included directly in your `hydra.conf`, otherwise they will be exposed in plain text in the store. Instead, use includes [as described here](./configuration.html#including-files). + +```xml + + owner = (someone|someother) + repo = somerepo + secret = foo + +``` + diff --git a/src/lib/Hydra/Controller/API.pm b/src/lib/Hydra/Controller/API.pm index 6f10ef575..4771de99c 100644 --- a/src/lib/Hydra/Controller/API.pm +++ b/src/lib/Hydra/Controller/API.pm @@ -9,9 +9,13 @@ use Hydra::Helper::CatalystUtils; use Hydra::Controller::Project; use JSON::MaybeXS; use DateTime; -use Digest::SHA qw(sha256_hex); +use Digest::SHA qw(sha256 sha256_hex); +use Digest::HMAC qw(hmac_hex); +use String::Compare::ConstantTime; +use File::Slurper qw(read_text); use Text::Diff; use IPC::Run qw(run); +use List::Util 'first'; sub api : Chained('/') PathPart('api') CaptureArgs(0) { @@ -267,24 +271,76 @@ sub push : Chained('api') PathPart('push') Args(0) { ); } -sub push_github : Chained('api') PathPart('push-github') Args(0) { +sub webhook_github : Chained('api') PathPart('webhook-github') Args(0) { my ($self, $c) = @_; $c->{stash}->{json}->{jobsetsTriggered} = []; my $in = $c->request->{data}; - my $owner = $in->{repository}->{owner}->{name} or die; - my $repo = $in->{repository}->{name} or die; - print STDERR "got push from GitHub repository $owner/$repo\n"; - - triggerJobset($self, $c, $_, 0) foreach $c->model('DB::Jobsets')->search( - { 'project.enabled' => 1, 'me.enabled' => 1 }, - { join => 'project' - , where => \ [ 'me.flake like ? or exists (select 1 from JobsetInputAlts where project = me.project and jobset = me.name and value like ?)', [ 'flake', "%github%$owner/$repo%"], [ 'value', "%github.com%$owner/$repo%" ] ] - }); + + # every GitHub webhook payload has the `repository` key and a `X-GitHub-Event` header + my $event = $c->req->header('X-GitHub-Event') or die; + my $owner = ( $in->{repository}->{owner}->{name} + // $in->{repository}->{owner}->{login}) or die; + my $repo = $in->{repository}->{name} or die; + + print STDERR "got event '$event' from GitHub repository $owner/$repo\n"; + + { # Verify X-Hub-Signature-256 if secret was defined in config + my $cfg = $c->config->{github_webhook}; + my @config = defined $cfg ? ref $cfg eq "ARRAY" ? @$cfg : ($cfg) : (); + my $rule = first { $owner =~ /^$_->{owner}$/ && $repo =~ /^$_->{repo}$/ } @config; + + if (defined $rule) { + my $sig = $c->req->header('X-Hub-Signature-256'); + die "X-Hub-Signature-256 is missing, but a secret was defined for GitHub repository $owner/$repo" + unless defined $sig; + my $body = read_text($c->req->body) or die; + my $secret = $rule->{secret} or die; + my $digest = hmac_hex($body, $secret, \&sha256); + die "Request body digest (${digest}) did not match X-Hub-Signature-256 (${sig})" + unless String::Compare::ConstantTime::equals($sig, "sha256=$digest"); + } else { + print STDERR "no secret given for webhook comming from GitHub repository $owner/$repo"; + } + } + + # `jobsetsOfInputs type value` finds the jobsets that have an input of type `type` and of value LIKE `value` + my $jobsetsOfInputs = sub { + my ($type, $value) = @_; + $c->model('DB::Jobsets')->search( + { 'jobsetinputs.type' => $type, 'project.enabled' => 1, 'me.enabled' => 1 }, + { join => ['project', {'jobsetinputs' => 'jobsetinputalts'}], + where => \ [ ' LOWER( jobsetinputalts.value ) LIKE LOWER ( ? ) ', [ 'value', $value] ] + }) + }; + + # Different SQL queries according the kind of `$event` we are dealing with + my $actions = { + create => sub {$jobsetsOfInputs->('github_refs', "$owner $repo %")}, + delete => sub {$jobsetsOfInputs->('github_refs', "$owner $repo %")}, + pull_request => sub {$jobsetsOfInputs->('githubpulls', "$owner $repo")}, + push => sub { + $c->model('DB::Jobsets')->search( + { 'project.enabled' => 1, 'me.enabled' => 1 }, + { join => 'project', where => \ [ + 'me.flake like ? or exists (select 1 from JobsetInputAlts where project = me.project and jobset = me.name and value like ?)', + [ 'flake', "%github%$owner/$repo%"], + [ 'value', "%github.com%$owner/$repo%" ] + ] }) + } + }; + + triggerJobset($self, $c, $_, 0) foreach ($actions->{$event} // sub { + die "Cannot handle GitHub event [$event]"; + })->(); + $c->response->body(""); } - +sub push_github : Chained('api') PathPart('push-github') Args(0) { + print STDERR 'The endpoint [/api/push_github] is deprecated in favor of [/api/webhook_github]'; + webhook_github @_ +} 1; diff --git a/src/lib/Hydra/Controller/Root.pm b/src/lib/Hydra/Controller/Root.pm index c6843d296..c6879ddde 100644 --- a/src/lib/Hydra/Controller/Root.pm +++ b/src/lib/Hydra/Controller/Root.pm @@ -32,6 +32,7 @@ sub noLoginNeeded { return $whitelisted || $c->request->path eq "api/push-github" || + $c->request->path eq "api/webhook-github" || $c->request->path eq "google-login" || $c->request->path eq "github-redirect" || $c->request->path eq "github-login" || @@ -77,7 +78,9 @@ sub begin :Private { $_->supportedInputTypes($c->stash->{inputTypes}) foreach @{$c->hydra_plugins}; # XSRF protection: require POST requests to have the same origin. - if ($c->req->method eq "POST" && $c->req->path ne "api/push-github") { + if ($c->req->method eq "POST" && ( + $c->req->path ne "api/push-github" && $c->req->path ne "api/webhook-github" + )) { my $referer = $c->req->header('Referer'); $referer //= $c->req->header('Origin'); my $base = $c->req->base; diff --git a/t/Hydra/Controller/API/checks.t b/t/Hydra/Controller/API/checks.t index 2b97b4895..712c63e81 100644 --- a/t/Hydra/Controller/API/checks.t +++ b/t/Hydra/Controller/API/checks.t @@ -169,6 +169,7 @@ subtest "/api/push-github" => sub { my $req = POST '/api/push-github', "Content-Type" => "application/json", + "X-GitHub-Event" => "push", "Content" => encode_json({ repository => { owner => { @@ -195,6 +196,7 @@ subtest "/api/push-github" => sub { my $req = POST '/api/push-github', "Content-Type" => "application/json", + "X-GitHub-Event" => "push", "Content" => encode_json({ repository => { owner => {