mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-06 14:07:37 -04:00
fix(webhooks): reject malformed payload values without a 500
Every webhook handler (GitHub, GitLab, Gitea, Bitbucket, GitHub App, GitLab App) now reads the fields it uses through typed readers: refs, titles, and actions must be strings, ids positive integers, commit SHAs 7-64 hex characters, and URLs http(s). An invalid value returns "Nothing to do. Invalid '<field>' in the request." without a deployment. Signature checks and the failure lockout are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
129cc97047
commit
eeda90a8a4
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* A webhook payload field has a wrong type or format.
|
||||
*
|
||||
* Webhook handlers catch this exception and send a clean "Nothing to do."
|
||||
* response. The request does not deploy anything.
|
||||
*/
|
||||
class InvalidWebhookPayloadException extends Exception
|
||||
{
|
||||
public static function forField(string $field): self
|
||||
{
|
||||
return new self("Nothing to do. Invalid '{$field}' in the request.");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Exceptions\InvalidWebhookPayloadException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
@@ -39,7 +40,7 @@ class Bitbucket extends Controller
|
||||
// A deleted branch has no "new" state, so no branch is found.
|
||||
$branch = $this->webhookString(data_get($payload, 'push.changes.0.new.name'));
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$commit = $this->webhookString(data_get($payload, 'push.changes.0.new.target.hash'));
|
||||
$commit = $this->webhookCommitSha($payload, 'push.changes.0.new.target.hash');
|
||||
// Bitbucket webhooks ship up to 5 commits per change. Larger pushes
|
||||
// are evaluated only on the visible 5.
|
||||
$changes = data_get($payload, 'push.changes');
|
||||
@@ -61,11 +62,10 @@ class Bitbucket extends Controller
|
||||
$branch = $this->webhookString(data_get($payload, 'pullrequest.destination.branch.name'));
|
||||
$base_branch = $this->webhookString(data_get($payload, 'pullrequest.source.branch.name'));
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$pull_request_id = data_get($payload, 'pullrequest.id');
|
||||
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
|
||||
$pull_request_title = data_get($payload, 'pullrequest.title');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
||||
$commit = data_get($payload, 'pullrequest.source.commit.hash');
|
||||
$pull_request_id = $this->webhookPullRequestId($payload, 'pullrequest.id');
|
||||
$pull_request_html_url = $this->webhookPayloadUrl($payload, 'pullrequest.links.html.href');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$this->webhookPayloadString($payload, 'pullrequest.title')]);
|
||||
$commit = $this->webhookCommitSha($payload, 'pullrequest.source.commit.hash');
|
||||
|
||||
if (! $branch) {
|
||||
return response([
|
||||
@@ -222,7 +222,7 @@ class Bitbucket extends Controller
|
||||
'git_type' => 'bitbucket',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
@@ -231,7 +231,7 @@ class Bitbucket extends Controller
|
||||
'git_type' => 'bitbucket',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn();
|
||||
}
|
||||
@@ -291,6 +291,11 @@ class Bitbucket extends Controller
|
||||
}
|
||||
|
||||
return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt);
|
||||
} catch (InvalidWebhookPayloadException $e) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,37 @@
|
||||
|
||||
namespace App\Http\Controllers\Webhook\Concerns;
|
||||
|
||||
use App\Exceptions\InvalidWebhookPayloadException;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Reads push webhook payloads defensively.
|
||||
* Reads webhook payloads defensively.
|
||||
*
|
||||
* Git hosts omit the commit list (or send null) for some pushes, for example
|
||||
* branch creation, branch deletion and some system hooks. Such payloads must
|
||||
* not crash the handler and must not skip a deployment because of watch paths.
|
||||
*
|
||||
* The typed readers (webhookPayloadString(), webhookPayloadId(),
|
||||
* webhookCommitSha(), webhookPayloadUrl()) throw an
|
||||
* InvalidWebhookPayloadException for a value with a wrong type or format. The
|
||||
* handlers catch it and send a clean "Nothing to do." response, so a malformed
|
||||
* but correctly signed payload never causes a 500 or a deployment.
|
||||
*/
|
||||
trait ReadsWebhookPushPayload
|
||||
{
|
||||
/**
|
||||
* Largest value of an integer database column (pull_request_id,
|
||||
* repository_project_id).
|
||||
*/
|
||||
protected const WEBHOOK_MAX_DATABASE_INTEGER = 2147483647;
|
||||
|
||||
/**
|
||||
* Length of the application_previews.pull_request_html_url column.
|
||||
*/
|
||||
protected const WEBHOOK_MAX_URL_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Branch name from a ref such as "refs/heads/main", or null when the ref is
|
||||
* missing or not a string.
|
||||
@@ -38,6 +56,128 @@ trait ReadsWebhookPushPayload
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A string field of the payload. A missing, null or empty value gives null.
|
||||
*
|
||||
* @throws InvalidWebhookPayloadException When the value is not a string, or is missing and required.
|
||||
*/
|
||||
protected function webhookPayloadString(mixed $payload, string $key, bool $required = false): ?string
|
||||
{
|
||||
$value = data_get($payload, $key);
|
||||
if ($value === null || $value === '') {
|
||||
if ($required) {
|
||||
throw InvalidWebhookPayloadException::forField($key);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
throw InvalidWebhookPayloadException::forField($key);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A positive integer id of the payload, sent as an integer or as a string
|
||||
* of digits. A missing or null value gives null.
|
||||
*
|
||||
* @throws InvalidWebhookPayloadException When the value is not a positive integer up to $max, or is missing and required.
|
||||
*/
|
||||
protected function webhookPayloadId(mixed $payload, string $key, bool $required = false, int $max = PHP_INT_MAX): ?int
|
||||
{
|
||||
$value = data_get($payload, $key);
|
||||
if ($value === null) {
|
||||
if ($required) {
|
||||
throw InvalidWebhookPayloadException::forField($key);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value) && preg_match('/\A[1-9][0-9]{0,18}\z/', $value) === 1) {
|
||||
$value = filter_var($value, FILTER_VALIDATE_INT);
|
||||
}
|
||||
|
||||
if (! is_int($value) || $value < 1 || $value > $max) {
|
||||
throw InvalidWebhookPayloadException::forField($key);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* An id that Coolify stores in or compares with an integer database
|
||||
* column, for example a pull request id or a repository project id.
|
||||
*
|
||||
* @throws InvalidWebhookPayloadException
|
||||
*/
|
||||
protected function webhookPayloadDatabaseId(mixed $payload, string $key, bool $required = false): ?int
|
||||
{
|
||||
return $this->webhookPayloadId($payload, $key, $required, self::WEBHOOK_MAX_DATABASE_INTEGER);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pull request or merge request id. The id is required.
|
||||
*
|
||||
* @throws InvalidWebhookPayloadException
|
||||
*/
|
||||
protected function webhookPullRequestId(mixed $payload, string $key): int
|
||||
{
|
||||
return $this->webhookPayloadDatabaseId($payload, $key, required: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* A commit SHA of the payload: 7 to 64 hexadecimal characters. A missing,
|
||||
* null or empty value gives null.
|
||||
*
|
||||
* @throws InvalidWebhookPayloadException When the value is not a commit SHA.
|
||||
*/
|
||||
protected function webhookCommitSha(mixed $payload, string $key): ?string
|
||||
{
|
||||
$value = data_get($payload, $key);
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_string($value) || preg_match('/\A[0-9a-fA-F]{7,64}\z/', $value) !== 1) {
|
||||
throw InvalidWebhookPayloadException::forField($key);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* An absolute http or https URL of the payload, for example a pull request
|
||||
* link that Coolify stores and shows in the UI. A missing, null or empty
|
||||
* value gives null.
|
||||
*
|
||||
* @throws InvalidWebhookPayloadException When the value is not a valid URL, or is missing and required.
|
||||
*/
|
||||
protected function webhookPayloadUrl(mixed $payload, string $key, bool $required = false): ?string
|
||||
{
|
||||
$value = $this->webhookPayloadString($payload, $key, $required);
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheme = parse_url($value, PHP_URL_SCHEME);
|
||||
$host = parse_url($value, PHP_URL_HOST);
|
||||
if (
|
||||
strlen($value) > self::WEBHOOK_MAX_URL_LENGTH
|
||||
|| preg_match('/[\s\x00-\x1F\x7F]/', $value) === 1
|
||||
|| ! is_string($scheme)
|
||||
|| ! in_array(strtolower($scheme), ['http', 'https'], true)
|
||||
|| ! is_string($host)
|
||||
|| $host === ''
|
||||
) {
|
||||
throw InvalidWebhookPayloadException::forField($key);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Files that the commits of a push add, remove or modify.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Exceptions\InvalidWebhookPayloadException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
@@ -45,20 +46,21 @@ class Gitea extends Controller
|
||||
if ($x_gitea_event === 'push') {
|
||||
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$commit = $this->webhookCommitSha($payload, 'after');
|
||||
$changed_files = $this->webhookPushChangedFiles($payload);
|
||||
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
|
||||
}
|
||||
if ($x_gitea_event === 'pull_request') {
|
||||
$action = data_get($payload, 'action');
|
||||
$action = $this->webhookPayloadString($payload, 'action');
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$pull_request_id = data_get($payload, 'number');
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$pull_request_title = data_get($payload, 'pull_request.title');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
||||
$pull_request_id = $this->webhookPullRequestId($payload, 'number');
|
||||
$pull_request_html_url = $this->webhookPayloadUrl($payload, 'pull_request.html_url');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$this->webhookPayloadString($payload, 'pull_request.title')]);
|
||||
$branch = $this->webhookString(data_get($payload, 'pull_request.head.ref'));
|
||||
$base_branch = $this->webhookString(data_get($payload, 'pull_request.base.ref'));
|
||||
$commit = $this->webhookCommitSha($payload, 'head.sha');
|
||||
}
|
||||
if (! $branch) {
|
||||
if (! $branch || ($x_gitea_event === 'pull_request' && ! $base_branch)) {
|
||||
return response('Nothing to do. No branch found in the request.');
|
||||
}
|
||||
// A deleted branch has no commit to deploy. No secret is checked here.
|
||||
@@ -143,7 +145,7 @@ class Gitea extends Controller
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
commit: $commit ?? 'HEAD',
|
||||
is_webhook: true,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
@@ -161,7 +163,7 @@ class Gitea extends Controller
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'commit' => $commit,
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
@@ -222,7 +224,7 @@ class Gitea extends Controller
|
||||
'git_type' => 'gitea',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
@@ -231,7 +233,7 @@ class Gitea extends Controller
|
||||
'git_type' => 'gitea',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn();
|
||||
}
|
||||
@@ -241,7 +243,7 @@ class Gitea extends Controller
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'head.sha', 'HEAD'),
|
||||
commit: $commit ?? 'HEAD',
|
||||
is_webhook: true,
|
||||
git_type: 'gitea'
|
||||
);
|
||||
@@ -292,6 +294,8 @@ class Gitea extends Controller
|
||||
}
|
||||
|
||||
return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt);
|
||||
} catch (InvalidWebhookPayloadException $e) {
|
||||
return response($e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Exceptions\InvalidWebhookPayloadException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
@@ -46,26 +47,30 @@ class Github extends Controller
|
||||
if ($x_github_event === 'push') {
|
||||
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$commit = $this->webhookCommitSha($payload, 'after');
|
||||
$changed_files = $this->webhookPushChangedFiles($payload);
|
||||
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
$action = data_get($payload, 'action');
|
||||
[
|
||||
'action' => $action,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_title' => $pull_request_title,
|
||||
'branch' => $branch,
|
||||
'base_branch' => $base_branch,
|
||||
'before_sha' => $before_sha,
|
||||
'after_sha' => $after_sha,
|
||||
'commit_sha' => $commit_sha,
|
||||
'author_association' => $author_association,
|
||||
'is_fork_pull_request' => $is_fork_pull_request,
|
||||
] = $this->readPullRequestPayload($payload);
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$pull_request_id = data_get($payload, 'number');
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$pull_request_title = data_get($payload, 'pull_request.title');
|
||||
$branch = $this->webhookString(data_get($payload, 'pull_request.head.ref'));
|
||||
$base_branch = $this->webhookString(data_get($payload, 'pull_request.base.ref'));
|
||||
$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.");
|
||||
}
|
||||
if (! $branch) {
|
||||
if (! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) {
|
||||
return response('Nothing to do. No branch found in the request.');
|
||||
}
|
||||
// A deleted branch has no commit to deploy. No secret is checked here.
|
||||
@@ -154,7 +159,7 @@ class Github extends Controller
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
commit: $commit ?? 'HEAD',
|
||||
is_webhook: true,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
@@ -172,7 +177,7 @@ class Github extends Controller
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'commit' => $commit,
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
@@ -224,13 +229,13 @@ class Github extends Controller
|
||||
action: $action,
|
||||
pullRequestId: $pull_request_id,
|
||||
pullRequestHtmlUrl: $pull_request_html_url,
|
||||
pullRequestTitle: $pull_request_title ?? null,
|
||||
pullRequestTitle: $pull_request_title,
|
||||
beforeSha: $before_sha,
|
||||
afterSha: $after_sha,
|
||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||
commitSha: $commit_sha,
|
||||
authorAssociation: $author_association,
|
||||
fullName: $full_name,
|
||||
isForkPullRequest: $is_fork_pull_request ?? false,
|
||||
isForkPullRequest: $is_fork_pull_request,
|
||||
);
|
||||
|
||||
$return_payloads->push([
|
||||
@@ -243,6 +248,8 @@ class Github extends Controller
|
||||
}
|
||||
|
||||
return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt);
|
||||
} catch (InvalidWebhookPayloadException $e) {
|
||||
return response($e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
@@ -254,9 +261,9 @@ class Github extends Controller
|
||||
$return_payloads = collect([]);
|
||||
$id = null;
|
||||
$x_github_delivery = $request->header('X-GitHub-Delivery');
|
||||
$x_github_event = Str::lower($request->header('X-GitHub-Event'));
|
||||
$x_github_event = Str::lower((string) $request->header('X-GitHub-Event'));
|
||||
$x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id');
|
||||
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
|
||||
$x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
|
||||
$payload = $request->collect();
|
||||
if ($x_github_event === 'ping') {
|
||||
// Just pong
|
||||
@@ -300,28 +307,36 @@ class Github extends Controller
|
||||
return response('cool');
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
$id = data_get($payload, 'repository.id');
|
||||
$id = $this->webhookPayloadDatabaseId($payload, 'repository.id');
|
||||
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
|
||||
$commit = $this->webhookCommitSha($payload, 'after');
|
||||
$changed_files = $this->webhookPushChangedFiles($payload);
|
||||
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
$action = data_get($payload, 'action');
|
||||
$id = data_get($payload, 'repository.id');
|
||||
$pull_request_id = data_get($payload, 'number');
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$pull_request_title = data_get($payload, 'pull_request.title');
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
$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);
|
||||
$id = $this->webhookPayloadDatabaseId($payload, 'repository.id');
|
||||
[
|
||||
'action' => $action,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_title' => $pull_request_title,
|
||||
'branch' => $branch,
|
||||
'base_branch' => $base_branch,
|
||||
'before_sha' => $before_sha,
|
||||
'after_sha' => $after_sha,
|
||||
'commit_sha' => $commit_sha,
|
||||
'author_association' => $author_association,
|
||||
'is_fork_pull_request' => $is_fork_pull_request,
|
||||
] = $this->readPullRequestPayload($payload);
|
||||
$full_name = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name'));
|
||||
if ($full_name === null) {
|
||||
return response('Nothing to do. Invalid repository.');
|
||||
}
|
||||
}
|
||||
if (! in_array($x_github_event, ['push', 'pull_request'])) {
|
||||
return response("Nothing to do. Event '$x_github_event' is not supported.");
|
||||
}
|
||||
if (! $id || ! $branch) {
|
||||
if (! $id || ! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) {
|
||||
return response('Nothing to do. No id or branch found.');
|
||||
}
|
||||
if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) {
|
||||
@@ -380,7 +395,7 @@ class Github extends Controller
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
commit: $commit ?? 'HEAD',
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
);
|
||||
@@ -394,7 +409,7 @@ class Github extends Controller
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'commit' => $commit,
|
||||
'github_app_id' => $github_app->id,
|
||||
]);
|
||||
}
|
||||
@@ -439,21 +454,19 @@ class Github extends Controller
|
||||
continue;
|
||||
}
|
||||
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
|
||||
ProcessGithubPullRequestWebhook::dispatch(
|
||||
applicationId: $application->id,
|
||||
githubAppId: $github_app->id,
|
||||
action: $action,
|
||||
pullRequestId: $pull_request_id,
|
||||
pullRequestHtmlUrl: $pull_request_html_url,
|
||||
pullRequestTitle: $pull_request_title ?? null,
|
||||
pullRequestTitle: $pull_request_title,
|
||||
beforeSha: $before_sha,
|
||||
afterSha: $after_sha,
|
||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||
commitSha: $commit_sha,
|
||||
authorAssociation: $author_association,
|
||||
fullName: $full_name,
|
||||
isForkPullRequest: $is_fork_pull_request ?? false,
|
||||
isForkPullRequest: $is_fork_pull_request,
|
||||
);
|
||||
|
||||
$return_payloads->push([
|
||||
@@ -466,11 +479,39 @@ class Github extends Controller
|
||||
}
|
||||
|
||||
return response($return_payloads);
|
||||
} catch (InvalidWebhookPayloadException $e) {
|
||||
return response($e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate the pull_request payload fields that the handlers use.
|
||||
*
|
||||
* @return array{action: string, pull_request_id: int, pull_request_html_url: string, pull_request_title: ?string, branch: ?string, base_branch: ?string, before_sha: ?string, after_sha: ?string, commit_sha: string, author_association: ?string, is_fork_pull_request: bool}
|
||||
*
|
||||
* @throws InvalidWebhookPayloadException When a field has a wrong type or format.
|
||||
*/
|
||||
private function readPullRequestPayload(mixed $payload): array
|
||||
{
|
||||
$head_sha = $this->webhookCommitSha($payload, 'pull_request.head.sha');
|
||||
|
||||
return [
|
||||
'action' => $this->webhookPayloadString($payload, 'action', required: true),
|
||||
'pull_request_id' => $this->webhookPullRequestId($payload, 'number'),
|
||||
'pull_request_html_url' => $this->webhookPayloadUrl($payload, 'pull_request.html_url', required: true),
|
||||
'pull_request_title' => $this->webhookPayloadString($payload, 'pull_request.title'),
|
||||
'branch' => $this->webhookString(data_get($payload, 'pull_request.head.ref')),
|
||||
'base_branch' => $this->webhookString(data_get($payload, 'pull_request.base.ref')),
|
||||
'before_sha' => $this->webhookCommitSha($payload, 'before'),
|
||||
'after_sha' => $this->webhookCommitSha($payload, 'after') ?? $head_sha,
|
||||
'commit_sha' => $head_sha ?? 'HEAD',
|
||||
'author_association' => $this->webhookPayloadString($payload, 'pull_request.author_association'),
|
||||
'is_fork_pull_request' => $this->isForkPullRequest($payload),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a pull_request webhook payload originates from a fork.
|
||||
*
|
||||
@@ -481,14 +522,16 @@ class Github extends Controller
|
||||
* 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).
|
||||
*
|
||||
* @throws InvalidWebhookPayloadException When a repository id is not a positive integer.
|
||||
*/
|
||||
private function isForkPullRequest(mixed $payload): bool
|
||||
{
|
||||
$headRepoId = data_get($payload, 'pull_request.head.repo.id');
|
||||
$baseRepoId = data_get($payload, 'pull_request.base.repo.id');
|
||||
$headRepoId = $this->webhookPayloadId($payload, 'pull_request.head.repo.id');
|
||||
$baseRepoId = $this->webhookPayloadId($payload, 'pull_request.base.repo.id');
|
||||
|
||||
if ($headRepoId !== null && $baseRepoId !== null) {
|
||||
return (string) $headRepoId !== (string) $baseRepoId;
|
||||
return $headRepoId !== $baseRepoId;
|
||||
}
|
||||
|
||||
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Exceptions\InvalidWebhookPayloadException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
@@ -85,11 +86,10 @@ class Gitlab extends Controller
|
||||
$return_payloads = collect([]);
|
||||
$payload = $request->collect();
|
||||
$x_gitlab_token = $request->header('X-Gitlab-Token');
|
||||
$object_kind = data_get($payload, 'object_kind');
|
||||
$project_id = data_get($payload, 'project.id');
|
||||
$object_kind = $this->webhookString(data_get($payload, 'object_kind'));
|
||||
|
||||
$allowed_events = ['push', 'merge_request'];
|
||||
if (! in_array($object_kind, $allowed_events)) {
|
||||
if (! in_array($object_kind, $allowed_events, true)) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'Event not allowed. Only push and merge_request events are allowed.',
|
||||
@@ -119,6 +119,7 @@ class Gitlab extends Controller
|
||||
], 401);
|
||||
}
|
||||
|
||||
$project_id = $this->webhookPayloadDatabaseId($payload, 'project.id', required: true);
|
||||
$applications = Application::where('source_id', $gitlab_app->id)
|
||||
->where('source_type', GitlabApp::class)
|
||||
->where('repository_project_id', $project_id);
|
||||
@@ -137,6 +138,7 @@ class Gitlab extends Controller
|
||||
'message' => 'Nothing to do. Branch deleted.',
|
||||
]);
|
||||
}
|
||||
$commit = $this->webhookCommitSha($payload, 'after');
|
||||
|
||||
$applications = $applications->where('git_branch', $branch)->get();
|
||||
$changed_files = $this->webhookPushChangedFiles($payload);
|
||||
@@ -187,7 +189,7 @@ class Gitlab extends Controller
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
commit: $commit ?? 'HEAD',
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
);
|
||||
@@ -202,7 +204,7 @@ class Gitlab extends Controller
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid->toString(),
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'commit' => $commit,
|
||||
]);
|
||||
|
||||
$return_payloads->push([
|
||||
@@ -214,14 +216,21 @@ class Gitlab extends Controller
|
||||
}
|
||||
|
||||
if ($object_kind === 'merge_request') {
|
||||
$action = data_get($payload, 'object_attributes.action');
|
||||
$branch = data_get($payload, 'object_attributes.source_branch');
|
||||
$base_branch = data_get($payload, 'object_attributes.target_branch');
|
||||
$pull_request_id = data_get($payload, 'object_attributes.iid');
|
||||
$pull_request_html_url = data_get($payload, 'object_attributes.url');
|
||||
$pull_request_title = data_get($payload, 'object_attributes.title');
|
||||
$latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
|
||||
$branch = $this->webhookString(data_get($payload, 'object_attributes.source_branch'));
|
||||
$base_branch = $this->webhookString(data_get($payload, 'object_attributes.target_branch'));
|
||||
if (! $branch || ! $base_branch) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'No branch found in the request.',
|
||||
]);
|
||||
}
|
||||
[
|
||||
'action' => $action,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'commit' => $commit,
|
||||
'skip_deploy' => $skip_deploy_pr,
|
||||
] = $this->readMergeRequestPayload($payload);
|
||||
|
||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
||||
|
||||
@@ -236,7 +245,7 @@ class Gitlab extends Controller
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($action, ['open', 'opened', 'synchronize', 'reopened', 'reopen', 'update'])) {
|
||||
if (in_array($action, ['open', 'opened', 'synchronize', 'reopened', 'reopen', 'update'], true)) {
|
||||
if (! $application->isPRDeployable()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
@@ -277,7 +286,7 @@ class Gitlab extends Controller
|
||||
'git_type' => 'gitlab',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
@@ -286,7 +295,7 @@ class Gitlab extends Controller
|
||||
'git_type' => 'gitlab',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn();
|
||||
}
|
||||
@@ -296,7 +305,7 @@ class Gitlab extends Controller
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'object_attributes.last_commit.id', 'HEAD'),
|
||||
commit: $commit ?? 'HEAD',
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
git_type: 'gitlab',
|
||||
@@ -311,7 +320,7 @@ class Gitlab extends Controller
|
||||
'status' => $result['status'] ?? 'success',
|
||||
'message' => $result['message'] ?? 'Preview Deployment queued',
|
||||
]);
|
||||
} elseif (in_array($action, ['closed', 'close', 'merge'])) {
|
||||
} elseif (in_array($action, ['closed', 'close', 'merge'], true)) {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->first();
|
||||
@@ -329,6 +338,11 @@ class Gitlab extends Controller
|
||||
}
|
||||
|
||||
return response($return_payloads);
|
||||
} catch (InvalidWebhookPayloadException $e) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
@@ -341,9 +355,9 @@ class Gitlab extends Controller
|
||||
$payload = $request->collect();
|
||||
$headers = $request->headers->all();
|
||||
$x_gitlab_token = data_get($headers, 'x-gitlab-token.0');
|
||||
$x_gitlab_event = data_get($payload, 'object_kind');
|
||||
$x_gitlab_event = $this->webhookString(data_get($payload, 'object_kind'));
|
||||
$allowed_events = ['push', 'merge_request'];
|
||||
if (! in_array($x_gitlab_event, $allowed_events)) {
|
||||
if (! in_array($x_gitlab_event, $allowed_events, true)) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Event not allowed. Only push and merge_request events are allowed.',
|
||||
@@ -382,19 +396,14 @@ class Gitlab extends Controller
|
||||
|
||||
return response($return_payloads);
|
||||
}
|
||||
$commit = $this->webhookCommitSha($payload, 'after');
|
||||
$changed_files = $this->webhookPushChangedFiles($payload);
|
||||
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
|
||||
}
|
||||
if ($x_gitlab_event === 'merge_request') {
|
||||
$action = data_get($payload, 'object_attributes.action');
|
||||
$branch = $this->webhookString(data_get($payload, 'object_attributes.source_branch'));
|
||||
$base_branch = $this->webhookString(data_get($payload, 'object_attributes.target_branch'));
|
||||
$full_name = data_get($payload, 'project.path_with_namespace');
|
||||
$pull_request_id = data_get($payload, 'object_attributes.iid');
|
||||
$pull_request_html_url = data_get($payload, 'object_attributes.url');
|
||||
$pull_request_title = data_get($payload, 'object_attributes.title');
|
||||
$latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
|
||||
if (! $branch || ! $base_branch) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
@@ -403,6 +412,13 @@ class Gitlab extends Controller
|
||||
|
||||
return response($return_payloads);
|
||||
}
|
||||
[
|
||||
'action' => $action,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'commit' => $commit,
|
||||
'skip_deploy' => $skip_deploy_pr,
|
||||
] = $this->readMergeRequestPayload($payload);
|
||||
}
|
||||
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||
if ($full_name === null) {
|
||||
@@ -485,7 +501,7 @@ class Gitlab extends Controller
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
commit: $commit ?? 'HEAD',
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
);
|
||||
@@ -505,7 +521,7 @@ class Gitlab extends Controller
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'commit' => $commit,
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
@@ -566,7 +582,7 @@ class Gitlab extends Controller
|
||||
'git_type' => 'gitlab',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
@@ -575,7 +591,7 @@ class Gitlab extends Controller
|
||||
'git_type' => 'gitlab',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn();
|
||||
}
|
||||
@@ -584,7 +600,7 @@ class Gitlab extends Controller
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'object_attributes.last_commit.id', 'HEAD'),
|
||||
commit: $commit ?? 'HEAD',
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
git_type: 'gitlab'
|
||||
@@ -639,8 +655,33 @@ class Gitlab extends Controller
|
||||
}
|
||||
|
||||
return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt);
|
||||
} catch (InvalidWebhookPayloadException $e) {
|
||||
return response([[
|
||||
'status' => 'failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
} catch (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate the merge_request payload fields that the handlers use.
|
||||
*
|
||||
* @return array{action: ?string, pull_request_id: int, pull_request_html_url: ?string, commit: ?string, skip_deploy: bool}
|
||||
*
|
||||
* @throws InvalidWebhookPayloadException When a field has a wrong type or format.
|
||||
*/
|
||||
private function readMergeRequestPayload(mixed $payload): array
|
||||
{
|
||||
$title = $this->webhookPayloadString($payload, 'object_attributes.title');
|
||||
|
||||
return [
|
||||
'action' => $this->webhookPayloadString($payload, 'object_attributes.action'),
|
||||
'pull_request_id' => $this->webhookPullRequestId($payload, 'object_attributes.iid'),
|
||||
'pull_request_html_url' => $this->webhookPayloadUrl($payload, 'object_attributes.url'),
|
||||
'commit' => $this->webhookCommitSha($payload, 'object_attributes.last_commit.id'),
|
||||
'skip_deploy' => self::shouldSkipDeployAny([$title, data_get($payload, 'object_attributes.last_commit.message')]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function githubPullRequestPayload(string $action, string $baseBranch): array
|
||||
'author_association' => 'OWNER',
|
||||
'head' => [
|
||||
'ref' => 'feature/child',
|
||||
'sha' => 'head-sha',
|
||||
'sha' => 'e83c5163316f89bfbde7d9ab23ca2e25604af290',
|
||||
],
|
||||
'base' => [
|
||||
'ref' => $baseBranch,
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('webhook signature failure logging', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('webhook signature failure logging', function () {
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'wrong-token',
|
||||
@@ -297,7 +297,7 @@ describe('webhook signature failure logging', function () {
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
@@ -328,7 +328,7 @@ describe('webhook signature failure logging', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ test('manual webhook routes are not rate limited per request', function (string
|
||||
* An invalid delivery uses a new random wrong secret unless $wrongSecret is set,
|
||||
* so each invalid delivery is a new guess (a new token or a new signature).
|
||||
*/
|
||||
function sendManualWebhookPush(TestCase $test, string $provider, Application $application, bool $validSignature = true, string $ip = '203.0.113.10', string $repository = 'test-org/test-repo', string $branch = 'main', ?string $wrongSecret = null, string $commit = 'abc123'): TestResponse
|
||||
function sendManualWebhookPush(TestCase $test, string $provider, Application $application, bool $validSignature = true, string $ip = '203.0.113.10', string $repository = 'test-org/test-repo', string $branch = 'main', ?string $wrongSecret = null, string $commit = 'abc1234'): TestResponse
|
||||
{
|
||||
$secret = $validSignature ? $application->{"manual_webhook_secret_{$provider}"} : ($wrongSecret ?? 'wrong-secret-'.Str::random(24));
|
||||
$server = ['REMOTE_ADDR' => $ip, 'CONTENT_TYPE' => 'application/json'];
|
||||
@@ -212,13 +212,13 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () {
|
||||
|
||||
// Each delivery has a different payload. Identical redeliveries count once.
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$response = sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: "commit-{$i}");
|
||||
$response = sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: sprintf('abc%04d', $i));
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
}
|
||||
|
||||
sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: 'commit-30')->assertStatus(429);
|
||||
sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: 'abc0030')->assertStatus(429);
|
||||
});
|
||||
|
||||
test('valid deliveries do not count when another matching application has a different secret', function () {
|
||||
@@ -327,7 +327,7 @@ describe('GitHub Manual Webhook HMAC', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -347,7 +347,7 @@ describe('GitHub Manual Webhook HMAC', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -368,7 +368,7 @@ describe('GitHub Manual Webhook HMAC', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -403,7 +403,7 @@ describe('GitHub App Webhook HMAC', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['id' => 987654321],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -430,7 +430,7 @@ describe('GitLab Manual Webhook HMAC', function () {
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'attacker-supplied-token',
|
||||
@@ -447,7 +447,7 @@ describe('GitLab Manual Webhook HMAC', function () {
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'wrong-token',
|
||||
@@ -465,7 +465,7 @@ describe('GitLab Manual Webhook HMAC', function () {
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => $secret,
|
||||
@@ -486,7 +486,7 @@ describe('Bitbucket Manual Webhook HMAC', function () {
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
@@ -505,7 +505,7 @@ describe('Bitbucket Manual Webhook HMAC', function () {
|
||||
$secret = $app->manual_webhook_secret_bitbucket;
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
@@ -523,7 +523,7 @@ describe('Bitbucket Manual Webhook HMAC', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
@@ -542,7 +542,7 @@ describe('Bitbucket Manual Webhook HMAC', function () {
|
||||
$secret = $app->manual_webhook_secret_bitbucket;
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
@@ -571,7 +571,7 @@ describe('Gitea Manual Webhook HMAC', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -591,7 +591,7 @@ describe('Gitea Manual Webhook HMAC', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -612,7 +612,7 @@ describe('Gitea Manual Webhook HMAC', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -638,7 +638,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => ''],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -661,7 +661,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -684,7 +684,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -727,7 +727,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => ''],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
],
|
||||
['HTTP_X-Gitlab-Token' => 'wrong-token'],
|
||||
@@ -736,7 +736,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
'bitbucket',
|
||||
'/webhooks/source/bitbucket/events/manual',
|
||||
[
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
|
||||
'repository' => ['full_name' => ''],
|
||||
],
|
||||
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
|
||||
@@ -747,7 +747,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
[
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => ''],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
],
|
||||
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
|
||||
@@ -778,7 +778,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
],
|
||||
['HTTP_X-Gitlab-Token' => 'wrong-token'],
|
||||
@@ -787,7 +787,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
'bitbucket',
|
||||
'/webhooks/source/bitbucket/events/manual',
|
||||
[
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test'],
|
||||
],
|
||||
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
|
||||
@@ -798,7 +798,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
[
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
],
|
||||
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
|
||||
@@ -814,7 +814,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -837,7 +837,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -860,7 +860,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -885,7 +885,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/master',
|
||||
'project' => ['path_with_namespace' => 'services/xyz'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => $secret,
|
||||
@@ -906,7 +906,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/master',
|
||||
'project' => ['path_with_namespace' => 'services/xyz'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => $secret,
|
||||
@@ -925,7 +925,7 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
@@ -995,7 +995,7 @@ function sendSignedManualWebhook(TestCase $test, string $provider, Application $
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function manualWebhookPushPayloadWithoutCommits(string $provider, string $after = 'abc123'): array
|
||||
function manualWebhookPushPayloadWithoutCommits(string $provider, string $after = 'abc1234'): array
|
||||
{
|
||||
if ($provider === 'gitlab') {
|
||||
return [
|
||||
@@ -1070,7 +1070,7 @@ describe('Manual Webhook Push Payloads Without Commits', function () {
|
||||
test('a github push marked as deleted does not queue a deployment', function () {
|
||||
Queue::fake();
|
||||
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
|
||||
$payload = manualWebhookPushPayloadWithoutCommits('github', after: 'abc123') + ['deleted' => true, 'commits' => []];
|
||||
$payload = manualWebhookPushPayloadWithoutCommits('github', after: 'abc1234') + ['deleted' => true, 'commits' => []];
|
||||
|
||||
$response = sendSignedManualWebhook($this, 'github', $application, $payload);
|
||||
|
||||
@@ -1112,7 +1112,7 @@ describe('Manual Webhook Malformed Payloads', function () {
|
||||
'bitbucket branch deletion' => ['bitbucket', ['push' => ['changes' => [['new' => null, 'old' => ['name' => 'main']]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'],
|
||||
'bitbucket with an array branch' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => ['main']]]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'],
|
||||
'bitbucket without repository' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => 'main']]]]], 'Invalid repository'],
|
||||
'bitbucket with malformed commits' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']], 'commits' => [['message' => ['x']], 'text']]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'Deployment queued'],
|
||||
'bitbucket with malformed commits' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']], 'commits' => [['message' => ['x']], 'text']]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'Deployment queued'],
|
||||
]);
|
||||
|
||||
test('gitea deliveries for unsupported events get a clean response', function () {
|
||||
@@ -1158,7 +1158,7 @@ describe('App Webhook Push Payloads Without Commits', function () {
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['id' => 987654321],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [
|
||||
@@ -1196,7 +1196,7 @@ describe('App Webhook Push Payloads Without Commits', function () {
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['id' => 4242],
|
||||
'after' => 'abc123',
|
||||
'after' => 'abc1234',
|
||||
];
|
||||
if ($variant === 'null') {
|
||||
$payload['commits'] = null;
|
||||
@@ -1333,9 +1333,9 @@ describe('Manual Webhook Repeated Failed Deliveries', function () {
|
||||
$application = createApplicationWithWebhook();
|
||||
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: "commit-{$i}")->assertOk();
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: sprintf('abc%04d', $i))->assertOk();
|
||||
}
|
||||
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: 'commit-30')->assertStatus(429);
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: 'abc0030')->assertStatus(429);
|
||||
})->with(['github', 'bitbucket', 'gitea']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Jobs\ProcessGithubPullRequestWebhook;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Queue::fake();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
});
|
||||
|
||||
function webhookTypesSha(): string
|
||||
{
|
||||
return 'e83c5163316f89bfbde7d9ab23ca2e25604af290';
|
||||
}
|
||||
|
||||
/**
|
||||
* An application that the handler deploys for a valid payload. App handlers
|
||||
* get a GitHub App or GitLab App source.
|
||||
*/
|
||||
function webhookTypesApplication(string $handler): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
$source = [];
|
||||
if ($handler === 'github app') {
|
||||
$githubApp = GithubApp::create([
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'github-app-payload-types',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'app_id' => 1234567890,
|
||||
'webhook_secret' => 'github-app-secret',
|
||||
'team_id' => $team->id,
|
||||
'is_public' => false,
|
||||
]);
|
||||
$source = ['source_id' => $githubApp->id, 'source_type' => GithubApp::class, 'repository_project_id' => 987654321];
|
||||
}
|
||||
if ($handler === 'gitlab app') {
|
||||
$gitlabApp = GitlabApp::create([
|
||||
'name' => 'gitlab-app-payload-types',
|
||||
'api_url' => 'https://gitlab.com/api/v4',
|
||||
'html_url' => 'https://gitlab.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'webhook_token' => 'gitlab-app-token',
|
||||
'team_id' => $team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
$source = ['source_id' => $gitlabApp->id, 'source_type' => GitlabApp::class, 'repository_project_id' => 4242];
|
||||
}
|
||||
|
||||
$application = Application::create(array_merge([
|
||||
'name' => 'webhook-payload-types-app',
|
||||
'git_repository' => 'https://github.com/test-org/test-repo',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
], $source));
|
||||
$application->settings->update([
|
||||
'is_auto_deploy_enabled' => true,
|
||||
'is_preview_deployments_enabled' => true,
|
||||
]);
|
||||
|
||||
return $application->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* A valid payload for the handler and event.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function webhookTypesPayload(string $handler, string $event, string $state = 'open'): array
|
||||
{
|
||||
$sha = webhookTypesSha();
|
||||
$provider = str($handler)->before(' ')->value();
|
||||
|
||||
if ($event === 'push') {
|
||||
return match ($provider) {
|
||||
'gitlab' => [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['id' => 4242, 'path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => $sha,
|
||||
'commits' => [],
|
||||
],
|
||||
'bitbucket' => [
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => $sha]]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
],
|
||||
default => [
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['id' => 987654321, 'full_name' => 'test-org/test-repo'],
|
||||
'after' => $sha,
|
||||
'commits' => [],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
$closed = $state === 'closed';
|
||||
|
||||
return match ($provider) {
|
||||
'github' => [
|
||||
'action' => $closed ? 'closed' : 'opened',
|
||||
'number' => 42,
|
||||
'repository' => ['id' => 987654321, 'full_name' => 'test-org/test-repo'],
|
||||
'pull_request' => [
|
||||
'html_url' => 'https://github.com/test-org/test-repo/pull/42',
|
||||
'title' => 'Add feature',
|
||||
'author_association' => 'OWNER',
|
||||
'head' => ['ref' => 'feature', 'sha' => $sha, 'repo' => ['id' => 987654321, 'full_name' => 'test-org/test-repo']],
|
||||
'base' => ['ref' => 'main', 'repo' => ['id' => 987654321, 'full_name' => 'test-org/test-repo']],
|
||||
],
|
||||
],
|
||||
'gitlab' => [
|
||||
'object_kind' => 'merge_request',
|
||||
'project' => ['id' => 4242, 'path_with_namespace' => 'test-org/test-repo'],
|
||||
'object_attributes' => [
|
||||
'action' => $closed ? 'close' : 'open',
|
||||
'iid' => 7,
|
||||
'url' => 'https://gitlab.com/test-org/test-repo/-/merge_requests/7',
|
||||
'title' => 'Add feature',
|
||||
'source_branch' => 'feature',
|
||||
'target_branch' => 'main',
|
||||
'source_project_id' => 4242,
|
||||
'target_project_id' => 4242,
|
||||
'last_commit' => ['id' => $sha, 'message' => 'Add feature'],
|
||||
],
|
||||
],
|
||||
'gitea' => [
|
||||
'action' => $closed ? 'closed' : 'opened',
|
||||
'number' => 7,
|
||||
'repository' => ['id' => 55, 'full_name' => 'test-org/test-repo'],
|
||||
'pull_request' => [
|
||||
'html_url' => 'https://gitea.example.com/test-org/test-repo/pulls/7',
|
||||
'title' => 'Add feature',
|
||||
'head' => ['ref' => 'feature', 'sha' => $sha, 'repo' => ['id' => 55]],
|
||||
'base' => ['ref' => 'main', 'repo' => ['id' => 55]],
|
||||
],
|
||||
],
|
||||
'bitbucket' => [
|
||||
'pullrequest' => [
|
||||
'id' => 7,
|
||||
'title' => 'Add feature',
|
||||
'links' => ['html' => ['href' => 'https://bitbucket.org/test-org/test-repo/pull-requests/7']],
|
||||
'source' => ['branch' => ['name' => 'feature'], 'commit' => ['hash' => substr($sha, 0, 12)], 'repository' => ['uuid' => '{repo-uuid}']],
|
||||
'destination' => ['branch' => ['name' => 'main'], 'repository' => ['uuid' => '{repo-uuid}']],
|
||||
],
|
||||
'repository' => ['full_name' => 'test-org/test-repo', 'uuid' => '{repo-uuid}'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a correctly signed (or token-authenticated) delivery to the handler.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
function webhookTypesSend(TestCase $test, string $handler, string $event, string $state, Application $application, array $payload): TestResponse
|
||||
{
|
||||
[$provider, $mode] = explode(' ', $handler);
|
||||
$body = json_encode($payload, JSON_THROW_ON_ERROR);
|
||||
$secret = match ($handler) {
|
||||
'github app' => 'github-app-secret',
|
||||
'gitlab app' => 'gitlab-app-token',
|
||||
default => $application->{"manual_webhook_secret_{$provider}"},
|
||||
};
|
||||
$signature = 'sha256='.hash_hmac('sha256', $body, $secret);
|
||||
$headers = match ($provider) {
|
||||
'github' => [
|
||||
'HTTP_X-GitHub-Event' => $event === 'push' ? 'push' : 'pull_request',
|
||||
'HTTP_X-Hub-Signature-256' => $signature,
|
||||
'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890',
|
||||
],
|
||||
'gitea' => [
|
||||
'HTTP_X-Gitea-Event' => $event === 'push' ? 'push' : 'pull_request',
|
||||
'HTTP_X-Hub-Signature-256' => $signature,
|
||||
],
|
||||
'gitlab' => ['HTTP_X-Gitlab-Token' => $secret],
|
||||
'bitbucket' => [
|
||||
'HTTP_X-Event-Key' => $event === 'push' ? 'repo:push' : ($state === 'closed' ? 'pullrequest:fulfilled' : 'pullrequest:created'),
|
||||
'HTTP_X-Hub-Signature' => $signature,
|
||||
],
|
||||
};
|
||||
$uri = $mode === 'manual' ? "/webhooks/source/{$provider}/events/manual" : "/webhooks/source/{$provider}/events";
|
||||
|
||||
return $test->call('POST', $uri, [], [], [], $headers + [
|
||||
'REMOTE_ADDR' => '203.0.113.20',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid values for a kind of payload field.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function webhookTypesInvalidValues(string $kind): array
|
||||
{
|
||||
$wrongTypes = [
|
||||
'an array' => ['x'],
|
||||
'an object' => ['key' => 'value'],
|
||||
'true' => true,
|
||||
'false' => false,
|
||||
];
|
||||
|
||||
return match ($kind) {
|
||||
'string' => $wrongTypes + ['an integer' => 123, 'a float' => 1.5],
|
||||
'required string' => $wrongTypes + ['an integer' => 123, 'null' => null],
|
||||
'branch' => $wrongTypes + ['an integer' => 123, 'null' => null],
|
||||
'sha' => $wrongTypes + [
|
||||
'an integer' => 1234567,
|
||||
'a short sha' => 'abc12',
|
||||
'an oversized sha' => str_repeat('a', 65),
|
||||
'a non-hex sha' => 'zzzzzzzz',
|
||||
'a shell expression' => '$(id)',
|
||||
'a git option' => '--upload-pack=touch',
|
||||
],
|
||||
'id' => $wrongTypes + [
|
||||
'null' => null,
|
||||
'zero' => 0,
|
||||
'a negative integer' => -1,
|
||||
'a float' => 1.5,
|
||||
'text' => 'abc',
|
||||
'mixed text' => '12abc',
|
||||
'an oversized integer' => 2147483648,
|
||||
'an oversized numeric string' => '99999999999999999999999',
|
||||
],
|
||||
'repository id' => $wrongTypes + ['zero' => 0, 'a negative integer' => -1, 'a float' => 1.5, 'text' => 'abc'],
|
||||
'url' => $wrongTypes + [
|
||||
'an integer' => 123,
|
||||
'a javascript url' => 'javascript:alert(1)',
|
||||
'a relative url' => '/test-org/test-repo/pull/1',
|
||||
'a url with spaces' => 'https://example.com/a b',
|
||||
'an oversized url' => 'https://example.com/'.str_repeat('a', 300),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload fields that each handler reads: [path, kind, expected message].
|
||||
* A null message means "Invalid '<path>'".
|
||||
*
|
||||
* @return array<string, array<int, array{0: string, 1: string, 2: ?string}>>
|
||||
*/
|
||||
function webhookTypesFields(): array
|
||||
{
|
||||
return [
|
||||
'github manual|push|open' => [
|
||||
['ref', 'branch', 'No branch'],
|
||||
['after', 'sha', null],
|
||||
['repository.full_name', 'required string', 'Invalid repository'],
|
||||
],
|
||||
'github manual|pr|open' => [
|
||||
['action', 'required string', null],
|
||||
['number', 'id', null],
|
||||
['pull_request.html_url', 'url', null],
|
||||
['pull_request.title', 'string', null],
|
||||
['pull_request.author_association', 'string', null],
|
||||
['pull_request.head.ref', 'branch', 'No branch'],
|
||||
['pull_request.base.ref', 'branch', 'No branch'],
|
||||
['pull_request.head.sha', 'sha', null],
|
||||
['after', 'sha', null],
|
||||
['before', 'sha', null],
|
||||
['pull_request.head.repo.id', 'repository id', null],
|
||||
['pull_request.base.repo.id', 'repository id', null],
|
||||
['repository.full_name', 'required string', 'Invalid repository'],
|
||||
],
|
||||
'github manual|pr|closed' => [
|
||||
['action', 'required string', null],
|
||||
['number', 'id', null],
|
||||
['pull_request.html_url', 'url', null],
|
||||
],
|
||||
'github app|push|open' => [
|
||||
['repository.id', 'id', 'Nothing to do.'],
|
||||
['ref', 'branch', 'No id or branch'],
|
||||
['after', 'sha', null],
|
||||
],
|
||||
'github app|pr|open' => [
|
||||
['repository.id', 'id', 'Nothing to do.'],
|
||||
['action', 'required string', null],
|
||||
['number', 'id', null],
|
||||
['pull_request.html_url', 'url', null],
|
||||
['pull_request.title', 'string', null],
|
||||
['pull_request.author_association', 'string', null],
|
||||
['pull_request.head.ref', 'branch', 'No id or branch'],
|
||||
['pull_request.base.ref', 'branch', 'No id or branch'],
|
||||
['pull_request.head.sha', 'sha', null],
|
||||
['after', 'sha', null],
|
||||
['before', 'sha', null],
|
||||
['pull_request.head.repo.id', 'repository id', null],
|
||||
['repository.full_name', 'required string', 'Invalid repository'],
|
||||
],
|
||||
'github app|pr|closed' => [
|
||||
['action', 'required string', null],
|
||||
['number', 'id', null],
|
||||
['pull_request.html_url', 'url', null],
|
||||
],
|
||||
'gitlab manual|push|open' => [
|
||||
['object_kind', 'string', 'Event not allowed'],
|
||||
['ref', 'branch', 'No branch'],
|
||||
['after', 'sha', null],
|
||||
['project.path_with_namespace', 'required string', 'Invalid repository'],
|
||||
],
|
||||
'gitlab manual|pr|open' => [
|
||||
['object_attributes.action', 'string', null],
|
||||
['object_attributes.iid', 'id', null],
|
||||
['object_attributes.url', 'url', null],
|
||||
['object_attributes.title', 'string', null],
|
||||
['object_attributes.source_branch', 'branch', 'No branch'],
|
||||
['object_attributes.target_branch', 'branch', 'No branch'],
|
||||
['object_attributes.last_commit.id', 'sha', null],
|
||||
],
|
||||
'gitlab manual|pr|closed' => [
|
||||
['object_attributes.action', 'string', null],
|
||||
['object_attributes.iid', 'id', null],
|
||||
],
|
||||
'gitlab app|push|open' => [
|
||||
['object_kind', 'string', 'Event not allowed'],
|
||||
['project.id', 'id', null],
|
||||
['ref', 'branch', 'No branch'],
|
||||
['after', 'sha', null],
|
||||
],
|
||||
'gitlab app|pr|open' => [
|
||||
['project.id', 'id', null],
|
||||
['object_attributes.action', 'string', null],
|
||||
['object_attributes.iid', 'id', null],
|
||||
['object_attributes.url', 'url', null],
|
||||
['object_attributes.title', 'string', null],
|
||||
['object_attributes.source_branch', 'branch', 'No branch'],
|
||||
['object_attributes.target_branch', 'branch', 'No branch'],
|
||||
['object_attributes.last_commit.id', 'sha', null],
|
||||
],
|
||||
'gitlab app|pr|closed' => [
|
||||
['object_attributes.action', 'string', null],
|
||||
['object_attributes.iid', 'id', null],
|
||||
],
|
||||
'gitea manual|push|open' => [
|
||||
['ref', 'branch', 'No branch'],
|
||||
['after', 'sha', null],
|
||||
['repository.full_name', 'required string', 'Invalid repository'],
|
||||
],
|
||||
'gitea manual|pr|open' => [
|
||||
['action', 'string', null],
|
||||
['number', 'id', null],
|
||||
['pull_request.html_url', 'url', null],
|
||||
['pull_request.title', 'string', null],
|
||||
['pull_request.head.ref', 'branch', 'No branch'],
|
||||
['pull_request.base.ref', 'branch', 'No branch'],
|
||||
['head.sha', 'sha', null],
|
||||
['repository.full_name', 'required string', 'Invalid repository'],
|
||||
],
|
||||
'gitea manual|pr|closed' => [
|
||||
['action', 'string', null],
|
||||
['number', 'id', null],
|
||||
],
|
||||
'bitbucket manual|push|open' => [
|
||||
['push.changes.0.new.name', 'branch', 'No branch'],
|
||||
['push.changes.0.new.target.hash', 'sha', null],
|
||||
['repository.full_name', 'required string', 'Invalid repository'],
|
||||
],
|
||||
'bitbucket manual|pr|open' => [
|
||||
['pullrequest.id', 'id', null],
|
||||
['pullrequest.links.html.href', 'url', null],
|
||||
['pullrequest.title', 'string', null],
|
||||
['pullrequest.destination.branch.name', 'branch', 'No branch'],
|
||||
['pullrequest.source.commit.hash', 'sha', null],
|
||||
['repository.full_name', 'required string', 'Invalid repository'],
|
||||
],
|
||||
'bitbucket manual|pr|closed' => [
|
||||
['pullrequest.id', 'id', null],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Generator<string, array{0: string, 1: string, 2: string, 3: string, 4: mixed, 5: string}>
|
||||
*/
|
||||
function webhookTypesInvalidDeliveries(): Generator
|
||||
{
|
||||
foreach (webhookTypesFields() as $target => $fields) {
|
||||
[$handler, $event, $state] = explode('|', $target);
|
||||
foreach ($fields as [$path, $kind, $message]) {
|
||||
foreach (webhookTypesInvalidValues($kind) as $label => $value) {
|
||||
yield "{$handler} {$event} {$state}: {$path} is {$label}" => [
|
||||
$handler, $event, $state, $path, $value, $message ?? "Invalid '{$path}'",
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function webhookTypesCreatePreview(Application $application, string $handler): ApplicationPreview
|
||||
{
|
||||
return ApplicationPreview::create([
|
||||
'git_type' => str($handler)->before(' ')->value(),
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 7,
|
||||
'pull_request_html_url' => 'https://example.com/pull/7',
|
||||
]);
|
||||
}
|
||||
|
||||
describe('Webhook payload value types', function () {
|
||||
test('a signed delivery with an invalid value gets a clean response and does not deploy', function (string $handler, string $event, string $state, string $path, mixed $value, string $message) {
|
||||
$application = webhookTypesApplication($handler);
|
||||
$closedPreview = $event === 'pr' && $state === 'closed' && ! str_starts_with($handler, 'github')
|
||||
? webhookTypesCreatePreview($application, $handler)
|
||||
: null;
|
||||
CleanupPreviewDeployment::shouldNotRun();
|
||||
|
||||
$payload = webhookTypesPayload($handler, $event, $state);
|
||||
data_set($payload, $path, $value);
|
||||
|
||||
$response = webhookTypesSend($this, $handler, $event, $state, $application, $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain($message)
|
||||
->not->toContain('queued');
|
||||
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
|
||||
expect(ApplicationPreview::query()->where('application_id', $application->id)->count())->toBe($closedPreview ? 1 : 0);
|
||||
Queue::assertNotPushed(ProcessGithubPullRequestWebhook::class);
|
||||
})->with(fn (): Generator => webhookTypesInvalidDeliveries());
|
||||
|
||||
test('a valid push is deployed with its commit', function (string $handler) {
|
||||
$application = webhookTypesApplication($handler);
|
||||
|
||||
$response = webhookTypesSend($this, $handler, 'push', 'open', $application, webhookTypesPayload($handler, 'push'));
|
||||
|
||||
$response->assertOk();
|
||||
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->pluck('commit')->all())
|
||||
->toBe([webhookTypesSha()]);
|
||||
})->with(['github manual', 'github app', 'gitlab manual', 'gitlab app', 'gitea manual', 'bitbucket manual']);
|
||||
|
||||
test('a valid github pull request is queued for processing', function (string $handler, string $state) {
|
||||
$application = webhookTypesApplication($handler);
|
||||
|
||||
$response = webhookTypesSend($this, $handler, 'pr', $state, $application, webhookTypesPayload($handler, 'pr', $state));
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('PR webhook received');
|
||||
Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->applicationId === $application->id
|
||||
&& $job->action === ($state === 'closed' ? 'closed' : 'opened')
|
||||
&& $job->pullRequestId === 42
|
||||
&& $job->pullRequestHtmlUrl === 'https://github.com/test-org/test-repo/pull/42'
|
||||
&& $job->pullRequestTitle === 'Add feature'
|
||||
&& $job->commitSha === webhookTypesSha()
|
||||
&& $job->authorAssociation === 'OWNER'
|
||||
&& $job->fullName === 'test-org/test-repo'
|
||||
&& $job->isForkPullRequest === false);
|
||||
})->with(['github manual', 'github app'])->with(['open', 'closed']);
|
||||
|
||||
test('a github pull request with a numeric string number and sync shas is queued', function (string $handler) {
|
||||
$application = webhookTypesApplication($handler);
|
||||
$payload = webhookTypesPayload($handler, 'pr');
|
||||
$payload['action'] = 'synchronize';
|
||||
$payload['number'] = '42';
|
||||
$payload['before'] = str_repeat('b', 40);
|
||||
$payload['after'] = webhookTypesSha();
|
||||
|
||||
webhookTypesSend($this, $handler, 'pr', 'open', $application, $payload)->assertOk();
|
||||
|
||||
Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->pullRequestId === 42
|
||||
&& $job->beforeSha === str_repeat('b', 40)
|
||||
&& $job->afterSha === webhookTypesSha());
|
||||
})->with(['github manual', 'github app']);
|
||||
|
||||
test('a valid pull request opens a preview deployment', function (string $handler, string $commit) {
|
||||
$application = webhookTypesApplication($handler);
|
||||
|
||||
$response = webhookTypesSend($this, $handler, 'pr', 'open', $application, webhookTypesPayload($handler, 'pr'));
|
||||
|
||||
$response->assertOk();
|
||||
expect(ApplicationPreview::query()->where('application_id', $application->id)->where('pull_request_id', 7)->exists())->toBeTrue();
|
||||
$deployment = ApplicationDeploymentQueue::query()->where('application_id', $application->id)->sole();
|
||||
expect($deployment->pull_request_id)->toBe(7)
|
||||
->and($deployment->commit)->toBe($commit);
|
||||
})->with([
|
||||
'gitlab manual' => ['gitlab manual', webhookTypesSha()],
|
||||
'gitlab app' => ['gitlab app', webhookTypesSha()],
|
||||
'gitea manual' => ['gitea manual', 'HEAD'],
|
||||
'bitbucket manual' => ['bitbucket manual', substr(webhookTypesSha(), 0, 12)],
|
||||
]);
|
||||
|
||||
test('a merge request id as a numeric string is accepted', function (string $handler) {
|
||||
$application = webhookTypesApplication($handler);
|
||||
$payload = webhookTypesPayload($handler, 'pr');
|
||||
data_set($payload, 'object_attributes.iid', '7');
|
||||
|
||||
webhookTypesSend($this, $handler, 'pr', 'open', $application, $payload)->assertOk();
|
||||
|
||||
expect(ApplicationPreview::query()->where('application_id', $application->id)->where('pull_request_id', 7)->exists())->toBeTrue();
|
||||
})->with(['gitlab manual', 'gitlab app']);
|
||||
|
||||
test('a pull request without a url opens a preview with an empty url', function () {
|
||||
$application = webhookTypesApplication('gitlab manual');
|
||||
$payload = webhookTypesPayload('gitlab manual', 'pr');
|
||||
unset($payload['object_attributes']['url']);
|
||||
|
||||
webhookTypesSend($this, 'gitlab manual', 'pr', 'open', $application, $payload)->assertOk();
|
||||
|
||||
expect(ApplicationPreview::query()->where('application_id', $application->id)->sole()->pull_request_html_url)->toBe('');
|
||||
});
|
||||
|
||||
test('an unsigned github delivery in dev mode with an invalid value gets a clean response', function (string $event, string $path) {
|
||||
config()->set('app.env', 'local');
|
||||
$application = webhookTypesApplication('github manual');
|
||||
$payload = webhookTypesPayload('github manual', $event);
|
||||
data_set($payload, $path, ['x']);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => $event === 'push' ? 'push' : 'pull_request',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain("Invalid '{$path}'");
|
||||
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
|
||||
Queue::assertNotPushed(ProcessGithubPullRequestWebhook::class);
|
||||
})->with([
|
||||
'push after' => ['push', 'after'],
|
||||
'pull request number' => ['pr', 'number'],
|
||||
'pull request html_url' => ['pr', 'pull_request.html_url'],
|
||||
]);
|
||||
|
||||
test('a valid closed pull request cleans up the preview deployment', function (string $handler) {
|
||||
$application = webhookTypesApplication($handler);
|
||||
webhookTypesCreatePreview($application, $handler);
|
||||
CleanupPreviewDeployment::shouldRun()->once();
|
||||
|
||||
$response = webhookTypesSend($this, $handler, 'pr', 'closed', $application, webhookTypesPayload($handler, 'pr', 'closed'));
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Preview deployment closed');
|
||||
})->with(['gitlab manual', 'gitlab app', 'gitea manual', 'bitbucket manual']);
|
||||
});
|
||||
Reference in New Issue
Block a user