- 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.
252 lines
8.2 KiB
Perl
252 lines
8.2 KiB
Perl
use strict;
|
|
use warnings;
|
|
use Setup;
|
|
use Test2::V0;
|
|
use Catalyst::Test ();
|
|
use HTTP::Request;
|
|
use HTTP::Request::Common;
|
|
use JSON::MaybeXS qw(decode_json encode_json);
|
|
use Digest::SHA qw(hmac_sha256_hex);
|
|
|
|
sub is_json {
|
|
my ($response, $message) = @_;
|
|
|
|
my $data;
|
|
my $valid_json = lives { $data = decode_json($response->content); };
|
|
ok($valid_json, $message // "We get back valid JSON.");
|
|
if (!$valid_json) {
|
|
use Data::Dumper;
|
|
print STDERR Dumper $response->content;
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
my $ctx = test_context(hydra_config => qq|
|
|
<webhooks>
|
|
<github>
|
|
secret = test
|
|
</github>
|
|
</webhooks>
|
|
|);
|
|
Catalyst::Test->import('Hydra');
|
|
|
|
# Create a user to log in to
|
|
my $user = $ctx->db->resultset('Users')->create({ username => 'alice', emailaddress => 'alice@example.com', password => '!' });
|
|
$user->setPassword('foobar');
|
|
$user->userroles->update_or_create({ role => 'admin' });
|
|
|
|
# Login and save cookie for future requests
|
|
my $req = request(POST '/login',
|
|
Referer => 'http://localhost/',
|
|
Content => {
|
|
username => 'alice',
|
|
password => 'foobar'
|
|
}
|
|
);
|
|
is($req->code, 302, "The login redirects");
|
|
my $cookie = $req->header("set-cookie");
|
|
|
|
my $finishedBuilds = $ctx->makeAndEvaluateJobset(
|
|
expression => "one-job.nix",
|
|
build => 1
|
|
);
|
|
|
|
my $queuedBuilds = $ctx->makeAndEvaluateJobset(
|
|
expression => "one-job.nix",
|
|
build => 0
|
|
);
|
|
|
|
subtest "/api/queue" => sub {
|
|
my $response = request(GET '/api/queue?nr=1');
|
|
ok($response->is_success, "The API enpdoint showing the queue returns 200.");
|
|
|
|
my $data = is_json($response);
|
|
my $build = $queuedBuilds->{"one_job"};
|
|
like($data, [{
|
|
priority => $build->priority,
|
|
id => $build->id,
|
|
}]);
|
|
};
|
|
|
|
subtest "/api/latestbuilds" => sub {
|
|
subtest "with no specific parameters" => sub {
|
|
my $response = request(GET '/api/latestbuilds?nr=1');
|
|
ok($response->is_success, "The API enpdoint showing the latest builds returns 200.");
|
|
|
|
my $data = is_json($response);
|
|
my $build = $finishedBuilds->{"one_job"};
|
|
like($data, [{
|
|
buildstatus => $build->buildstatus,
|
|
id => $build->id,
|
|
}]);
|
|
};
|
|
|
|
subtest "with very specific parameters" => sub {
|
|
my $build = $finishedBuilds->{"one_job"};
|
|
my $projectName = $build->project->name;
|
|
my $jobsetName = $build->jobset->name;
|
|
my $jobName = $build->job;
|
|
my $system = $build->system;
|
|
my $response = request(GET "/api/latestbuilds?nr=1&project=$projectName&jobset=$jobsetName&job=$jobName&system=$system");
|
|
ok($response->is_success, "The API enpdoint showing the latest builds returns 200.");
|
|
|
|
my $data = is_json($response);
|
|
|
|
like($data, [{
|
|
buildstatus => $build->buildstatus,
|
|
id => $build->id,
|
|
}]);
|
|
};
|
|
};
|
|
|
|
subtest "/api/nrbuilds" => sub {
|
|
subtest "with no specific parameters" => sub {
|
|
my $response = request(GET '/api/nrbuilds?nr=1&period=hour');
|
|
ok($response->is_success, "The API enpdoint showing the latest builds returns 200.");
|
|
|
|
my $data = is_json($response);
|
|
is($data, [1]);
|
|
};
|
|
|
|
subtest "with very specific parameters" => sub {
|
|
my $build = $finishedBuilds->{"one_job"};
|
|
my $projectName = $build->project->name;
|
|
my $jobsetName = $build->jobset->name;
|
|
my $jobName = $build->job;
|
|
my $system = $build->system;
|
|
my $response = request(GET "/api/nrbuilds?nr=1&period=hour&project=$projectName&jobset=$jobsetName&job=$jobName&system=$system");
|
|
ok($response->is_success, "The API enpdoint showing the latest builds returns 200.");
|
|
|
|
my $data = is_json($response);
|
|
is($data, [1]);
|
|
};
|
|
};
|
|
|
|
subtest "/api/push" => sub {
|
|
subtest "with a specific jobset" => sub {
|
|
my $build = $finishedBuilds->{"one_job"};
|
|
my $jobset = $build->jobset;
|
|
my $projectName = $jobset->project->name;
|
|
my $jobsetName = $jobset->name;
|
|
is($jobset->forceeval, undef, "The existing jobset is not set to be forced to eval");
|
|
|
|
my $response = request(POST "/api/push?jobsets=$projectName:$jobsetName&force=1",
|
|
Cookie => $cookie,
|
|
Referer => 'http://localhost/',
|
|
);
|
|
ok($response->is_success, "The API enpdoint for triggering jobsets returns 200.");
|
|
|
|
my $data = is_json($response);
|
|
is($data, { jobsetsTriggered => [ "$projectName:$jobsetName" ] });
|
|
|
|
my $updatedJobset = $ctx->db->resultset('Jobsets')->find({ id => $jobset->id });
|
|
is($updatedJobset->forceeval, 1, "The jobset is now forced to eval");
|
|
};
|
|
|
|
subtest "with a specific source" => sub {
|
|
my $repo = $ctx->jobsdir;
|
|
my $jobsetA = $queuedBuilds->{"one_job"}->jobset;
|
|
my $jobsetB = $finishedBuilds->{"one_job"}->jobset;
|
|
|
|
is($jobsetA->forceeval, undef, "The existing jobset is not set to be forced to eval");
|
|
|
|
print STDERR $repo;
|
|
|
|
my $response = request(POST "/api/push?repos=$repo&force=1",
|
|
Cookie => $cookie,
|
|
Referer => 'http://localhost/',
|
|
);
|
|
ok($response->is_success, "The API enpdoint for triggering jobsets returns 200.");
|
|
|
|
my $data = is_json($response);
|
|
is($data, { jobsetsTriggered => [
|
|
"${\$jobsetA->project->name}:${\$jobsetA->name}",
|
|
"${\$jobsetB->project->name}:${\$jobsetB->name}"
|
|
] });
|
|
|
|
my $updatedJobset = $ctx->db->resultset('Jobsets')->find({ id => $jobsetA->id });
|
|
is($updatedJobset->forceeval, 1, "The jobset is now forced to eval");
|
|
};
|
|
};
|
|
|
|
subtest "/api/push-github" => sub {
|
|
# Create a project and jobset which looks like it comes from GitHub
|
|
my $user = $ctx->db()->resultset('Users')->create({
|
|
username => "api-push-github",
|
|
emailaddress => 'api-push-github@example.org',
|
|
password => ''
|
|
});
|
|
|
|
my $project = $ctx->db()->resultset('Projects')->create({
|
|
name => "api-push-github",
|
|
displayname => "api-push-github",
|
|
owner => $user->username
|
|
});
|
|
|
|
subtest "with a legacy input type" => sub {
|
|
my $jobset = $project->jobsets->create({
|
|
name => "legacy-input-type",
|
|
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/LEGACY-REPO.git"});
|
|
|
|
my $payload = encode_json({
|
|
repository => {
|
|
owner => {
|
|
name => "OWNER",
|
|
},
|
|
name => "LEGACY-REPO",
|
|
}
|
|
});
|
|
my $signature = "sha256=" . hmac_sha256_hex($payload, 'test');
|
|
|
|
my $req = POST '/api/push-github',
|
|
"Content-Type" => "application/json",
|
|
"X-Hub-Signature-256" => $signature,
|
|
"Content" => $payload;
|
|
|
|
my $response = request($req);
|
|
ok($response->is_success, "The API enpdoint for triggering jobsets returns 200.");
|
|
|
|
my $data = is_json($response);
|
|
is($data, { jobsetsTriggered => [ "api-push-github:legacy-input-type" ] }, "The correct jobsets are triggered.");
|
|
};
|
|
|
|
subtest "with a flake input type" => sub {
|
|
my $jobset = $project->jobsets->create({
|
|
name => "flake-input-type",
|
|
type => 1,
|
|
flake => "github:OWNER/FLAKE-REPO",
|
|
emailoverride => ""
|
|
});
|
|
|
|
my $payload = encode_json({
|
|
repository => {
|
|
owner => {
|
|
name => "OWNER",
|
|
},
|
|
name => "FLAKE-REPO",
|
|
}
|
|
});
|
|
my $signature = "sha256=" . hmac_sha256_hex($payload, 'test');
|
|
|
|
my $req = POST '/api/push-github',
|
|
"Content-Type" => "application/json",
|
|
"X-Hub-Signature-256" => $signature,
|
|
"Content" => $payload;
|
|
|
|
my $response = request($req);
|
|
ok($response->is_success, "The API enpdoint for triggering jobsets returns 200.");
|
|
|
|
my $data = is_json($response);
|
|
is($data, { jobsetsTriggered => [ "api-push-github:flake-input-type" ] }, "The correct jobsets are triggered.");
|
|
};
|
|
};
|
|
|
|
done_testing;
|