From ab4b2045d427badeb0f5bd2c3d0a509e9dd74bb7 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 29 May 2026 23:40:47 +0530 Subject: [PATCH] fix(webhook): skip preview deployments for fork PRs when public previews are off --- app/Http/Controllers/Webhook/Github.php | 38 +++++++++++++++++++ app/Jobs/ProcessGithubPullRequestWebhook.php | 13 ++++++- .../project/application/advanced.blade.php | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index b481f4a67..8e3ffcd7c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -62,6 +62,7 @@ class Github extends Controller $before_sha = data_get($payload, 'before'); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); + $is_fork_pull_request = $this->isForkPullRequest($payload); } if (! in_array($x_github_event, ['push', 'pull_request'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); @@ -222,6 +223,7 @@ class Github extends Controller commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), authorAssociation: $author_association, fullName: $full_name, + isForkPullRequest: $is_fork_pull_request ?? false, ); $return_payloads->push([ @@ -303,6 +305,7 @@ class Github extends Controller $before_sha = data_get($payload, 'before'); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); + $is_fork_pull_request = $this->isForkPullRequest($payload); } if (! in_array($x_github_event, ['push', 'pull_request'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); @@ -434,6 +437,7 @@ class Github extends Controller commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), authorAssociation: $author_association, fullName: $full_name, + isForkPullRequest: $is_fork_pull_request ?? false, ); $return_payloads->push([ @@ -451,6 +455,40 @@ class Github extends Controller } } + /** + * Determine whether a pull_request webhook payload originates from a fork. + * + * GitHub's `author_association` is not a reliable trust signal (it grants + * CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork + * detection is gated on whether the PR crosses repository boundaries. + * + * The repository id comparison is the canonical signal; the `head.repo.fork` + * flag and a case-insensitive full_name comparison are fallbacks for payloads + * where the ids are unavailable (e.g. a deleted head repository). + */ + private function isForkPullRequest(mixed $payload): bool + { + $headRepoId = data_get($payload, 'pull_request.head.repo.id'); + $baseRepoId = data_get($payload, 'pull_request.base.repo.id'); + + if ($headRepoId !== null && $baseRepoId !== null) { + return (string) $headRepoId !== (string) $baseRepoId; + } + + if (data_get($payload, 'pull_request.head.repo.fork') === true) { + return true; + } + + $headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name'); + $baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name'); + + if (is_string($headRepoFullName) && is_string($baseRepoFullName)) { + return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName); + } + + return false; + } + public function redirect(Request $request) { $code = (string) $request->query('code', ''); diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 54e386676..141351784 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -39,6 +39,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue public string $commitSha, public ?string $authorAssociation, public string $fullName, + public bool $isForkPullRequest = false, ) { $this->onQueue('high'); } @@ -92,7 +93,17 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue // Check if PR deployments from public contributors are restricted if (! $application->settings->is_pr_deployments_public_enabled) { - $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; + // Fork PRs carry untrusted code from a repository outside our control. + // GitHub's author_association cannot be trusted to gate these (it grants + // CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork + // PRs are never deployed automatically when public previews are off. + if ($this->isForkPullRequest) { + return; + } + + // Same-repo (non-fork) branch PRs require push access to the base repo, + // so only trusted associations are allowed to trigger a deployment. + $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; if (! in_array($this->authorAssociation, $trustedAssociations)) { return; } diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 82ee31933..d6fcc2e4a 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -41,7 +41,7 @@ instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update" :canResource="$application" />