fix(webhook): match manual webhook repositories exactly (#10361)
This commit is contained in:
commit
11dbcfcfe8
6 changed files with 380 additions and 60 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Actions\Application\CleanupPreviewDeployment;
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||||
|
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
class Bitbucket extends Controller
|
class Bitbucket extends Controller
|
||||||
{
|
{
|
||||||
use DetectsSkipDeployCommits;
|
use DetectsSkipDeployCommits;
|
||||||
|
use MatchesManualWebhookApplications;
|
||||||
|
|
||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
|
|
@ -62,8 +64,14 @@ public function manual(Request $request)
|
||||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
||||||
$commit = data_get($payload, 'pullrequest.source.commit.hash');
|
$commit = data_get($payload, 'pullrequest.source.commit.hash');
|
||||||
}
|
}
|
||||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
if ($full_name === null) {
|
||||||
|
return response([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Nothing to do. Invalid repository.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response([
|
return response([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
|
|
@ -79,11 +87,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_bitbucket_event,
|
'event' => $x_bitbucket_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Webhook secret not configured.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -97,11 +101,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_bitbucket_event,
|
'event' => $x_bitbucket_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -114,11 +114,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_bitbucket_event,
|
'event' => $x_bitbucket_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Webhook\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
trait MatchesManualWebhookApplications
|
||||||
|
{
|
||||||
|
protected function manualWebhookRepositoryFullName(mixed $fullName): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($fullName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullName = trim($fullName, " \t\n\r\0\x0B/");
|
||||||
|
|
||||||
|
if ($fullName === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/\A[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\z/', $fullName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeManualWebhookRepositoryPath($fullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Application>
|
||||||
|
*/
|
||||||
|
protected function manualWebhookApplications(Builder $query, string $fullName): Collection
|
||||||
|
{
|
||||||
|
return $query->get()
|
||||||
|
->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool
|
||||||
|
{
|
||||||
|
$repositoryPath = $this->canonicalManualWebhookRepository($gitRepository);
|
||||||
|
|
||||||
|
if ($repositoryPath === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names
|
||||||
|
// case-insensitively, so compare the canonical paths case-insensitively.
|
||||||
|
return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{status: string, message: string}
|
||||||
|
*/
|
||||||
|
protected function unauthenticatedManualWebhookFailurePayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Invalid signature.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function canonicalManualWebhookRepository(?string $gitRepository): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($gitRepository)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gitRepository = trim($gitRepository);
|
||||||
|
|
||||||
|
if ($gitRepository === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = null;
|
||||||
|
$parts = parse_url($gitRepository);
|
||||||
|
|
||||||
|
if (is_array($parts) && isset($parts['scheme'])) {
|
||||||
|
$path = data_get($parts, 'path');
|
||||||
|
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
|
||||||
|
$path = Str::after($gitRepository, ':');
|
||||||
|
} else {
|
||||||
|
$path = $gitRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($path) || $path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeManualWebhookRepositoryPath($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeManualWebhookRepositoryPath(string $path): string
|
||||||
|
{
|
||||||
|
$path = trim($path);
|
||||||
|
$path = strtok($path, '?#') ?: $path;
|
||||||
|
$path = trim($path, '/');
|
||||||
|
$path = preg_replace('/\.git\z/i', '', $path) ?? $path;
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Actions\Application\CleanupPreviewDeployment;
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||||
|
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -15,6 +16,7 @@
|
||||||
class Gitea extends Controller
|
class Gitea extends Controller
|
||||||
{
|
{
|
||||||
use DetectsSkipDeployCommits;
|
use DetectsSkipDeployCommits;
|
||||||
|
use MatchesManualWebhookApplications;
|
||||||
|
|
||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
|
|
@ -58,15 +60,19 @@ public function manual(Request $request)
|
||||||
if (! $branch) {
|
if (! $branch) {
|
||||||
return response('Nothing to do. No branch found in the request.');
|
return response('Nothing to do. No branch found in the request.');
|
||||||
}
|
}
|
||||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||||
|
if ($full_name === null) {
|
||||||
|
return response('Nothing to do. Invalid repository.');
|
||||||
|
}
|
||||||
|
$applications = Application::query();
|
||||||
if ($x_gitea_event === 'push') {
|
if ($x_gitea_event === 'push') {
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($x_gitea_event === 'pull_request') {
|
if ($x_gitea_event === 'pull_request') {
|
||||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
||||||
}
|
}
|
||||||
|
|
@ -80,11 +86,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_gitea_event,
|
'event' => $x_gitea_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Webhook secret not configured.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -96,11 +98,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_gitea_event,
|
'event' => $x_gitea_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||||
|
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||||
use App\Jobs\GithubAppPermissionJob;
|
use App\Jobs\GithubAppPermissionJob;
|
||||||
use App\Jobs\ProcessGithubPullRequestWebhook;
|
use App\Jobs\ProcessGithubPullRequestWebhook;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
class Github extends Controller
|
class Github extends Controller
|
||||||
{
|
{
|
||||||
use DetectsSkipDeployCommits;
|
use DetectsSkipDeployCommits;
|
||||||
|
use MatchesManualWebhookApplications;
|
||||||
|
|
||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
|
|
@ -66,15 +68,19 @@ public function manual(Request $request)
|
||||||
if (! $branch) {
|
if (! $branch) {
|
||||||
return response('Nothing to do. No branch found in the request.');
|
return response('Nothing to do. No branch found in the request.');
|
||||||
}
|
}
|
||||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||||
|
if ($full_name === null) {
|
||||||
|
return response('Nothing to do. Invalid repository.');
|
||||||
|
}
|
||||||
|
$applications = Application::query();
|
||||||
if ($x_github_event === 'push') {
|
if ($x_github_event === 'push') {
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($x_github_event === 'pull_request') {
|
if ($x_github_event === 'pull_request') {
|
||||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
|
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
|
||||||
}
|
}
|
||||||
|
|
@ -93,11 +99,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'mode' => 'manual',
|
'mode' => 'manual',
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Webhook secret not configured.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -109,11 +111,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'mode' => 'manual',
|
'mode' => 'manual',
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Actions\Application\CleanupPreviewDeployment;
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||||
|
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -15,6 +16,7 @@
|
||||||
class Gitlab extends Controller
|
class Gitlab extends Controller
|
||||||
{
|
{
|
||||||
use DetectsSkipDeployCommits;
|
use DetectsSkipDeployCommits;
|
||||||
|
use MatchesManualWebhookApplications;
|
||||||
|
|
||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
|
|
@ -85,9 +87,18 @@ public function manual(Request $request)
|
||||||
return response($return_payloads);
|
return response($return_payloads);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||||
|
if ($full_name === null) {
|
||||||
|
$return_payloads->push([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Nothing to do. Invalid repository.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response($return_payloads);
|
||||||
|
}
|
||||||
|
$applications = Application::query();
|
||||||
if ($x_gitlab_event === 'push') {
|
if ($x_gitlab_event === 'push') {
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
|
|
@ -98,7 +109,7 @@ public function manual(Request $request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($x_gitlab_event === 'merge_request') {
|
if ($x_gitlab_event === 'merge_request') {
|
||||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
|
|
@ -117,11 +128,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_gitlab_event,
|
'event' => $x_gitlab_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Webhook secret not configured.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -132,11 +139,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_gitlab_event,
|
'event' => $x_gitlab_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin
|
||||||
], $payload);
|
], $payload);
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
expect($response->getContent())->toContain('Invalid signature');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects push with forged hash', function () {
|
test('rejects push with forged hash', function () {
|
||||||
|
|
@ -118,7 +118,7 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
expect($response->getContent())->toContain('Invalid signature');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects push with wrong token', function () {
|
test('rejects push with wrong token', function () {
|
||||||
|
|
@ -178,7 +178,7 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin
|
||||||
], $payload);
|
], $payload);
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
expect($response->getContent())->toContain('Invalid signature');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects push with non-sha256 algorithm', function () {
|
test('rejects push with non-sha256 algorithm', function () {
|
||||||
|
|
@ -263,7 +263,7 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin
|
||||||
], $payload);
|
], $payload);
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
expect($response->getContent())->toContain('Invalid signature');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects push with forged hash', function () {
|
test('rejects push with forged hash', function () {
|
||||||
|
|
@ -312,6 +312,227 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Manual Webhook Repository Matching', function () {
|
||||||
|
test('github rejects empty repository without leaking applications', function () {
|
||||||
|
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'ref' => 'refs/heads/main',
|
||||||
|
'repository' => ['full_name' => ''],
|
||||||
|
'after' => 'abc123',
|
||||||
|
'commits' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||||
|
'HTTP_X-GitHub-Event' => 'push',
|
||||||
|
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], $payload);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$content = $response->getContent();
|
||||||
|
expect($content)->toContain('Invalid repository')
|
||||||
|
->not->toContain('secret-github-app')
|
||||||
|
->not->toContain($app->uuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('github does not match repository substrings', function () {
|
||||||
|
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'ref' => 'refs/heads/main',
|
||||||
|
'repository' => ['full_name' => 'test-org/test'],
|
||||||
|
'after' => 'abc123',
|
||||||
|
'commits' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||||
|
'HTTP_X-GitHub-Event' => 'push',
|
||||||
|
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], $payload);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$content = $response->getContent();
|
||||||
|
expect($content)->toContain('No applications found')
|
||||||
|
->not->toContain('secret-github-app')
|
||||||
|
->not->toContain($app->uuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('github invalid signature does not leak matched application identifiers', function () {
|
||||||
|
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'ref' => 'refs/heads/main',
|
||||||
|
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||||
|
'after' => 'abc123',
|
||||||
|
'commits' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||||
|
'HTTP_X-GitHub-Event' => 'push',
|
||||||
|
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], $payload);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$content = $response->getContent();
|
||||||
|
expect($content)->toContain('Invalid signature')
|
||||||
|
->not->toContain('secret-github-app')
|
||||||
|
->not->toContain($app->uuid)
|
||||||
|
->not->toContain('application_uuid')
|
||||||
|
->not->toContain('application_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual webhooks reject empty repositories for every provider without leaking applications', function (string $provider, string $uri, array $payload, array $headers) {
|
||||||
|
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
|
||||||
|
$body = json_encode($payload);
|
||||||
|
|
||||||
|
$server = ['CONTENT_TYPE' => 'application/json'];
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
$server[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->call('POST', $uri, [], [], [], $server, $body);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$content = $response->getContent();
|
||||||
|
expect($content)->toContain('Invalid repository')
|
||||||
|
->not->toContain("secret-{$provider}-app")
|
||||||
|
->not->toContain($app->uuid);
|
||||||
|
})->with([
|
||||||
|
'gitlab' => [
|
||||||
|
'gitlab',
|
||||||
|
'/webhooks/source/gitlab/events/manual',
|
||||||
|
[
|
||||||
|
'object_kind' => 'push',
|
||||||
|
'ref' => 'refs/heads/main',
|
||||||
|
'project' => ['path_with_namespace' => ''],
|
||||||
|
'after' => 'abc123',
|
||||||
|
'commits' => [],
|
||||||
|
],
|
||||||
|
['HTTP_X-Gitlab-Token' => 'wrong-token'],
|
||||||
|
],
|
||||||
|
'bitbucket' => [
|
||||||
|
'bitbucket',
|
||||||
|
'/webhooks/source/bitbucket/events/manual',
|
||||||
|
[
|
||||||
|
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||||
|
'repository' => ['full_name' => ''],
|
||||||
|
],
|
||||||
|
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
|
||||||
|
],
|
||||||
|
'gitea' => [
|
||||||
|
'gitea',
|
||||||
|
'/webhooks/source/gitea/events/manual',
|
||||||
|
[
|
||||||
|
'ref' => 'refs/heads/main',
|
||||||
|
'repository' => ['full_name' => ''],
|
||||||
|
'after' => 'abc123',
|
||||||
|
'commits' => [],
|
||||||
|
],
|
||||||
|
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('manual webhooks do not match repository substrings for every provider', function (string $provider, string $uri, array $payload, array $headers) {
|
||||||
|
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
|
||||||
|
$body = json_encode($payload);
|
||||||
|
|
||||||
|
$server = ['CONTENT_TYPE' => 'application/json'];
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
$server[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->call('POST', $uri, [], [], [], $server, $body);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$content = $response->getContent();
|
||||||
|
expect($content)->toContain('No applications found')
|
||||||
|
->not->toContain("secret-{$provider}-app")
|
||||||
|
->not->toContain($app->uuid);
|
||||||
|
})->with([
|
||||||
|
'gitlab' => [
|
||||||
|
'gitlab',
|
||||||
|
'/webhooks/source/gitlab/events/manual',
|
||||||
|
[
|
||||||
|
'object_kind' => 'push',
|
||||||
|
'ref' => 'refs/heads/main',
|
||||||
|
'project' => ['path_with_namespace' => 'test-org/test'],
|
||||||
|
'after' => 'abc123',
|
||||||
|
'commits' => [],
|
||||||
|
],
|
||||||
|
['HTTP_X-Gitlab-Token' => 'wrong-token'],
|
||||||
|
],
|
||||||
|
'bitbucket' => [
|
||||||
|
'bitbucket',
|
||||||
|
'/webhooks/source/bitbucket/events/manual',
|
||||||
|
[
|
||||||
|
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||||
|
'repository' => ['full_name' => 'test-org/test'],
|
||||||
|
],
|
||||||
|
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
|
||||||
|
],
|
||||||
|
'gitea' => [
|
||||||
|
'gitea',
|
||||||
|
'/webhooks/source/gitea/events/manual',
|
||||||
|
[
|
||||||
|
'ref' => 'refs/heads/main',
|
||||||
|
'repository' => ['full_name' => 'test-org/test'],
|
||||||
|
'after' => 'abc123',
|
||||||
|
'commits' => [],
|
||||||
|
],
|
||||||
|
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('github matches ssh git repository URL exactly', function () {
|
||||||
|
$app = createApplicationWithWebhook(overrides: [
|
||||||
|
'git_repository' => 'git@github.com:test-org/test-repo.git',
|
||||||
|
]);
|
||||||
|
$secret = $app->manual_webhook_secret_github;
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'ref' => 'refs/heads/main',
|
||||||
|
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||||
|
'after' => 'abc123',
|
||||||
|
'commits' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||||
|
'HTTP_X-GitHub-Event' => 'push',
|
||||||
|
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], $payload);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect($response->getContent())->not->toContain('No applications found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('github matches repository case-insensitively', function () {
|
||||||
|
$app = createApplicationWithWebhook(overrides: [
|
||||||
|
'git_repository' => 'https://github.com/Test-Org/Test-Repo.git',
|
||||||
|
]);
|
||||||
|
$secret = $app->manual_webhook_secret_github;
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'ref' => 'refs/heads/main',
|
||||||
|
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||||
|
'after' => 'abc123',
|
||||||
|
'commits' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||||
|
'HTTP_X-GitHub-Event' => 'push',
|
||||||
|
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], $payload);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect($response->getContent())->not->toContain('No applications found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Webhook Secret Auto-Generation', function () {
|
describe('Webhook Secret Auto-Generation', function () {
|
||||||
test('auto-generates webhook secrets on application creation', function () {
|
test('auto-generates webhook secrets on application creation', function () {
|
||||||
$app = createApplicationWithWebhook();
|
$app = createApplicationWithWebhook();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue