diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b3e7c2..47f674a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -51,7 +51,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 diff --git a/composer.json b/composer.json index 3b4ae4e..7b4b584 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,23 @@ "host-uk/core": "@dev", "symfony/yaml": "^7.0" }, + "require-dev": { + "laravel/pint": "^1.18", + "nunomaduro/collision": "^8.6", + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.5", + "vimeo/psalm": "^5.26|^6.0" + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/host-uk/core-php", + "no-api": true + } + ], "autoload": { "psr-4": { "Core\\Api\\": "src/Api/", @@ -19,6 +36,6 @@ "providers": [] } }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true } diff --git a/src/Api/Boot.php b/src/Api/Boot.php index 904a679..0e17f3f 100644 --- a/src/Api/Boot.php +++ b/src/Api/Boot.php @@ -57,6 +57,7 @@ public function register(): void }); // Register webhook services + $this->app->singleton(Services\WebhookUrlValidator::class); $this->app->singleton(Services\WebhookTemplateService::class); $this->app->singleton(Services\WebhookSecretRotationService::class); diff --git a/src/Api/Jobs/DeliverWebhookJob.php b/src/Api/Jobs/DeliverWebhookJob.php index 2e2d9ad..393ab40 100644 --- a/src/Api/Jobs/DeliverWebhookJob.php +++ b/src/Api/Jobs/DeliverWebhookJob.php @@ -5,6 +5,7 @@ namespace Core\Api\Jobs; use Core\Api\Models\WebhookDelivery; +use Core\Api\Services\WebhookUrlValidator; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -77,6 +78,27 @@ public function handle(): void $deliveryPayload = $this->delivery->getDeliveryPayload(); $timeout = config('api.webhooks.timeout', 30); + // Final SSRF validation before delivery + $validator = app(WebhookUrlValidator::class); + if (! $validator->validate($endpoint->url)) { + Log::error('Webhook delivery cancelled - restricted URL detected', [ + 'delivery_id' => $this->delivery->id, + 'endpoint_id' => $endpoint->id, + 'url' => $endpoint->url, + ]); + + $this->delivery->update([ + 'status' => WebhookDelivery::STATUS_FAILED, + 'response_code' => 0, + 'response_body' => 'Restricted URL blocked for security reasons.', + 'attempt' => WebhookDelivery::MAX_RETRIES, // No further retries + ]); + + $endpoint->recordFailure(); + + return; + } + Log::info('Attempting webhook delivery', [ 'delivery_id' => $this->delivery->id, 'endpoint_url' => $endpoint->url, diff --git a/src/Api/Models/WebhookEndpoint.php b/src/Api/Models/WebhookEndpoint.php index a2990e3..b7681a0 100644 --- a/src/Api/Models/WebhookEndpoint.php +++ b/src/Api/Models/WebhookEndpoint.php @@ -5,6 +5,7 @@ namespace Core\Api\Models; use Core\Api\Services\WebhookSignature; +use Core\Api\Services\WebhookUrlValidator; use Core\Tenant\Models\Workspace; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -105,6 +106,11 @@ public static function createForWorkspace( array $events, ?string $description = null ): static { + $validator = app(WebhookUrlValidator::class); + if (! $validator->validate($url)) { + throw new \InvalidArgumentException('Invalid or restricted webhook URL.'); + } + $signatureService = app(WebhookSignature::class); return static::create([ @@ -233,6 +239,20 @@ public function rotateSecret(): string return $newSecret; } + /** + * Set the webhook URL with validation. + */ + public function setUrlAttribute(string $value): void + { + $validator = app(WebhookUrlValidator::class); + + if (! $validator->validate($value)) { + throw new \InvalidArgumentException('Invalid or restricted webhook URL.'); + } + + $this->attributes['url'] = $value; + } + // Relationships public function workspace(): BelongsTo { diff --git a/src/Api/Services/WebhookUrlValidator.php b/src/Api/Services/WebhookUrlValidator.php new file mode 100644 index 0000000..27fd437 --- /dev/null +++ b/src/Api/Services/WebhookUrlValidator.php @@ -0,0 +1,154 @@ +resolveHost($host); + + if (empty($ips)) { + // If it can't be resolved, it might be an IP already or an invalid host + if (filter_var($host, FILTER_VALIDATE_IP)) { + $ips = [$host]; + } else { + // If we can't resolve it and it's not an IP, we block it for safety + // in case of intermittent DNS issues during creation. + return false; + } + } + + foreach ($ips as $ip) { + if ($this->isPrivateOrReservedIp($ip)) { + Log::warning('Blocked restricted webhook URL resolution', [ + 'url' => $url, + 'host' => $host, + 'resolved_ip' => $ip, + ]); + + return false; + } + } + + return true; + } + + /** + * Resolve a hostname to its IP addresses (IPv4 and IPv6). + * + * @return array + */ + protected function resolveHost(string $host): array + { + $ips = []; + + // IPv4 resolution + $ipv4s = gethostbynamel($host); + if ($ipv4s !== false) { + $ips = array_merge($ips, $ipv4s); + } + + // IPv6 resolution (if dns_get_record is available) + if (function_exists('dns_get_record')) { + try { + $ipv6s = @dns_get_record($host, DNS_AAAA); + if ($ipv6s) { + foreach ($ipv6s as $record) { + if (isset($record['ipv6'])) { + $ips[] = $record['ipv6']; + } + } + } + } catch (\Throwable $e) { + // Ignore DNS errors, rely on IPv4 or IP-based check + } + } + + return array_unique($ips); + } + + /** + * Check if an IP is private or reserved. + */ + protected function isPrivateOrReservedIp(string $ip): bool + { + // FILTER_FLAG_NO_PRIV_RANGE: + // Blocks 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and 127.0.0.0/8 + // FILTER_FLAG_NO_RES_RANGE: + // Blocks reserved ranges including 169.254.0.0/16 (Link-local/Metadata) + + $isPublic = filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); + + if ($isPublic === false) { + return true; + } + + // Additional manual checks for safety + $lowIp = strtolower($ip); + + // IPv6 loopback + if ($lowIp === '::1' || $lowIp === '0000:0000:0000:0000:0000:0000:0000:0001') { + return true; + } + + // Check for 169.254.x.x (Link-local / AWS Metadata) - double check + if (str_starts_with($ip, '169.254.')) { + return true; + } + + // Check for IPv6 link-local (fe80::/10) + if (str_starts_with($lowIp, 'fe80:')) { + return true; + } + + return false; + } +} diff --git a/src/Api/Tests/Feature/WebhookUrlValidationTest.php b/src/Api/Tests/Feature/WebhookUrlValidationTest.php new file mode 100644 index 0000000..de9d671 --- /dev/null +++ b/src/Api/Tests/Feature/WebhookUrlValidationTest.php @@ -0,0 +1,141 @@ +workspace = Workspace::factory()->create(); + $this->validator = app(WebhookUrlValidator::class); + Http::fake(); + } + + /** @test */ + public function it_blocks_localhost() + { + $this->assertFalse($this->validator->validate('http://localhost/webhook')); + $this->assertFalse($this->validator->validate('https://localhost/webhook')); + $this->assertFalse($this->validator->validate('http://localhost.localdomain/webhook')); + } + + /** @test */ + public function it_blocks_private_ipv4_addresses() + { + $this->assertFalse($this->validator->validate('http://127.0.0.1/webhook')); + $this->assertFalse($this->validator->validate('http://10.0.0.1/webhook')); + $this->assertFalse($this->validator->validate('http://172.16.0.1/webhook')); + $this->assertFalse($this->validator->validate('http://192.168.1.1/webhook')); + } + + /** @test */ + public function it_blocks_link_local_and_metadata_addresses() + { + $this->assertFalse($this->validator->validate('http://169.254.169.254/latest/meta-data')); + $this->assertFalse($this->validator->validate('http://169.254.0.1/webhook')); + } + + /** @test */ + public function it_blocks_loopback_ipv6_addresses() + { + $this->assertFalse($this->validator->validate('http://[::1]/webhook')); + $this->assertFalse($this->validator->validate('http://[0000:0000:0000:0000:0000:0000:0000:0001]/webhook')); + } + + /** @test */ + public function it_blocks_non_http_schemes() + { + $this->assertFalse($this->validator->validate('ftp://example.com/webhook')); + $this->assertFalse($this->validator->validate('file:///etc/passwd')); + $this->assertFalse($this->validator->validate('gopher://example.com')); + } + + /** @test */ + public function it_allows_valid_public_urls() + { + // Using IP addresses that are definitely public + $this->assertTrue($this->validator->validate('https://8.8.8.8/webhook')); + $this->assertTrue($this->validator->validate('https://1.1.1.1/webhook')); + + // Hostnames should also work if they resolve to public IPs + $this->assertTrue($this->validator->validate('https://example.com/webhook')); + } + + /** @test */ + public function it_throws_exception_when_creating_endpoint_with_restricted_url() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid or restricted webhook URL.'); + + WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'http://127.0.0.1/webhook', + ['*'] + ); + } + + /** @test */ + public function it_throws_exception_when_updating_endpoint_to_restricted_url() + { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['*'] + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid or restricted webhook URL.'); + + $endpoint->url = 'http://10.0.0.1/webhook'; + $endpoint->save(); + } + + /** @test */ + public function it_cancels_delivery_if_url_is_restricted_at_runtime() + { + // Create with valid URL first + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['*'] + ); + + // Bypass the mutator to set an invalid URL + // This simulates a URL that was valid but now points to a restricted IP, + // or a URL that was changed directly in the database. + $endpoint->getAttributes(); // ensure attributes are loaded + $endpoint->setRawAttributes(array_merge($endpoint->getAttributes(), [ + 'url' => 'http://127.0.0.1/webhook', + ])); + $endpoint->save(); + + $delivery = WebhookDelivery::createForEvent($endpoint, 'test.event', ['foo' => 'bar']); + + $job = new DeliverWebhookJob($delivery); + $job->handle(); + + $delivery->refresh(); + $this->assertEquals(WebhookDelivery::STATUS_FAILED, $delivery->status); + $this->assertEquals('Restricted URL blocked for security reasons.', $delivery->response_body); + $this->assertEquals(WebhookDelivery::MAX_RETRIES, $delivery->attempt); + + Http::assertNothingSent(); + } +}