feat: add internal endpoint controls
This commit is contained in:
parent
7a853efa79
commit
0bf97df9af
13 changed files with 713 additions and 86 deletions
|
|
@ -16,6 +16,7 @@
|
||||||
use App\Notifications\Database\BackupFailed;
|
use App\Notifications\Database\BackupFailed;
|
||||||
use App\Notifications\Database\BackupSuccess;
|
use App\Notifications\Database\BackupSuccess;
|
||||||
use App\Notifications\Database\BackupSuccessWithS3Warning;
|
use App\Notifications\Database\BackupSuccessWithS3Warning;
|
||||||
|
use App\Rules\SafeWebhookUrl;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
|
|
@ -716,8 +717,12 @@ private function upload_to_s3(): void
|
||||||
$escapedSecret = escapeshellarg($secret);
|
$escapedSecret = escapeshellarg($secret);
|
||||||
$escapedBackupLocation = escapeshellarg($this->backup_location);
|
$escapedBackupLocation = escapeshellarg($this->backup_location);
|
||||||
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
|
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
|
||||||
|
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint))
|
||||||
|
->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption))
|
||||||
|
->implode(' ');
|
||||||
|
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
|
||||||
|
|
||||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
|
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
|
||||||
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,24 @@ public function handle(): void
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
|
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
|
||||||
'url' => $this->webhookUrl,
|
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
|
||||||
'errors' => $validator->errors()->all(),
|
'errors' => $validator->errors()->all(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->message->toPayload());
|
try {
|
||||||
|
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL at send time', [
|
||||||
|
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,15 +44,26 @@ public function handle(): void
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
|
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
|
||||||
'url' => $this->webhookUrl,
|
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
|
||||||
'errors' => $validator->errors()->all(),
|
'errors' => $validator->errors()->all(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL at send time', [
|
||||||
|
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->isSlackWebhook()) {
|
if ($this->isSlackWebhook()) {
|
||||||
$this->sendToSlack();
|
$this->sendToSlack($httpOptions);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +73,7 @@ public function handle(): void
|
||||||
*
|
*
|
||||||
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
|
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
|
||||||
*/
|
*/
|
||||||
$this->sendToMattermost();
|
$this->sendToMattermost($httpOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isSlackWebhook(): bool
|
private function isSlackWebhook(): bool
|
||||||
|
|
@ -79,9 +90,12 @@ private function isSlackWebhook(): bool
|
||||||
return $scheme === 'https' && $host === 'hooks.slack.com';
|
return $scheme === 'https' && $host === 'hooks.slack.com';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendToSlack(): void
|
/**
|
||||||
|
* @param array<string, mixed> $httpOptions
|
||||||
|
*/
|
||||||
|
private function sendToSlack(array $httpOptions): void
|
||||||
{
|
{
|
||||||
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
|
Http::withOptions($httpOptions)->post($this->webhookUrl, [
|
||||||
'text' => $this->message->title,
|
'text' => $this->message->title,
|
||||||
'blocks' => [
|
'blocks' => [
|
||||||
[
|
[
|
||||||
|
|
@ -119,11 +133,14 @@ private function sendToSlack(): void
|
||||||
/**
|
/**
|
||||||
* @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type.
|
* @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type.
|
||||||
*/
|
*/
|
||||||
private function sendToMattermost(): void
|
/**
|
||||||
|
* @param array<string, mixed> $httpOptions
|
||||||
|
*/
|
||||||
|
private function sendToMattermost(array $httpOptions): void
|
||||||
{
|
{
|
||||||
$username = config('app.name');
|
$username = config('app.name');
|
||||||
|
|
||||||
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
|
Http::withOptions($httpOptions)->post($this->webhookUrl, [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'attachments' => [
|
'attachments' => [
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ public function handle(): void
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
Log::warning('SendWebhookJob: blocked unsafe webhook URL', [
|
Log::warning('SendWebhookJob: blocked unsafe webhook URL', [
|
||||||
'url' => $this->webhookUrl,
|
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
|
||||||
'errors' => $validator->errors()->all(),
|
'errors' => $validator->errors()->all(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -64,7 +64,18 @@ public function handle(): void
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->payload);
|
try {
|
||||||
|
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
Log::warning('SendWebhookJob: blocked unsafe webhook URL at send time', [
|
||||||
|
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload);
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
ray('Webhook response', [
|
ray('Webhook response', [
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
use App\Models\StandaloneMysql;
|
use App\Models\StandaloneMysql;
|
||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\StandaloneRedis;
|
use App\Models\StandaloneRedis;
|
||||||
|
use App\Rules\SafeWebhookUrl;
|
||||||
use App\Support\DatabaseBackupFileValidator;
|
use App\Support\DatabaseBackupFileValidator;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
@ -598,6 +599,7 @@ public function checkS3File()
|
||||||
'bucket' => $s3Storage->bucket,
|
'bucket' => $s3Storage->bucket,
|
||||||
'endpoint' => $s3Storage->endpoint,
|
'endpoint' => $s3Storage->endpoint,
|
||||||
'use_path_style_endpoint' => true,
|
'use_path_style_endpoint' => true,
|
||||||
|
'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ class Advanced extends Component
|
||||||
#[Validate('boolean')]
|
#[Validate('boolean')]
|
||||||
public bool $is_mcp_server_enabled;
|
public bool $is_mcp_server_enabled;
|
||||||
|
|
||||||
|
public ?string $webhook_allowed_internal_hosts = null;
|
||||||
|
|
||||||
|
#[Validate('boolean')]
|
||||||
|
public bool $webhook_allow_localhost;
|
||||||
|
|
||||||
public function rules()
|
public function rules()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
@ -56,6 +61,8 @@ public function rules()
|
||||||
'disable_two_step_confirmation' => 'boolean',
|
'disable_two_step_confirmation' => 'boolean',
|
||||||
'is_wire_navigate_enabled' => 'boolean',
|
'is_wire_navigate_enabled' => 'boolean',
|
||||||
'is_mcp_server_enabled' => 'boolean',
|
'is_mcp_server_enabled' => 'boolean',
|
||||||
|
'webhook_allowed_internal_hosts' => 'nullable|string',
|
||||||
|
'webhook_allow_localhost' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +82,8 @@ public function mount()
|
||||||
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
|
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
|
||||||
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
|
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
|
||||||
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
|
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
|
||||||
|
$this->webhook_allowed_internal_hosts = collect($this->settings->webhook_allowed_internal_hosts ?? [])->implode(',');
|
||||||
|
$this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
|
|
@ -141,13 +150,21 @@ public function submit()
|
||||||
$this->allowed_ips = implode(',', $validEntries);
|
$this->allowed_ips = implode(',', $validEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->instantSave();
|
$webhookAllowedInternalHosts = $this->normalizeWebhookAllowedInternalHosts();
|
||||||
|
if ($webhookAllowedInternalHosts === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->instantSave($webhookAllowedInternalHosts);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function instantSave()
|
/**
|
||||||
|
* @param array<int, string>|null $webhookAllowedInternalHosts
|
||||||
|
*/
|
||||||
|
public function instantSave(?array $webhookAllowedInternalHosts = null)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->settings);
|
$this->authorize('update', $this->settings);
|
||||||
|
|
@ -161,6 +178,8 @@ public function instantSave()
|
||||||
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
|
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
|
||||||
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
|
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
|
||||||
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
|
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
|
||||||
|
$this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? [];
|
||||||
|
$this->settings->webhook_allow_localhost = $this->webhook_allow_localhost;
|
||||||
$this->settings->save();
|
$this->settings->save();
|
||||||
$this->dispatch('success', 'Settings updated!');
|
$this->dispatch('success', 'Settings updated!');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
@ -168,6 +187,49 @@ public function instantSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>|false
|
||||||
|
*/
|
||||||
|
private function normalizeWebhookAllowedInternalHosts(): array|false
|
||||||
|
{
|
||||||
|
$entries = collect(preg_split('/[,\r\n]+/', $this->webhook_allowed_internal_hosts ?? '') ?: [])
|
||||||
|
->map(fn (string $entry): string => rtrim(strtolower(trim($entry)), '.'))
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$invalidEntries = $entries->reject(fn (string $entry): bool => $this->isValidWebhookAllowlistEntry($entry));
|
||||||
|
if ($invalidEntries->isNotEmpty()) {
|
||||||
|
$this->dispatch('error', 'Invalid webhook internal allowlist entries: '.$invalidEntries->implode(', '));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->webhook_allowed_internal_hosts = $entries->implode(',');
|
||||||
|
|
||||||
|
return $entries->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isValidWebhookAllowlistEntry(string $entry): bool
|
||||||
|
{
|
||||||
|
if (filter_var($entry, FILTER_VALIDATE_IP)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($entry, '/')) {
|
||||||
|
[$ip, $mask] = array_pad(explode('/', $entry, 2), 2, null);
|
||||||
|
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||||
|
$maxMask = $isIpv6 ? 128 : 32;
|
||||||
|
|
||||||
|
return filter_var($ip, FILTER_VALIDATE_IP) !== false
|
||||||
|
&& is_numeric($mask)
|
||||||
|
&& (int) $mask >= 0
|
||||||
|
&& (int) $mask <= $maxMask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
public function toggleRegistration($password): bool
|
public function toggleRegistration($password): bool
|
||||||
{
|
{
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ class InstanceSettings extends Model
|
||||||
'dev_helper_version',
|
'dev_helper_version',
|
||||||
'is_wire_navigate_enabled',
|
'is_wire_navigate_enabled',
|
||||||
'is_mcp_server_enabled',
|
'is_mcp_server_enabled',
|
||||||
|
'webhook_allowed_internal_hosts',
|
||||||
|
'webhook_allow_localhost',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|
@ -69,10 +71,16 @@ class InstanceSettings extends Model
|
||||||
'sentinel_token' => 'encrypted',
|
'sentinel_token' => 'encrypted',
|
||||||
'is_wire_navigate_enabled' => 'boolean',
|
'is_wire_navigate_enabled' => 'boolean',
|
||||||
'is_mcp_server_enabled' => 'boolean',
|
'is_mcp_server_enabled' => 'boolean',
|
||||||
|
'webhook_allowed_internal_hosts' => 'array',
|
||||||
|
'webhook_allow_localhost' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
|
static::created(function () {
|
||||||
|
Once::flush();
|
||||||
|
});
|
||||||
|
|
||||||
static::updated(function ($settings) {
|
static::updated(function ($settings) {
|
||||||
// Clear once() cache so subsequent calls get fresh data
|
// Clear once() cache so subsequent calls get fresh data
|
||||||
Once::flush();
|
Once::flush();
|
||||||
|
|
|
||||||
|
|
@ -173,11 +173,10 @@ public function testConnection(bool $shouldSave = false)
|
||||||
'bucket' => $this['bucket'],
|
'bucket' => $this['bucket'],
|
||||||
'endpoint' => $this['endpoint'],
|
'endpoint' => $this['endpoint'],
|
||||||
'use_path_style_endpoint' => true,
|
'use_path_style_endpoint' => true,
|
||||||
'http' => [
|
'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [
|
||||||
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
|
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
|
||||||
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
|
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
|
||||||
'allow_redirects' => false,
|
]),
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
// Test the connection by listing files with ListObjectsV2 (S3)
|
// Test the connection by listing files with ListObjectsV2 (S3)
|
||||||
$disk->files();
|
$disk->files();
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
namespace App\Rules;
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class SafeWebhookUrl implements ValidationRule
|
class SafeWebhookUrl implements ValidationRule
|
||||||
{
|
{
|
||||||
|
|
@ -18,8 +20,8 @@ public function __construct(private ?Closure $resolver = null) {}
|
||||||
*
|
*
|
||||||
* Validates that a webhook URL is safe for server-side requests.
|
* Validates that a webhook URL is safe for server-side requests.
|
||||||
* Blocks loopback addresses, cloud metadata endpoints (link-local),
|
* Blocks loopback addresses, cloud metadata endpoints (link-local),
|
||||||
* and dangerous hostnames while allowing private network IPs
|
* private/reserved ranges, and dangerous hostnames unless the
|
||||||
* for self-hosted deployments.
|
* instance operator explicitly allowlists the intranet target.
|
||||||
*/
|
*/
|
||||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
{
|
{
|
||||||
|
|
@ -43,12 +45,17 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($host, '.')) {
|
||||||
|
$fail('The :attribute host must not end with a trailing dot.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$host = strtolower($host);
|
$host = strtolower($host);
|
||||||
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
|
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
|
||||||
$hostForDns = rtrim($hostForIpCheck, '.');
|
$hostForDns = rtrim($hostForIpCheck, '.');
|
||||||
|
|
||||||
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
|
if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) {
|
||||||
if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) {
|
|
||||||
$this->logBlockedHost($attribute, $host);
|
$this->logBlockedHost($attribute, $host);
|
||||||
$fail('The :attribute must not point to localhost or internal hosts.');
|
$fail('The :attribute must not point to localhost or internal hosts.');
|
||||||
|
|
||||||
|
|
@ -56,9 +63,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
|
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
|
||||||
if ($this->isBlockedIp($hostForIpCheck)) {
|
if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) {
|
||||||
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
|
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
|
||||||
$fail('The :attribute must not point to loopback or link-local addresses.');
|
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -67,16 +74,124 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
}
|
}
|
||||||
|
|
||||||
$resolvedIps = $this->resolveHost($hostForDns);
|
$resolvedIps = $this->resolveHost($hostForDns);
|
||||||
|
if ($resolvedIps === []) {
|
||||||
|
$fail('The :attribute host could not be resolved.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($resolvedIps as $resolvedIp) {
|
foreach ($resolvedIps as $resolvedIp) {
|
||||||
if ($this->isBlockedIp($resolvedIp)) {
|
if (! $this->isAllowedIp($resolvedIp, $hostForDns)) {
|
||||||
$this->logBlockedIp($attribute, $host, $resolvedIp);
|
$this->logBlockedIp($attribute, $host, $resolvedIp);
|
||||||
$fail('The :attribute must not point to loopback or link-local addresses.');
|
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTTP client options that pin the validated host to the resolved IPs.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function httpClientOptions(string $url): array
|
||||||
|
{
|
||||||
|
$options = ['allow_redirects' => false];
|
||||||
|
|
||||||
|
if (! defined('CURLOPT_RESOLVE')) {
|
||||||
|
throw new \RuntimeException('Webhook URL DNS pinning is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = self::resolveUrlForRequest($url);
|
||||||
|
|
||||||
|
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
$options['curl'] = [
|
||||||
|
CURLOPT_RESOLVE => array_map(
|
||||||
|
fn (string $ip): string => sprintf('%s:%d:%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
|
||||||
|
$target['ips'],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build mc --resolve mappings that pin the endpoint host for S3 backups.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function minioClientResolveOptions(string $url): array
|
||||||
|
{
|
||||||
|
$target = self::resolveUrlForRequest($url);
|
||||||
|
|
||||||
|
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
|
||||||
|
$target['ips'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function redactedUrlForLog(string $url): string
|
||||||
|
{
|
||||||
|
$scheme = parse_url($url, PHP_URL_SCHEME);
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
$port = parse_url($url, PHP_URL_PORT);
|
||||||
|
|
||||||
|
if (! is_string($scheme) || ! is_string($host)) {
|
||||||
|
return '[invalid-url]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($scheme).'://'.strtolower($host).($port ? ':'.$port : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{host: string, port: int, ips: array<int, string>}
|
||||||
|
*/
|
||||||
|
private static function resolveUrlForRequest(string $url): array
|
||||||
|
{
|
||||||
|
$rule = new self;
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
if (! is_string($host) || $host === '') {
|
||||||
|
throw new \RuntimeException('Webhook URL host could not be resolved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($host, '.')) {
|
||||||
|
throw new \RuntimeException('Webhook URL host must not end with a trailing dot.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
|
||||||
|
$port = parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80);
|
||||||
|
$hostForDns = rtrim($rule->normalizeHostForIpCheck(strtolower($host)), '.');
|
||||||
|
|
||||||
|
if (filter_var($hostForDns, FILTER_VALIDATE_IP)) {
|
||||||
|
if (! $rule->isAllowedIp($hostForDns, $hostForDns)) {
|
||||||
|
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['host' => $hostForDns, 'port' => $port, 'ips' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedIps = $rule->resolveHost($hostForDns);
|
||||||
|
if ($resolvedIps === []) {
|
||||||
|
throw new \RuntimeException('Webhook URL host could not be resolved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($resolvedIps as $resolvedIp) {
|
||||||
|
if (! $rule->isAllowedIp($resolvedIp, $hostForDns)) {
|
||||||
|
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['host' => $hostForDns, 'port' => $port, 'ips' => $resolvedIps];
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeHostForIpCheck(string $host): string
|
private function normalizeHostForIpCheck(string $host): string
|
||||||
{
|
{
|
||||||
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
|
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
|
||||||
|
|
@ -119,60 +234,271 @@ private function resolveHost(string $host): array
|
||||||
return array_values(array_unique($ips));
|
return array_values(array_unique($ips));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isBlockedIp(string $ip): bool
|
private function isAllowedIp(string $ip, string $host): bool
|
||||||
{
|
{
|
||||||
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
|
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
|
||||||
if ($embeddedIpv4 !== null) {
|
if ($embeddedIpv4 !== null) {
|
||||||
return $this->isBlockedIpv4($embeddedIpv4);
|
$ip = $embeddedIpv4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isPublicIp($ip)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isLocalhostIp($ip)) {
|
||||||
|
return $this->allowLocalhost()
|
||||||
|
&& ($this->isAllowedHostname($host) || $this->isAllowlistedIp($ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isPrivateIp($ip)) {
|
||||||
|
return $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isAllowlistedIp($ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isPublicIp(string $ip): bool
|
||||||
|
{
|
||||||
|
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
|
||||||
|
if ($embeddedIpv4 !== null) {
|
||||||
|
return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
|
||||||
|
&& ! $this->isSpecialUseIpv4($embeddedIpv4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
|
||||||
|
&& ! $this->isSpecialUseIp($ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isLocalhostIp(string $ip): bool
|
||||||
|
{
|
||||||
|
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
|
||||||
|
if ($embeddedIpv4 !== null) {
|
||||||
|
return $this->isLocalhostIp($embeddedIpv4);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
return $this->isBlockedIpv4($ip);
|
return $this->ipv4InCidr($ip, '127.0.0.0/8');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->isBlockedIpv6($ip);
|
return @inet_pton($ip) === @inet_pton('::1');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isBlockedIpv4(string $ip): bool
|
private function isPrivateIp(string $ip): bool
|
||||||
{
|
{
|
||||||
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
|
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
|
||||||
|
if ($embeddedIpv4 !== null) {
|
||||||
|
$ip = $embeddedIpv4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false
|
||||||
|
&& filter_var($ip, FILTER_VALIDATE_IP) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSpecialUseIp(string $ip): bool
|
||||||
|
{
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
return $this->isSpecialUseIpv4($ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isSpecialUseIpv6($ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSpecialUseIpv4(string $ip): bool
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
'0.0.0.0/8',
|
||||||
|
'100.64.0.0/10',
|
||||||
|
'127.0.0.0/8',
|
||||||
|
'169.254.0.0/16',
|
||||||
|
'192.0.0.0/24',
|
||||||
|
'192.0.2.0/24',
|
||||||
|
'198.18.0.0/15',
|
||||||
|
'198.51.100.0/24',
|
||||||
|
'203.0.113.0/24',
|
||||||
|
'224.0.0.0/4',
|
||||||
|
'240.0.0.0/4',
|
||||||
|
'255.255.255.255/32',
|
||||||
|
] as $cidr) {
|
||||||
|
if ($this->ipv4InCidr($ip, $cidr)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSpecialUseIpv6(string $ip): bool
|
||||||
|
{
|
||||||
|
$ipBytes = @inet_pton($ip);
|
||||||
|
if ($ipBytes === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'::/128',
|
||||||
|
'::1/128',
|
||||||
|
'::ffff:0:0/96',
|
||||||
|
'64:ff9b::/96',
|
||||||
|
'100::/64',
|
||||||
|
'2001::/23',
|
||||||
|
'2001:2::/48',
|
||||||
|
'2001:db8::/32',
|
||||||
|
'2002::/16',
|
||||||
|
'fc00::/7',
|
||||||
|
'fe80::/10',
|
||||||
|
'ff00::/8',
|
||||||
|
] as $cidr) {
|
||||||
|
[$network, $prefix] = explode('/', $cidr, 2);
|
||||||
|
$networkBytes = @inet_pton($network);
|
||||||
|
if ($networkBytes !== false && $this->binaryInCidr($ipBytes, $networkBytes, (int) $prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isBlockedHostname(string $host): bool
|
||||||
|
{
|
||||||
|
return in_array($host, ['localhost'], true)
|
||||||
|
|| str_ends_with($host, '.local')
|
||||||
|
|| str_ends_with($host, '.internal')
|
||||||
|
|| str_ends_with($host, '.cluster.local');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAllowedHostname(string $host): bool
|
||||||
|
{
|
||||||
|
foreach ($this->allowlistEntries() as $entry) {
|
||||||
|
if (! str_contains($entry, '/') && strtolower($entry) === $host) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAllowlistedIp(string $ip): bool
|
||||||
|
{
|
||||||
|
foreach ($this->allowlistEntries() as $entry) {
|
||||||
|
if (str_contains($entry, '/')) {
|
||||||
|
if ($this->ipInCidr($ip, $entry)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($entry, FILTER_VALIDATE_IP) && @inet_pton($entry) === @inet_pton($ip)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function allowlistEntries(): array
|
||||||
|
{
|
||||||
|
$entries = $this->instanceSettings()?->webhook_allowed_internal_hosts ?? [];
|
||||||
|
|
||||||
|
if (is_string($entries)) {
|
||||||
|
$entries = explode(',', $entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($entries)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
fn (mixed $entry): string => rtrim(strtolower(trim((string) $entry)), '.'),
|
||||||
|
$entries,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function allowLocalhost(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->instanceSettings()?->webhook_allow_localhost ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function instanceSettings(): ?InstanceSettings
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return InstanceSettings::query()->find(0);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ipInCidr(string $ip, string $cidr): bool
|
||||||
|
{
|
||||||
|
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
|
||||||
|
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
return $this->ipv4InCidr($ip, $cidr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) || ! filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = (int) $prefix;
|
||||||
|
if ($prefix < 0 || $prefix > 128) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipBytes = @inet_pton($ip);
|
||||||
|
$networkBytes = @inet_pton($network);
|
||||||
|
if ($ipBytes === false || $networkBytes === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->binaryInCidr($ipBytes, $networkBytes, $prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ipv4InCidr(string $ip, string $cidr): bool
|
||||||
|
{
|
||||||
|
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
|
||||||
|
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = (int) $prefix;
|
||||||
|
if ($prefix < 0 || $prefix > 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipLong = ip2long($ip);
|
||||||
|
$networkLong = ip2long($network);
|
||||||
|
if ($ipLong === false || $networkLong === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mask = $prefix === 0 ? 0 : (-1 << (32 - $prefix));
|
||||||
|
|
||||||
|
return ($ipLong & $mask) === ($networkLong & $mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function binaryInCidr(string $ipBytes, string $networkBytes, int $prefix): bool
|
||||||
|
{
|
||||||
|
$bytes = intdiv($prefix, 8);
|
||||||
|
$bits = $prefix % 8;
|
||||||
|
|
||||||
|
if ($bytes > 0 && substr($ipBytes, 0, $bytes) !== substr($networkBytes, 0, $bytes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bits === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$long = ip2long($ip);
|
$mask = 0xFF << (8 - $bits) & 0xFF;
|
||||||
if ($long === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$unsigned = sprintf('%u', $long);
|
return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask);
|
||||||
$linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
|
|
||||||
$linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
|
|
||||||
|
|
||||||
return $unsigned >= $linkLocalStart && $unsigned <= $linkLocalEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isBlockedIpv6(string $ip): bool
|
|
||||||
{
|
|
||||||
$packed = @inet_pton($ip);
|
|
||||||
if ($packed === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($packed === inet_pton('::1') || $packed === inet_pton('::')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$bytes = unpack('C16', $packed);
|
|
||||||
if ($bytes === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$firstByte = $bytes[1];
|
|
||||||
$secondByte = $bytes[2];
|
|
||||||
|
|
||||||
// fe80::/10 link-local and fc00::/7 unique local addresses.
|
|
||||||
return ($firstByte === 0xFE && ($secondByte & 0xC0) === 0x80)
|
|
||||||
|| (($firstByte & 0xFE) === 0xFC);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractIpv4FromMappedIpv6(string $ip): ?string
|
private function extractIpv4FromMappedIpv6(string $ip): ?string
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('instance_settings', function (Blueprint $table) {
|
||||||
|
$table->json('webhook_allowed_internal_hosts')->nullable();
|
||||||
|
$table->boolean('webhook_allow_localhost')->default(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('instance_settings', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['webhook_allowed_internal_hosts', 'webhook_allow_localhost']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -70,6 +70,15 @@ class="flex flex-col h-full gap-8 sm:flex-row">
|
||||||
environments!
|
environments!
|
||||||
</x-callout>
|
</x-callout>
|
||||||
@endif
|
@endif
|
||||||
|
<h4 class="pt-4">Webhook/S3 Endpoint Controls</h4>
|
||||||
|
<x-forms.textarea id="webhook_allowed_internal_hosts" rows="4"
|
||||||
|
label="Allowed Internal Webhook/S3 Targets"
|
||||||
|
helper="Optional instance-level allowlist for webhook and S3 endpoint destinations. Supports exact hostnames, IPs, and CIDR ranges separated by commas or new lines."
|
||||||
|
placeholder="hooks.company.local, 10.50.0.0/16, 192.168.10.20" />
|
||||||
|
<div class="md:w-96">
|
||||||
|
<x-forms.checkbox id="webhook_allow_localhost" label="Allow Localhost Webhook/S3 Targets"
|
||||||
|
helper="Allows localhost/loopback targets only when they are also listed above. Use only for local development or trusted single-instance setups." />
|
||||||
|
</div>
|
||||||
<h4 class="pt-4">MCP Server</h4>
|
<h4 class="pt-4">MCP Server</h4>
|
||||||
<div class="md:w-96">
|
<div class="md:w-96">
|
||||||
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server Instance-wide"
|
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server Instance-wide"
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\S3Storage;
|
use App\Models\S3Storage;
|
||||||
use App\Rules\SafeWebhookUrl;
|
use App\Rules\SafeWebhookUrl;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
uses(TestCase::class);
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression tests for SSRF via S3 Storage endpoint.
|
* Regression tests for S3 Storage endpoint validation.
|
||||||
*
|
*
|
||||||
* The Livewire forms (Create.php, Form.php) and the model-level defense in
|
* The Livewire forms (Create.php, Form.php) and the model-level defense in
|
||||||
* S3Storage::testConnection() share the same SafeWebhookUrl rule. These tests
|
* S3Storage::testConnection() share the same SafeWebhookUrl rule. These tests
|
||||||
* assert the rule rejects the concrete payloads and that the model refuses to
|
* assert the rule rejects the concrete payloads and that the model refuses to
|
||||||
* build an S3 client for an unsafe endpoint.
|
* build an S3 client for an unsafe endpoint.
|
||||||
*/
|
*/
|
||||||
it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
|
it('rejects disallowed targets on the S3 endpoint', function (string $endpoint) {
|
||||||
$validator = Validator::make(
|
$validator = Validator::make(
|
||||||
['endpoint' => $endpoint],
|
['endpoint' => $endpoint],
|
||||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
|
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
|
||||||
|
|
@ -43,19 +45,15 @@
|
||||||
it('accepts real-world S3 endpoints', function (string $endpoint) {
|
it('accepts real-world S3 endpoints', function (string $endpoint) {
|
||||||
$validator = Validator::make(
|
$validator = Validator::make(
|
||||||
['endpoint' => $endpoint],
|
['endpoint' => $endpoint],
|
||||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
|
['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34'])]],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}");
|
expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}");
|
||||||
})->with([
|
})->with([
|
||||||
'AWS S3' => 'https://s3.us-east-1.amazonaws.com',
|
'AWS S3' => 'https://s3.us-east-1.amazonaws.com',
|
||||||
'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com',
|
|
||||||
'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com',
|
'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com',
|
||||||
'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com',
|
'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com',
|
||||||
'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000',
|
'Custom public domain S3-compatible endpoint' => 'https://example.com',
|
||||||
'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000',
|
|
||||||
'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000',
|
|
||||||
'Custom domain MinIO' => 'https://minio.example.com',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
|
it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
|
||||||
|
|
@ -91,3 +89,18 @@
|
||||||
'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
|
'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
|
||||||
'internal TLD' => 'http://backend.internal',
|
'internal TLD' => 'http://backend.internal',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
it('accepts explicitly allowlisted intranet S3 endpoints', function (string $endpoint, array $allowlist) {
|
||||||
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
|
||||||
|
|
||||||
|
$validator = Validator::make(
|
||||||
|
['endpoint' => $endpoint],
|
||||||
|
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($validator->passes())->toBeTrue("Expected allowlisted intranet S3 endpoint: {$endpoint}");
|
||||||
|
})->with([
|
||||||
|
'Self-hosted MinIO on 10.x CIDR' => ['http://10.0.0.5:9000', ['10.0.0.0/8']],
|
||||||
|
'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']],
|
||||||
|
'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']],
|
||||||
|
]);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
use App\Rules\SafeWebhookUrl;
|
use App\Rules\SafeWebhookUrl;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
uses(TestCase::class);
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
it('accepts valid public URLs', function () {
|
it('accepts valid public URLs', function () {
|
||||||
$rule = new SafeWebhookUrl;
|
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
|
||||||
|
|
||||||
$validUrls = [
|
$validUrls = [
|
||||||
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
|
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
|
||||||
|
|
@ -22,17 +24,6 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts private network IPs for self-hosted deployments', function (string $url) {
|
|
||||||
$rule = new SafeWebhookUrl;
|
|
||||||
|
|
||||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
|
||||||
expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}");
|
|
||||||
})->with([
|
|
||||||
'10.x range' => 'http://10.0.0.5/webhook',
|
|
||||||
'172.16.x range' => 'http://172.16.0.1:8080/hook',
|
|
||||||
'192.168.x range' => 'http://192.168.1.50:8080/webhook',
|
|
||||||
]);
|
|
||||||
|
|
||||||
it('rejects loopback addresses', function (string $url) {
|
it('rejects loopback addresses', function (string $url) {
|
||||||
$rule = new SafeWebhookUrl;
|
$rule = new SafeWebhookUrl;
|
||||||
|
|
||||||
|
|
@ -117,3 +108,153 @@
|
||||||
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
|
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
|
||||||
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
|
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects private and reserved network targets by default', function (string $url) {
|
||||||
|
$rule = new SafeWebhookUrl;
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
|
||||||
|
})->with([
|
||||||
|
'private 10/8' => 'http://10.0.0.5/webhook',
|
||||||
|
'private 172.16/12' => 'http://172.16.0.1:8080/hook',
|
||||||
|
'private 192.168/16' => 'http://192.168.1.50:8080/webhook',
|
||||||
|
'shared address space' => 'http://100.64.0.1/webhook',
|
||||||
|
'zero network peer alias' => 'http://0.0.0.1/webhook',
|
||||||
|
'multicast' => 'http://224.0.0.1/webhook',
|
||||||
|
'benchmark range' => 'http://198.18.0.1/webhook',
|
||||||
|
'documentation range' => 'http://192.0.2.10/webhook',
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('rejects hostname forms that resolve to loopback', function (string $url) {
|
||||||
|
$rule = new SafeWebhookUrl;
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue("Expected loopback hostname-form rejection: {$url}");
|
||||||
|
})->with([
|
||||||
|
'decimal IPv4' => 'http://2130706433:8888/exfil',
|
||||||
|
'hex IPv4' => 'http://0x7f000001:8888/exfil',
|
||||||
|
'octal IPv4' => 'http://017700000001:8888/exfil',
|
||||||
|
'short dotted IPv4' => 'http://127.1:8888/exfil',
|
||||||
|
'IPv4-mapped IPv6 hex loopback' => 'http://[::ffff:7f00:1]:8888/exfil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('rejects internal DNS suffixes by default', function (string $url) {
|
||||||
|
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
|
||||||
|
})->with([
|
||||||
|
'.local host' => 'http://receiver.local/webhook',
|
||||||
|
'.cluster.local host' => 'http://service.cluster.local/webhook',
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('rejects unresolvable hostnames by default', function () {
|
||||||
|
$rule = new SafeWebhookUrl(fn (string $host): array => []);
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => 'http://does-not-resolve.example.test/webhook'], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue('Expected default rejection for unresolvable host');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows explicitly configured intranet webhook targets', function (string $url, array $resolvedIps, array $allowlist) {
|
||||||
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
|
||||||
|
|
||||||
|
$rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->passes())->toBeTrue("Expected configured intranet target to pass: {$url}");
|
||||||
|
})->with([
|
||||||
|
'exact .local hostname' => ['http://receiver.local/webhook', ['192.168.10.20'], ['receiver.local']],
|
||||||
|
'private CIDR' => ['http://hooks.example.test/webhook', ['10.50.10.20'], ['10.50.0.0/16']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('requires explicit localhost opt in in addition to allowlist', function () {
|
||||||
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => ['localhost']]));
|
||||||
|
|
||||||
|
$rule = new SafeWebhookUrl;
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue('Expected localhost to remain blocked without explicit localhost opt in');
|
||||||
|
|
||||||
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allow_localhost' => true]));
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->passes())->toBeTrue('Expected localhost to pass only after explicit localhost opt in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds HTTP client options that pin resolved DNS for the request', function () {
|
||||||
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||||
|
'webhook_allowed_internal_hosts' => ['localhost'],
|
||||||
|
'webhook_allow_localhost' => true,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$options = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook');
|
||||||
|
|
||||||
|
expect($options['allow_redirects'])->toBeFalse();
|
||||||
|
|
||||||
|
if (defined('CURLOPT_RESOLVE')) {
|
||||||
|
expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed while building HTTP options when the send-time resolution is unsafe', function () {
|
||||||
|
expect(fn () => SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook'))
|
||||||
|
->toThrow(RuntimeException::class, 'unsafe IP address');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds MinIO client resolve options for S3 backup uploads', function () {
|
||||||
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||||
|
'webhook_allowed_internal_hosts' => ['localhost'],
|
||||||
|
'webhook_allow_localhost' => true,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
|
||||||
|
|
||||||
|
expect($options)->toContain('localhost:9000=127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () {
|
||||||
|
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => 'http://example.com./webhook'], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue('Expected trailing-dot hostname rejection');
|
||||||
|
|
||||||
|
expect(fn () => SafeWebhookUrl::httpClientOptions('http://example.com./webhook'))
|
||||||
|
->toThrow(RuntimeException::class, 'trailing dot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects reserved IPv6 ranges by default', function (string $url) {
|
||||||
|
$rule = new SafeWebhookUrl;
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue("Expected reserved IPv6 rejection: {$url}");
|
||||||
|
})->with([
|
||||||
|
'documentation IPv6' => 'http://[2001:db8::1]/webhook',
|
||||||
|
'IPv4/IPv6 translation prefix' => 'http://[64:ff9b::1]/webhook',
|
||||||
|
'6to4' => 'http://[2002::1]/webhook',
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('rejects hostnames that resolve to reserved IPv6 ranges by default', function (string $resolvedIp) {
|
||||||
|
$rule = new SafeWebhookUrl(fn (string $host): array => [$resolvedIp]);
|
||||||
|
|
||||||
|
$validator = Validator::make(['url' => 'http://ipv6-reserved.example.test/webhook'], ['url' => $rule]);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue("Expected reserved IPv6 resolution rejection: {$resolvedIp}");
|
||||||
|
})->with([
|
||||||
|
'2001:db8::1',
|
||||||
|
'64:ff9b::1',
|
||||||
|
'2002::1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('redacts webhook URLs for logs', function () {
|
||||||
|
expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar'))
|
||||||
|
->toBe('https://hooks.slack.com');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue