Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions app/Http/Controllers/GitHubWebhookController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

namespace App\Http\Controllers;

use CDash\Model\Repository;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;

final class GitHubWebhookController extends AbstractController
{
public function __invoke(Request $request): Response
{
$secret = config('cdash.github_webhook_secret');
if ($secret !== null) {
self::verifySignature($request, (string) $secret);
}

// Note: json() decodes the body regardless of the request's Content-Type.
switch ($request->header('X-GitHub-Event', '')) {
case 'check_run':
// Avoid an infinite loop of reacting to our own activity.
if ($request->json('check_run.name') !== 'CDash' || $request->json('action') === 'rerequested') {
Repository::createOrUpdateCheck($request->json('check_run.head_sha'));
}
break;
case 'status':
Repository::createOrUpdateCheck($request->json('sha'));
break;
}

return response()->noContent();
}

/**
* Adapted from https://gist.github.com/milo/daed6e958ea534e4eba3
*/
private static function verifySignature(Request $request, string $secret): void
{
$signature = $request->headers->get('X-Hub-Signature');
if ($signature === null) {
self::fail("HTTP header 'X-Hub-Signature' is missing.");
}

[$algo, $hash] = explode('=', $signature, 2) + ['', ''];
if (!in_array($algo, hash_hmac_algos(), true)) {
self::fail("Hash algorithm '$algo' is not supported.");
}

if (!hash_equals(hash_hmac($algo, $request->getContent(), $secret), $hash)) {
self::fail('Hook secret does not match.');
}
}

private static function fail(string $message): never
{
Log::warning($message, [
'function' => 'GitHub webhook',
]);
abort(500, $message);
}
}
70 changes: 0 additions & 70 deletions app/cdash/public/api/v1/GitHub/webhook.php

This file was deleted.

42 changes: 0 additions & 42 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -12438,48 +12438,6 @@ parameters:
count: 4
path: app/cdash/include/filterdataFunctions.php

-
rawMessage: Cannot access offset 'head_sha' on mixed.
identifier: offsetAccess.nonOffsetAccessible
count: 1
path: app/cdash/public/api/v1/GitHub/webhook.php

-
rawMessage: Cannot access offset 'name' on mixed.
identifier: offsetAccess.nonOffsetAccessible
count: 1
path: app/cdash/public/api/v1/GitHub/webhook.php

-
rawMessage: 'Function handle_error() has no return type specified.'
identifier: missingType.return
count: 1
path: app/cdash/public/api/v1/GitHub/webhook.php

-
rawMessage: 'Function handle_error() has parameter $msg with no type specified.'
identifier: missingType.parameter
count: 1
path: app/cdash/public/api/v1/GitHub/webhook.php

-
rawMessage: 'Parameter #2 $data of function hash_hmac expects string, string|false given.'
identifier: argument.type
count: 1
path: app/cdash/public/api/v1/GitHub/webhook.php

-
rawMessage: 'Parameter #2 $string of function explode expects string, mixed given.'
identifier: argument.type
count: 1
path: app/cdash/public/api/v1/GitHub/webhook.php

-
rawMessage: 'Parameter #3 $key of function hash_hmac expects string, bool|string given.'
identifier: argument.type
count: 1
path: app/cdash/public/api/v1/GitHub/webhook.php

-
rawMessage: 'Argument of an invalid type mixed supplied for foreach, only iterables are supported.'
identifier: foreach.nonIterable
Expand Down
3 changes: 3 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use App\Http\Controllers\ExpectedBuildController;
use App\Http\Controllers\FilterController;
use App\Http\Controllers\GitHubWebhookController;
use App\Http\Controllers\TimelineController;
use Illuminate\Support\Facades\Route;

Expand Down Expand Up @@ -48,6 +49,8 @@

Route::match(['get', 'post', 'delete'], '/v1/expectedbuild.php', ExpectedBuildController::class);

Route::post('/v1/GitHub/webhook.php', GitHubWebhookController::class);

