mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 00:16:42 +00:00
Improve outbound URL validation (#10833)
This commit is contained in:
12 files changed
+440
-85
No files matched your search
@@ -3,6 +3,7 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
@@ -41,6 +44,20 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
Http::post($this->webhookUrl, $this->message->toPayload());
|
||||
$validator = Validator::make(
|
||||
['webhook_url' => $this->webhookUrl],
|
||||
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
|
||||
'url' => $this->webhookUrl,
|
||||
'errors' => $validator->errors()->all(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->message->toPayload());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
@@ -34,6 +37,20 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$validator = Validator::make(
|
||||
['webhook_url' => $this->webhookUrl],
|
||||
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
|
||||
'url' => $this->webhookUrl,
|
||||
'errors' => $validator->errors()->all(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isSlackWebhook()) {
|
||||
$this->sendToSlack();
|
||||
|
||||
@@ -64,7 +81,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function sendToSlack(): void
|
||||
{
|
||||
Http::post($this->webhookUrl, [
|
||||
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
|
||||
'text' => $this->message->title,
|
||||
'blocks' => [
|
||||
[
|
||||
@@ -106,7 +123,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
$username = config('app.name');
|
||||
|
||||
Http::post($this->webhookUrl, [
|
||||
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
|
||||
'username' => $username,
|
||||
'attachments' => [
|
||||
[
|
||||
|
||||
@@ -64,7 +64,7 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
|
||||
]);
|
||||
}
|
||||
|
||||
$response = Http::post($this->webhookUrl, $this->payload);
|
||||
$response = Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->payload);
|
||||
|
||||
if (isDev()) {
|
||||
ray('Webhook response', [
|
||||
|
||||
@@ -165,6 +165,7 @@ class S3Storage extends BaseModel
|
||||
'http' => [
|
||||
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
|
||||
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
|
||||
'allow_redirects' => false,
|
||||
],
|
||||
]);
|
||||
// Test the connection by listing files with ListObjectsV2 (S3)
|
||||
|
||||
+122
-24
@@ -8,6 +8,11 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SafeExternalUrl implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param (Closure(string): array<int, string>)|null $resolver
|
||||
*/
|
||||
public function __construct(private ?Closure $resolver = null) {}
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
@@ -38,44 +43,137 @@ class SafeExternalUrl implements ValidationRule
|
||||
}
|
||||
|
||||
$host = strtolower($host);
|
||||
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
|
||||
$hostForDns = rtrim($hostForIpCheck, '.');
|
||||
|
||||
// Block well-known internal hostnames
|
||||
$internalHosts = ['localhost', '0.0.0.0', '::1'];
|
||||
if (in_array($host, $internalHosts) || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) {
|
||||
Log::warning('External URL points to internal host', [
|
||||
'attribute' => $attribute,
|
||||
'url' => $value,
|
||||
'host' => $host,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
if (in_array($hostForDns, $internalHosts, true) || str_ends_with($hostForDns, '.local') || str_ends_with($hostForDns, '.internal')) {
|
||||
$this->logBlockedHost($attribute, $value, $host);
|
||||
$fail('The :attribute must not point to internal hosts.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve hostname to IP and block private/reserved ranges
|
||||
$ip = gethostbyname($host);
|
||||
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
|
||||
if (! $this->isPublicIp($hostForIpCheck)) {
|
||||
$this->logBlockedIp($attribute, $value, $host, $hostForIpCheck);
|
||||
$fail('The :attribute must not point to a private or reserved IP address.');
|
||||
|
||||
// gethostbyname returns the original hostname on failure (e.g. unresolvable)
|
||||
if ($ip === $host && ! filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolvedIps = $this->resolveHost($hostForDns);
|
||||
if ($resolvedIps === []) {
|
||||
$fail('The :attribute host could not be resolved.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
Log::warning('External URL resolves to private or reserved IP', [
|
||||
'attribute' => $attribute,
|
||||
'url' => $value,
|
||||
'host' => $host,
|
||||
'resolved_ip' => $ip,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
$fail('The :attribute must not point to a private or reserved IP address.');
|
||||
foreach ($resolvedIps as $resolvedIp) {
|
||||
if (! $this->isPublicIp($resolvedIp)) {
|
||||
$this->logBlockedIp($attribute, $value, $host, $resolvedIp);
|
||||
$fail('The :attribute must not point to a private or reserved IP address.');
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeHostForIpCheck(string $host): string
|
||||
{
|
||||
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
|
||||
? substr($host, 1, -1)
|
||||
: $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveHost(string $host): array
|
||||
{
|
||||
if ($this->resolver instanceof Closure) {
|
||||
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
|
||||
}
|
||||
|
||||
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
|
||||
if ($records === false) {
|
||||
$records = [];
|
||||
}
|
||||
|
||||
$ips = [];
|
||||
foreach ($records as $record) {
|
||||
foreach (['ip', 'ipv6'] as $key) {
|
||||
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
|
||||
$ips[] = $record[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ipv4Addresses = @gethostbynamel($host);
|
||||
if (is_array($ipv4Addresses)) {
|
||||
foreach ($ipv4Addresses as $ip) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
$ips[] = $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
||||
}
|
||||
|
||||
private function extractIpv4FromMappedIpv6(string $ip): ?string
|
||||
{
|
||||
$packed = @inet_pton($ip);
|
||||
if ($packed === false || strlen($packed) !== 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prefix = substr($packed, 0, 12);
|
||||
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = unpack('C4', substr($packed, 12, 4));
|
||||
if ($parts === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
private function logBlockedHost(string $attribute, string $url, string $host): void
|
||||
{
|
||||
Log::warning('External URL points to internal host', [
|
||||
'attribute' => $attribute,
|
||||
'url' => $url,
|
||||
'host' => $host,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function logBlockedIp(string $attribute, string $url, string $host, string $resolvedIp): void
|
||||
{
|
||||
Log::warning('External URL resolves to private or reserved IP', [
|
||||
'attribute' => $attribute,
|
||||
'url' => $url,
|
||||
'host' => $host,
|
||||
'resolved_ip' => $resolvedIp,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+154
-37
@@ -8,6 +8,11 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SafeWebhookUrl implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param (Closure(string): array<int, string>)|null $resolver
|
||||
*/
|
||||
public function __construct(private ?Closure $resolver = null) {}
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
@@ -39,63 +44,175 @@ class SafeWebhookUrl implements ValidationRule
|
||||
}
|
||||
|
||||
$host = strtolower($host);
|
||||
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
|
||||
$hostForDns = rtrim($hostForIpCheck, '.');
|
||||
|
||||
// Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed
|
||||
// literals can't sneak past filter_var FILTER_VALIDATE_IP.
|
||||
$hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']'))
|
||||
? substr($host, 1, -1)
|
||||
: $host;
|
||||
|
||||
// Block well-known dangerous hostnames
|
||||
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
|
||||
if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) {
|
||||
Log::warning('Webhook URL points to blocked host', [
|
||||
'attribute' => $attribute,
|
||||
'host' => $host,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) {
|
||||
$this->logBlockedHost($attribute, $host);
|
||||
$fail('The :attribute must not point to localhost or internal hosts.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
|
||||
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) {
|
||||
Log::warning('Webhook URL points to blocked IP range', [
|
||||
'attribute' => $attribute,
|
||||
'host' => $host,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
$fail('The :attribute must not point to loopback or link-local addresses.');
|
||||
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
|
||||
if ($this->isBlockedIp($hostForIpCheck)) {
|
||||
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
|
||||
$fail('The :attribute must not point to loopback or link-local addresses.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolvedIps = $this->resolveHost($hostForDns);
|
||||
foreach ($resolvedIps as $resolvedIp) {
|
||||
if ($this->isBlockedIp($resolvedIp)) {
|
||||
$this->logBlockedIp($attribute, $host, $resolvedIp);
|
||||
$fail('The :attribute must not point to loopback or link-local addresses.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isLoopback(string $ip): bool
|
||||
private function normalizeHostForIpCheck(string $host): string
|
||||
{
|
||||
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
|
||||
? substr($host, 1, -1)
|
||||
: $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveHost(string $host): array
|
||||
{
|
||||
if ($this->resolver instanceof Closure) {
|
||||
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
|
||||
}
|
||||
|
||||
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
|
||||
if ($records === false) {
|
||||
$records = [];
|
||||
}
|
||||
|
||||
$ips = [];
|
||||
foreach ($records as $record) {
|
||||
foreach (['ip', 'ipv6'] as $key) {
|
||||
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
|
||||
$ips[] = $record[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ipv4Addresses = @gethostbynamel($host);
|
||||
if (is_array($ipv4Addresses)) {
|
||||
foreach ($ipv4Addresses as $ip) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
$ips[] = $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
private function isBlockedIp(string $ip): bool
|
||||
{
|
||||
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
|
||||
if ($embeddedIpv4 !== null) {
|
||||
return $this->isBlockedIpv4($embeddedIpv4);
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return $this->isBlockedIpv4($ip);
|
||||
}
|
||||
|
||||
return $this->isBlockedIpv6($ip);
|
||||
}
|
||||
|
||||
private function isBlockedIpv4(string $ip): bool
|
||||
{
|
||||
// 127.0.0.0/8, 0.0.0.0
|
||||
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPv6 loopback
|
||||
$normalized = @inet_pton($ip);
|
||||
|
||||
return $normalized !== false && $normalized === inet_pton('::1');
|
||||
}
|
||||
|
||||
private function isLinkLocal(string $ip): bool
|
||||
{
|
||||
// 169.254.0.0/16 — covers cloud metadata at 169.254.169.254
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$long = ip2long($ip);
|
||||
if ($long === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$long = ip2long($ip);
|
||||
$unsigned = sprintf('%u', $long);
|
||||
$linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
|
||||
$linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
|
||||
|
||||
return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16);
|
||||
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
|
||||
{
|
||||
$packed = @inet_pton($ip);
|
||||
if ($packed === false || strlen($packed) !== 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prefix = substr($packed, 0, 12);
|
||||
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = unpack('C4', substr($packed, 12, 4));
|
||||
if ($parts === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
private function logBlockedHost(string $attribute, string $host): void
|
||||
{
|
||||
Log::warning('Webhook URL points to blocked host', [
|
||||
'attribute' => $attribute,
|
||||
'host' => $host,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function logBlockedIp(string $attribute, string $host, string $blockedIp): void
|
||||
{
|
||||
Log::warning('Webhook URL points to blocked IP range', [
|
||||
'attribute' => $attribute,
|
||||
'host' => $host,
|
||||
'resolved_ip' => $blockedIp,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user