Files
Helpddesk/Modules/Helpdesk/Http/Controllers/GitWebhookController.php
2026-09-02 17:08:57 +00:00

356 lines
14 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use JsonException;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Services\GitWebhookIngestionService;
final class GitWebhookController
{
/**
* Ingest a signed Git provider webhook for a registered Helpdesk repository.
*/
public function __invoke(Request $request, GitRepository $repository, GitWebhookIngestionService $service): JsonResponse
{
$provider = strtolower((string) ($repository->provider ?: 'git'));
$event = $this->eventName($request, $provider);
$deliveryId = $this->deliveryId($request, $provider);
$rawPayload = $request->getContent();
if (! $repository->is_active) {
$result = ['status' => 'rejected', 'message' => 'Repository is inactive.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
return response()->json($result, 403);
}
if (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
$result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
return response()->json($result, 403);
}
try {
/** @var array<string, mixed> $payload */
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$result = ['status' => 'rejected', 'message' => 'Invalid JSON webhook payload.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
return response()->json($result, 400);
}
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
return response()->json($result, 202);
}
/**
* Ingest the local Gitea webhook URL that does not include a repository id.
*/
public function gitea(Request $request, GitWebhookIngestionService $service): JsonResponse
{
$provider = 'gitea';
$event = $this->eventName($request, $provider);
$deliveryId = $this->deliveryId($request, $provider);
$rawPayload = $request->getContent();
try {
/** @var array<string, mixed> $payload */
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return response()->json([
'status' => 'rejected',
'message' => 'Invalid JSON webhook payload.',
'commit_count' => 0,
'linked_count' => 0,
'ignored_count' => 1,
], 400);
}
$repository = $this->repositoryForGiteaPayload($payload);
if (! $repository instanceof GitRepository) {
return response()->json([
'status' => 'rejected',
'message' => 'No active Helpdesk Git repository matched the Gitea webhook payload.',
'commit_count' => 0,
'linked_count' => 0,
'ignored_count' => 1,
], 404);
}
if (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) {
$result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload);
return response()->json($result, 403);
}
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
return response()->json($result, 202);
}
/**
* Validate provider-specific webhook signatures or tokens.
*/
private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
{
$secret = $this->secretForProvider($repository, $provider);
if ($secret === '') {
return false;
}
return match ($provider) {
'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')),
'gitea' => $this->hasValidGiteaSignature($request, $secret, $rawPayload),
'bitbucket' => $this->hasValidBitbucketSignature($request, $secret, $rawPayload),
default => $this->hasValidGenericSignature($request, $secret, $rawPayload),
};
}
/**
* Resolve the repository webhook secret, with local Gitea env fallback support.
*/
private function secretForProvider(GitRepository $repository, string $provider): string
{
$secret = trim((string) $repository->webhook_secret);
if ($secret !== '') {
return $secret;
}
return $provider === 'gitea' ? trim((string) config('services.gitea.webhook_secret', '')) : '';
}
/**
* Validate GitHub's current SHA-256 signature and legacy SHA-1 signature.
*/
private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool
{
$sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
$sha1Signature = (string) $request->header('X-Hub-Signature', '');
return hash_equals('sha256='.hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|| hash_equals('sha1='.hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
}
/**
* Validate Gitea's raw SHA-256 signature and GitHub-compatible HMAC headers.
*/
private function hasValidGiteaSignature(Request $request, string $secret, string $rawPayload): bool
{
$giteaSignature = (string) $request->header('X-Gitea-Signature', '');
$sha256 = hash_hmac('sha256', $rawPayload, $secret);
$sha1 = hash_hmac('sha1', $rawPayload, $secret);
return hash_equals($sha256, $giteaSignature)
|| hash_equals('sha256='.$sha256, $giteaSignature)
|| hash_equals('sha1='.$sha1, $giteaSignature)
|| $this->hasValidGitHubSignature($request, $secret, $rawPayload)
|| $this->hasValidGenericSignature($request, $secret, $rawPayload);
}
/**
* Validate Bitbucket's webhook HMAC signature.
*/
private function hasValidBitbucketSignature(Request $request, string $secret, string $rawPayload): bool
{
$signature = (string) $request->header('X-Hub-Signature', '');
return hash_equals('sha256='.hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals($secret, (string) $request->header('X-Helpdesk-Token', ''));
}
/**
* Validate a generic Git webhook using either a token header or HMAC signature.
*/
private function hasValidGenericSignature(Request $request, string $secret, string $rawPayload): bool
{
$token = (string) ($request->header('X-Helpdesk-Token') ?: $request->header('X-Gitlab-Token') ?: $request->header('X-Gitea-Token'));
$signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature'));
return hash_equals($secret, $token)
|| hash_equals('sha256='.hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals('sha1='.hash_hmac('sha1', $rawPayload, $secret), $signature);
}
/**
* Validate Gitea webhook security without allowing an optional Authorization
* header mismatch to hide otherwise valid signed push deliveries.
*/
private function hasValidGiteaSecurity(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
{
if ($this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
return true;
}
return $this->hasValidGiteaAuthorization($request);
}
/**
* Validate the optional Authorization header configured for the local Gitea webhook.
*/
private function hasValidGiteaAuthorization(Request $request): bool
{
$expected = trim((string) config('services.gitea.webhook_authorization', ''));
if ($expected === '') {
return true;
}
$actual = trim((string) $request->header('Authorization', ''));
return hash_equals($expected, $actual)
|| hash_equals('Basic '.$expected, $actual)
|| (Str::startsWith($expected, 'Basic ') && hash_equals($expected, $actual));
}
/**
* Match a Gitea webhook payload to the configured Helpdesk repository.
*
* @param array<string, mixed> $payload
*/
private function repositoryForGiteaPayload(array $payload): ?GitRepository
{
$repositories = GitRepository::query()
->where('is_active', true)
->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git'])
->get();
if ($repositories->isEmpty()) {
Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.');
return null;
}
$payloadValues = array_values(array_filter([
Arr::get($payload, 'repository.full_name'),
Arr::get($payload, 'repository.name'),
Arr::get($payload, 'repository.html_url'),
Arr::get($payload, 'repository.clone_url'),
Arr::get($payload, 'repository.ssh_url'),
Arr::get($payload, 'repository.url'),
Arr::get($payload, 'repository.owner.username') && Arr::get($payload, 'repository.name')
? Arr::get($payload, 'repository.owner.username').'/'.Arr::get($payload, 'repository.name')
: null,
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''));
$normalisedPayloadValues = array_values(array_filter(array_map(
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
$payloadValues,
)));
foreach ($repositories as $repository) {
$repositoryValues = array_filter([
$repository->remote_url,
$repository->name,
Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(),
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== '');
$normalisedRepositoryValues = array_values(array_filter(array_map(
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
$repositoryValues,
)));
foreach ($normalisedPayloadValues as $payloadValue) {
foreach ($normalisedRepositoryValues as $repositoryValue) {
if ($payloadValue === $repositoryValue
|| str_ends_with($repositoryValue, '/'.$payloadValue)
|| str_ends_with($payloadValue, '/'.$repositoryValue)) {
return $repository;
}
}
}
}
if ($repositories->count() === 1) {
return $repositories->first();
}
Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [
'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'),
'configured_repository_count' => $repositories->count(),
]);
return null;
}
/**
* Normalise a repository name or URL so Gitea payloads match local settings reliably.
*/
private function normaliseRepositoryIdentity(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$value = Str::of($value)
->replace('\\', '/')
->replace('ssh://git@', 'ssh://')
->replace('git@', '')
->replace('.git', '')
->lower()
->value();
$path = parse_url($value, PHP_URL_PATH);
if (is_string($path) && trim($path, '/') !== '') {
$value = trim($path, '/');
} elseif (str_contains($value, ':')) {
$value = Str::of($value)->afterLast(':')->trim('/')->value();
}
$segments = array_values(array_filter(explode('/', trim($value, '/'))));
if ($segments === []) {
return null;
}
return implode('/', array_slice($segments, -2));
}
/**
* Read the event name from provider-specific headers.
*/
private function eventName(Request $request, string $provider): ?string
{
return match ($provider) {
'github' => $request->header('X-GitHub-Event'),
'gitlab' => $request->header('X-Gitlab-Event'),
'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header('X-GitHub-Event'),
'bitbucket' => $request->header('X-Event-Key'),
default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $request->header('X-Gitlab-Event') ?: 'push',
};
}
/**
* Read the provider delivery/request id used for idempotent audit tracing.
*/
private function deliveryId(Request $request, string $provider): ?string
{
return match ($provider) {
'github' => $request->header('X-GitHub-Delivery'),
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'),
'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
'bitbucket' => $request->header('X-Request-UUID') ?: $request->header('X-Request-Id'),
default => $request->header('X-Helpdesk-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
};
}
}