Route::middleware(['auth'])->group(function (): void {
Route::get('/v1/manageSubProject.php', 'SubProjectController@apiManageSubProject');

Expand Down
135 changes: 128 additions & 7 deletions tests/Feature/GitHubWebhook.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@

namespace Tests\Feature;

use App\Models\Build;
use App\Models\BuildUpdate;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;
use Tests\Traits\CreatesProjects;

class GitHubWebhook extends TestCase
{
use CreatesProjects;
use DatabaseTransactions;

protected string $endpoint = '/api/v1/GitHub/webhook.php';
Expand All @@ -15,8 +22,6 @@ public function setUp(): void
{
parent::setUp();

$_SERVER['REQUEST_METHOD'] = 'POST';

config(['cdash.github_webhook_secret' => 'mock secret']);
}

Expand All @@ -29,24 +34,140 @@ public function testWebhookWithoutRequiredSignature(): void

public function testWebhookWithUnsupportedAlgorithm(): void
{
$_SERVER['HTTP_X_HUB_SIGNATURE'] = 'zzz=foo';
$this->post($this->endpoint)
$this->withHeader('X-Hub-Signature', 'zzz=foo')
->post($this->endpoint)
->assertServerError()
->assertJson(['error' => "Hash algorithm 'zzz' is not supported."], true);
}

public function testWebhookWithWrongSignature(): void
{
$_SERVER['HTTP_X_HUB_SIGNATURE'] = 'sha1=wrong secret';
$this->post($this->endpoint)
$this->withHeader('X-Hub-Signature', 'sha1=wrong secret')
->post($this->endpoint)
->assertServerError()
->assertJson(['error' => 'Hook secret does not match.'], true);
}

public function testWebhookWithCorrectSignature(): void
{
$hash = hash_hmac('sha1', '', 'mock secret');
$_SERVER['HTTP_X_HUB_SIGNATURE'] = "sha1=$hash";
$this->withHeader('X-Hub-Signature', "sha1=$hash")
->post($this->endpoint)
->assertNoContent();
}

public function testWebhookWithoutSecretDoesNotRequireSignature(): void
{
config(['cdash.github_webhook_secret' => null]);

$this->post($this->endpoint)->assertNoContent();
}

public function testSignatureCoversRequestBody(): void
{
$body = (string) json_encode(['sha' => 'abc']);
$hash = hash_hmac('sha1', $body . ' ', 'mock secret');

$this->call('POST', $this->endpoint, server: [
'HTTP_X_GITHUB_EVENT' => 'status',
'HTTP_X_HUB_SIGNATURE' => "sha1=$hash",
'CONTENT_TYPE' => 'application/json',
], content: $body)
->assertServerError()
->assertJson(['error' => 'Hook secret does not match.'], true);
}

public function testStatusEventCreatesCheck(): void
{
$this->sendEvent('status', ['sha' => $this->createCommit()])
->assertServerError()
->assertJson(['error' => 'No repository interface defined for gitlab'], true);
}

public function testStatusEventForUnknownCommit(): void
{
$this->createCommit();

$this->sendEvent('status', ['sha' => Str::uuid()->toString()])->assertNoContent();
}

public function testCheckRunEventCreatesCheck(): void
{
$this->sendEvent('check_run', [
'action' => 'completed',
'check_run' => [
'name' => 'Some other check',
'head_sha' => $this->createCommit(),
],
])
->assertServerError()
->assertJson(['error' => 'No repository interface defined for gitlab'], true);
}

public function testCheckRunEventIgnoresOwnCheck(): void
{
$this->sendEvent('check_run', [
'action' => 'completed',
'check_run' => [
'name' => 'CDash',
'head_sha' => $this->createCommit(),
],
])->assertNoContent();
}

public function testCheckRunEventRecreatesRerequestedOwnCheck(): void
{
$this->sendEvent('check_run', [
'action' => 'rerequested',
'check_run' => [
'name' => 'CDash',
'head_sha' => $this->createCommit(),
],
])
->assertServerError()
->assertJson(['error' => 'No repository interface defined for gitlab'], true);
}

public function testUnknownEventIsIgnored(): void
{
$this->sendEvent('push', ['sha' => $this->createCommit()])->assertNoContent();
}

/**
* Creates a build for a commit in a project which uses an unsupported repository viewer, so that
* creating a check for that commit fails with an error that shows the check was attempted.
*/
private function createCommit(): string
{
$project = $this->makePublicProject();
$project->cvsviewertype = 'gitlab';
$project->save();

/** @var Build $build */
$build = $project->builds()->create([
'name' => 'build1',
'uuid' => Str::uuid()->toString(),
]);
$update = BuildUpdate::factory()->create();
$build->updateStep()->associate($update)->save();

return $update->revision;
}

/**
* @param array<string,mixed> $payload
*
* @return TestResponse<Response>
*/
private function sendEvent(string $event, array $payload): TestResponse
{
$body = (string) json_encode($payload);
$hash = hash_hmac('sha1', $body, 'mock secret');

return $this->call('POST', $this->endpoint, server: [
'HTTP_X_GITHUB_EVENT' => $event,
'HTTP_X_HUB_SIGNATURE' => "sha1=$hash",
'CONTENT_TYPE' => 'application/json',
], content: $body);
}
}
Loading