webhooks: implement authentication for GitHub and Gitea
- Add HMAC-SHA256 signature verification for webhooks - Support multiple secrets for rotation - Add security logging for authentication events - Maintain backward compatibility (auth optional during migration) - Add comprehensive test coverage Without authentication, anyone could trigger job evaluations by sending POST requests to webhook endpoints. This could lead to resource exhaustion through repeated requests or manipulation of build scheduling. While not a data breach risk, it allows unauthorized control over CI/CD operations.
This commit is contained in:
@@ -12,6 +12,8 @@ use DateTime;
|
||||
use Digest::SHA qw(sha256_hex);
|
||||
use Text::Diff;
|
||||
use IPC::Run qw(run);
|
||||
use Digest::SHA qw(hmac_sha256_hex);
|
||||
use String::Compare::ConstantTime qw(equals);
|
||||
|
||||
|
||||
sub api : Chained('/') PathPart('api') CaptureArgs(0) {
|
||||
@@ -274,13 +276,84 @@ sub push : Chained('api') PathPart('push') Args(0) {
|
||||
);
|
||||
}
|
||||
|
||||
sub verifyWebhookSignature {
|
||||
my ($c, $platform, $header_name, $signature_prefix) = @_;
|
||||
|
||||
# Get secrets from config
|
||||
my $webhook_config = $c->config->{webhooks} // {};
|
||||
my $platform_config = $webhook_config->{$platform} // {};
|
||||
my $secrets = $platform_config->{secret};
|
||||
|
||||
# Normalize to array
|
||||
$secrets = [] unless defined $secrets;
|
||||
$secrets = [$secrets] unless ref($secrets) eq 'ARRAY';
|
||||
|
||||
# Trim whitespace from secrets
|
||||
my @secrets = grep { defined && length } map { s/^\s+|\s+$//gr } @$secrets;
|
||||
|
||||
if (@secrets) {
|
||||
my $signature = $c->request->header($header_name);
|
||||
|
||||
if (!$signature) {
|
||||
$c->log->warn("Webhook authentication failed for $platform: Missing signature from IP " . $c->request->address);
|
||||
$c->response->status(401);
|
||||
$c->stash->{json} = { error => "Missing webhook signature" };
|
||||
$c->forward('View::JSON');
|
||||
return 0;
|
||||
}
|
||||
|
||||
# Get the raw body content from the buffered PSGI input
|
||||
# For JSON requests, Catalyst will have already read and buffered the body
|
||||
my $input = $c->request->env->{'psgi.input'};
|
||||
$input->seek(0, 0);
|
||||
local $/;
|
||||
my $payload = <$input>;
|
||||
$input->seek(0, 0); # Reset for any other consumers
|
||||
|
||||
unless (defined $payload && length $payload) {
|
||||
$c->log->warn("Webhook authentication failed for $platform: Empty request body from IP " . $c->request->address);
|
||||
$c->response->status(400);
|
||||
$c->stash->{json} = { error => "Empty request body" };
|
||||
$c->forward('View::JSON');
|
||||
return 0;
|
||||
}
|
||||
|
||||
my $valid = 0;
|
||||
for my $secret (@secrets) {
|
||||
my $expected = $signature_prefix . hmac_sha256_hex($payload, $secret);
|
||||
if (equals($signature, $expected)) {
|
||||
$valid = 1;
|
||||
last;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$valid) {
|
||||
$c->log->warn("Webhook authentication failed for $platform: Invalid signature from IP " . $c->request->address);
|
||||
$c->response->status(401);
|
||||
$c->stash->{json} = { error => "Invalid webhook signature" };
|
||||
$c->forward('View::JSON');
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
} else {
|
||||
$c->log->warn("Webhook authentication failed for $platform: Unable to validate signature from IP " . $c->request->address . " because no secrets are configured");
|
||||
$c->response->status(401);
|
||||
$c->stash->{json} = { error => "Invalid webhook signature" };
|
||||
$c->forward('View::JSON');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
sub push_github : Chained('api') PathPart('push-github') Args(0) {
|
||||
my ($self, $c) = @_;
|
||||
|
||||
$c->{stash}->{json}->{jobsetsTriggered} = [];
|
||||
|
||||
return unless verifyWebhookSignature($c, 'github', 'X-Hub-Signature-256', 'sha256=');
|
||||
|
||||
my $in = $c->request->{data};
|
||||
my $owner = $in->{repository}->{owner}->{name} or die;
|
||||
my $owner = ($in->{repository}->{owner}->{name} // $in->{repository}->{owner}->{login}) or die;
|
||||
my $repo = $in->{repository}->{name} or die;
|
||||
print STDERR "got push from GitHub repository $owner/$repo\n";
|
||||
|
||||
@@ -297,6 +370,9 @@ sub push_gitea : Chained('api') PathPart('push-gitea') Args(0) {
|
||||
|
||||
$c->{stash}->{json}->{jobsetsTriggered} = [];
|
||||
|
||||
# Note: Gitea doesn't use sha256= prefix
|
||||
return unless verifyWebhookSignature($c, 'gitea', 'X-Gitea-Signature', '');
|
||||
|
||||
my $in = $c->request->{data};
|
||||
my $url = $in->{repository}->{clone_url} or die;
|
||||
$url =~ s/.git$//;
|
||||
|
Reference in New Issue
Block a user