Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand Down Expand Up @@ -51,7 +51,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
Expand Down
19 changes: 18 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand All @@ -19,6 +36,6 @@
"providers": []
}
},
"minimum-stability": "stable",
"minimum-stability": "dev",
"prefer-stable": true
}
1 change: 1 addition & 0 deletions src/Api/Boot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
22 changes: 22 additions & 0 deletions src/Api/Jobs/DeliverWebhookJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions src/Api/Models/WebhookEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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
{
Expand Down
154 changes: 154 additions & 0 deletions src/Api/Services/WebhookUrlValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

declare(strict_types=1);

namespace Core\Api\Services;

use Illuminate\Support\Facades\Log;

/**
* Webhook URL Validator.
*
* Validates webhook URLs to prevent Server-Side Request Forgery (SSRF).
* Blocks private/reserved IP ranges, localhost, and cloud metadata endpoints.
*/
class WebhookUrlValidator
{
/**
* Blocked hostnames.
*/
private const BLOCKED_HOSTS = [
'localhost',
'localhost.localdomain',
];

/**
* Validate a webhook URL.
*
* @param string $url The URL to validate
* @return bool True if the URL is safe and valid
*/
public function validate(string $url): bool
{
$parsed = parse_url($url);

if ($parsed === false || ! isset($parsed['host']) || ! isset($parsed['scheme'])) {
return false;
}

// Only allow HTTP and HTTPS
if (! in_array(strtolower($parsed['scheme']), ['http', 'https'], true)) {
return false;
}

$host = $parsed['host'];

// Block common localhost names
if (in_array(strtolower($host), self::BLOCKED_HOSTS, true)) {
return false;
}

// Resolve DNS and validate all returned IPs
$ips = $this->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<string>
*/
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);
}
Comment on lines +85 to +112

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The resolveHost method uses gethostbynamel for IPv4 resolution, which is an older function with some known issues (e.g., it's blocking, and its behavior can be inconsistent in some environments). For improved robustness and consistency, consider refactoring this method to use dns_get_record for both IPv4 (A records) and IPv6 (AAAA records) lookups. This would unify the DNS resolution logic and leverage a more modern PHP function.


/**
* 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;
Comment on lines +130 to +152

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The manual checks for private and reserved IP addresses are redundant. The filter_var call on line 124 with the flags FILTER_FLAG_NO_PRIV_RANGE and FILTER_FLAG_NO_RES_RANGE already covers all these cases, including IPv4/IPv6 loopback, link-local, and private ranges. The logic can be simplified to just check the result of filter_var.

        return $isPublic === false;
    }

}
}
Loading
Loading