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 $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(); if (! $this->hasValidGiteaAuthorization($request)) { return response()->json([ 'status' => 'rejected', 'message' => 'Gitea webhook authorization failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1, ], 403); } try { /** @var array $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->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); } $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', ''); return hash_equals(hash_hmac('sha256', $rawPayload, $secret), $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')); $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 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); } /** * Match a Gitea webhook payload to the configured Helpdesk repository. * * @param array $payload */ private function repositoryForGiteaPayload(array $payload): ?GitRepository { $repositories = GitRepository::query() ->where('is_active', true) ->whereIn('provider', ['gitea', 'git']) ->get(); if ($repositories->isEmpty()) { return null; } $payloadValues = 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'), ], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''); foreach ($repositories as $repository) { foreach ($payloadValues as $value) { $payloadValue = trim((string) $value); $remoteUrl = trim((string) $repository->remote_url); if ($remoteUrl !== '' && ($payloadValue === $remoteUrl || str_contains($remoteUrl, $payloadValue) || str_contains($payloadValue, $remoteUrl))) { return $repository; } if (strcasecmp($payloadValue, (string) $repository->name) === 0) { return $repository; } } } return $repositories->count() === 1 ? $repositories->first() : null; } /** * 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'), }; } }