- 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.
210 lines
6.7 KiB
Perl
210 lines
6.7 KiB
Perl
use strict;
|
|
use warnings;
|
|
use Setup;
|
|
use Test2::V0;
|
|
use Test2::Tools::Subtest qw(subtest_streamed);
|
|
use HTTP::Request;
|
|
use HTTP::Request::Common;
|
|
use JSON::MaybeXS qw(decode_json encode_json);
|
|
use Digest::SHA qw(hmac_sha256_hex);
|
|
|
|
# Create webhook configuration
|
|
my $github_secret = "github-test-secret-12345";
|
|
my $github_secret_alt = "github-alternative-secret";
|
|
my $gitea_secret = "gitea-test-secret-abcdef";
|
|
|
|
# Create a temporary directory first to get the path
|
|
use File::Temp;
|
|
my $tmpdir = File::Temp->newdir(CLEANUP => 0);
|
|
my $tmpdir_path = $tmpdir->dirname;
|
|
|
|
# Write webhook secrets configuration before creating test context
|
|
mkdir "$tmpdir_path/hydra-data";
|
|
|
|
# Create webhook secrets configuration file
|
|
my $webhook_config = qq|
|
|
<github>
|
|
secret = $github_secret
|
|
secret = $github_secret_alt
|
|
</github>
|
|
<gitea>
|
|
secret = $gitea_secret
|
|
</gitea>
|
|
|;
|
|
write_file("$tmpdir_path/hydra-data/webhook-secrets.conf", $webhook_config);
|
|
chmod 0600, "$tmpdir_path/hydra-data/webhook-secrets.conf";
|
|
|
|
# Create test context with webhook configuration using include
|
|
my $ctx = test_context(
|
|
tmpdir => $tmpdir,
|
|
hydra_config => qq|
|
|
<webhooks>
|
|
Include $tmpdir_path/hydra-data/webhook-secrets.conf
|
|
</webhooks>
|
|
|
|
|
);
|
|
|
|
# Import Catalyst::Test after test context is set up
|
|
require Catalyst::Test;
|
|
Catalyst::Test->import('Hydra');
|
|
|
|
# Create a project and jobset for testing
|
|
my $user = $ctx->db()->resultset('Users')->create({
|
|
username => "webhook-test",
|
|
emailaddress => 'webhook-test@example.org',
|
|
password => ''
|
|
});
|
|
|
|
my $project = $ctx->db()->resultset('Projects')->create({
|
|
name => "webhook-test",
|
|
displayname => "webhook-test",
|
|
owner => $user->username
|
|
});
|
|
|
|
my $jobset = $project->jobsets->create({
|
|
name => "test-jobset",
|
|
nixexprinput => "src",
|
|
nixexprpath => "default.nix",
|
|
emailoverride => ""
|
|
});
|
|
|
|
my $jobsetinput = $jobset->jobsetinputs->create({name => "src", type => "git"});
|
|
$jobsetinput->jobsetinputalts->create({altnr => 0, value => "https://github.com/owner/repo.git"});
|
|
|
|
# Create another jobset for Gitea
|
|
my $jobset_gitea = $project->jobsets->create({
|
|
name => "test-jobset-gitea",
|
|
nixexprinput => "src",
|
|
nixexprpath => "default.nix",
|
|
emailoverride => ""
|
|
});
|
|
|
|
my $jobsetinput_gitea = $jobset_gitea->jobsetinputs->create({name => "src", type => "git"});
|
|
$jobsetinput_gitea->jobsetinputalts->create({altnr => 0, value => "https://gitea.example.com/owner/repo.git"});
|
|
|
|
subtest "GitHub webhook authentication" => sub {
|
|
my $payload = encode_json({
|
|
repository => {
|
|
owner => { name => "owner" },
|
|
name => "repo"
|
|
}
|
|
});
|
|
|
|
subtest "without authentication - properly rejects" => sub {
|
|
my $req = POST '/api/push-github',
|
|
"Content-Type" => "application/json",
|
|
"Content" => $payload;
|
|
|
|
my $response = request($req);
|
|
is($response->code, 401, "Unauthenticated request is rejected");
|
|
|
|
my $data = decode_json($response->content);
|
|
is($data->{error}, "Missing webhook signature", "Proper error message for missing signature");
|
|
};
|
|
|
|
subtest "with valid signature" => sub {
|
|
my $signature = "sha256=" . hmac_sha256_hex($payload, $github_secret);
|
|
|
|
my $req = POST '/api/push-github',
|
|
"Content-Type" => "application/json",
|
|
"X-Hub-Signature-256" => $signature,
|
|
"Content" => $payload;
|
|
|
|
my $response = request($req);
|
|
is($response->code, 200, "Valid signature is accepted");
|
|
|
|
if ($response->code != 200) {
|
|
diag("Error response: " . $response->content);
|
|
}
|
|
|
|
my $data = decode_json($response->content);
|
|
is($data->{jobsetsTriggered}, ["webhook-test:test-jobset"], "Jobset was triggered with valid authentication");
|
|
};
|
|
|
|
subtest "with invalid signature" => sub {
|
|
my $signature = "sha256=" . hmac_sha256_hex($payload, "wrong-secret");
|
|
|
|
my $req = POST '/api/push-github',
|
|
"Content-Type" => "application/json",
|
|
"X-Hub-Signature-256" => $signature,
|
|
"Content" => $payload;
|
|
|
|
my $response = request($req);
|
|
is($response->code, 401, "Invalid signature is rejected");
|
|
|
|
my $data = decode_json($response->content);
|
|
is($data->{error}, "Invalid webhook signature", "Proper error message for invalid signature");
|
|
};
|
|
|
|
subtest "with second valid secret (multiple secrets configured)" => sub {
|
|
my $signature = "sha256=" . hmac_sha256_hex($payload, $github_secret_alt);
|
|
|
|
my $req = POST '/api/push-github',
|
|
"Content-Type" => "application/json",
|
|
"X-Hub-Signature-256" => $signature,
|
|
"Content" => $payload;
|
|
|
|
my $response = request($req);
|
|
is($response->code, 200, "Second valid secret is accepted");
|
|
};
|
|
};
|
|
|
|
subtest "Gitea webhook authentication" => sub {
|
|
my $payload = encode_json({
|
|
repository => {
|
|
owner => { username => "owner" },
|
|
name => "repo",
|
|
clone_url => "https://gitea.example.com/owner/repo.git"
|
|
}
|
|
});
|
|
|
|
subtest "without authentication - properly rejects" => sub {
|
|
my $req = POST '/api/push-gitea',
|
|
"Content-Type" => "application/json",
|
|
"Content" => $payload;
|
|
|
|
my $response = request($req);
|
|
is($response->code, 401, "Unauthenticated request is rejected");
|
|
|
|
my $data = decode_json($response->content);
|
|
is($data->{error}, "Missing webhook signature", "Proper error message for missing signature");
|
|
};
|
|
|
|
subtest "with valid signature" => sub {
|
|
# Note: Gitea doesn't use sha256= prefix
|
|
my $signature = hmac_sha256_hex($payload, $gitea_secret);
|
|
|
|
my $req = POST '/api/push-gitea',
|
|
"Content-Type" => "application/json",
|
|
"X-Gitea-Signature" => $signature,
|
|
"Content" => $payload;
|
|
|
|
my $response = request($req);
|
|
is($response->code, 200, "Valid signature is accepted");
|
|
|
|
if ($response->code != 200) {
|
|
diag("Error response: " . $response->content);
|
|
}
|
|
|
|
my $data = decode_json($response->content);
|
|
is($data->{jobsetsTriggered}, ["webhook-test:test-jobset-gitea"], "Jobset was triggered with valid authentication");
|
|
};
|
|
|
|
subtest "with invalid signature" => sub {
|
|
my $signature = hmac_sha256_hex($payload, "wrong-secret");
|
|
|
|
my $req = POST '/api/push-gitea',
|
|
"Content-Type" => "application/json",
|
|
"X-Gitea-Signature" => $signature,
|
|
"Content" => $payload;
|
|
|
|
my $response = request($req);
|
|
is($response->code, 401, "Invalid signature is rejected");
|
|
|
|
my $data = decode_json($response->content);
|
|
is($data->{error}, "Invalid webhook signature", "Proper error message for invalid signature");
|
|
};
|
|
};
|
|
|
|
done_testing;
|