provider ?: 'git')); $event = $this->eventName($request, $provider); $deliveryId = $this->deliveryId($request, $provider); $rawPayload = $request->getContent(); $status = 202; $payload = []; if (! $repository->is_active) { $result = $this->rejectedResult('Repository is inactive.'); $status = 403; } elseif (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) { $result = $this->rejectedResult(self::SIGNATURE_FAILED); $status = 403; } else { try { /** @var array $payload */ $payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); $result = $service->ingest($repository, $payload, $provider, $deliveryId, $event); } catch (JsonException) { $result = $this->rejectedResult('Invalid JSON webhook payload.'); $status = 400; } } if ($status !== 202) { $service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload); } return response()->json($result, $status); } /** * 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(); $status = 202; $repository = null; try { /** @var array $payload */ $payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); $repository = $this->repositoryForGiteaPayload($payload); if (! $repository instanceof GitRepository) { $result = $this->rejectedResult('No active Helpdesk Git repository matched the Gitea webhook payload.'); $status = 404; } elseif (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) { $result = $this->rejectedResult(self::SIGNATURE_FAILED); $status = 403; } else { $result = $service->ingest($repository, $payload, $provider, $deliveryId, $event); } } catch (JsonException) { $payload = []; $result = $this->rejectedResult('Invalid JSON webhook payload.'); $status = 400; } if ($repository instanceof GitRepository && $status !== 202) { $service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload); } return response()->json($result, $status); } /** * 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(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature) || hash_equals(self::SHA1_PREFIX . 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(self::SHA256_PREFIX . $sha256, $giteaSignature) || hash_equals(self::SHA1_PREFIX . $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(self::SHA256_PREFIX . 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(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature) || hash_equals(self::SHA1_PREFIX . 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 $payload */ private function repositoryForGiteaPayload(array $payload): ?GitRepository { $repositories = GitRepository::query() ->where('is_active', true) ->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git']) ->get(); $matchedRepository = null; if ($repositories->isEmpty()) { Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.'); } else { $normalisedPayloadValues = $this->normalisedPayloadRepositoryValues($payload); $matchedRepository = $repositories->first( fn (GitRepository $repository): bool => $this->repositoryMatchesPayload($repository, $normalisedPayloadValues), ); if (! $matchedRepository instanceof GitRepository && $repositories->count() === 1) { $matchedRepository = $repositories->first(); } elseif (! $matchedRepository instanceof GitRepository) { 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 $matchedRepository instanceof GitRepository ? $matchedRepository : null; } /** @param array $payload * @return list */ private function normalisedPayloadRepositoryValues(array $payload): array { $name = Arr::get($payload, 'repository.name'); $owner = Arr::get($payload, 'repository.owner.username'); return $this->normaliseRepositoryValues([ Arr::get($payload, 'repository.full_name'), $name, Arr::get($payload, 'repository.html_url'), Arr::get($payload, 'repository.clone_url'), Arr::get($payload, 'repository.ssh_url'), Arr::get($payload, 'repository.url'), $owner && $name ? $owner . '/' . $name : null, ]); } /** @param list $payloadValues */ private function repositoryMatchesPayload(GitRepository $repository, array $payloadValues): bool { $repositoryValues = $this->normaliseRepositoryValues([ $repository->remote_url, $repository->name, Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(), ]); foreach ($payloadValues as $payloadValue) { foreach ($repositoryValues as $repositoryValue) { if ($this->repositoryIdentitiesMatch($payloadValue, $repositoryValue)) { return true; } } } return false; } /** @param array $values * @return list */ private function normaliseRepositoryValues(array $values): array { $normalised = []; foreach ($values as $value) { if (! is_scalar($value) || trim((string) $value) === '') { continue; } $identity = $this->normaliseRepositoryIdentity((string) $value); if ($identity !== null) { $normalised[] = $identity; } } return $normalised; } private function repositoryIdentitiesMatch(string $payloadValue, string $repositoryValue): bool { return $payloadValue === $repositoryValue || str_ends_with($repositoryValue, '/' . $payloadValue) || str_ends_with($payloadValue, '/' . $repositoryValue); } /** * 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)); } /** @return array{status: string, message: string, commit_count: int, linked_count: int, ignored_count: int} */ private function rejectedResult(string $message): array { return [ 'status' => self::REJECTED, 'message' => $message, 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1, ]; } /** * Read the event name from provider-specific headers. */ private function eventName(Request $request, string $provider): ?string { return match ($provider) { 'github' => $request->header(self::GITHUB_EVENT_HEADER), 'gitlab' => $request->header('X-Gitlab-Event'), 'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header(self::GITHUB_EVENT_HEADER), 'bitbucket' => $request->header('X-Event-Key'), default => $request->header('X-Helpdesk-Event') ?: $request->header(self::GITHUB_EVENT_HEADER) ?: $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(self::GITHUB_DELIVERY_HEADER), 'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header(self::REQUEST_ID_HEADER), 'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header(self::GITHUB_DELIVERY_HEADER) ?: $request->header(self::REQUEST_ID_HEADER), 'bitbucket' => $request->header('X-Request-UUID') ?: $request->header(self::REQUEST_ID_HEADER), default => $request->header('X-Helpdesk-Delivery') ?: $request->header(self::GITHUB_DELIVERY_HEADER) ?: $request->header(self::REQUEST_ID_HEADER), }; } }