CVE-2025-9821
Description
SummaryUsers with webhook permissions can conduct SSRF via webhooks. If they have permission to view the webhook logs, the (partial) request response is also disclosed
DetailsWhen sending webhooks, the destination is not validated, causing SSRF.
ImpactBypass of firewalls to interact with internal services. See https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/ for more potential impact.
Resources https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html for more information on SSRF and its fix.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mautic/corePackagist | >= 4.4.0, < 4.4.17 | 4.4.17 |
mautic/corePackagist | >= 5.0.0-alpha, < 5.2.8 | 5.2.8 |
mautic/corePackagist | >= 6.0.0-alpha, < 6.0.5 | 6.0.5 |
Affected products
1Patches
212 files changed · +561 −67
app/bundles/CoreBundle/Helper/PrivateAddressChecker.php+181 −0 added@@ -0,0 +1,181 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\CoreBundle\Helper; + +class PrivateAddressChecker +{ + private const PRIVATE_IP_RANGES = [ + '10.0.0.0/8', // RFC1918 + '172.16.0.0/12', // RFC1918 + '192.168.0.0/16', // RFC1918 + '127.0.0.0/8', // Localhost + '169.254.0.0/16', // Link-local + 'fc00::/7', // Unique local address + 'fe80::/10', // Link-local address + '::1/128', // Localhost IPv6 + ]; + + /** @var array<string> */ + private array $allowedPrivateAddresses = []; + + /** + * @param callable|null $dnsResolver + */ + public function __construct( + private $dnsResolver = null, + ) { + $this->dnsResolver = $dnsResolver ?? 'gethostbynamel'; + } + + /** + * @param array<string> $allowedPrivateAddresses + */ + public function setAllowedPrivateAddresses(array $allowedPrivateAddresses): PrivateAddressChecker + { + $this->allowedPrivateAddresses = $allowedPrivateAddresses; + + return $this; + } + + public function isPrivateUrl(string $url): bool + { + try { + $parsedUrl = parse_url($url); + + if (!isset($parsedUrl['host'])) { + throw new \InvalidArgumentException('Invalid URL format'); + } + + $host = strtolower($parsedUrl['host']); + + if ('localhost' === $host) { + return true; + } + + // Handle IPv6 addresses with brackets + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $ip = substr($host, 1, -1); // Remove brackets + + return $this->isPrivateIp($ip); + } + + if (!filter_var($host, FILTER_VALIDATE_IP)) { + $ips = ($this->dnsResolver)($host); + if (false === $ips) { + throw new \InvalidArgumentException('Could not resolve hostname'); + } + foreach ($ips as $ip) { + if ($this->isPrivateIp($ip)) { + return true; + } + } + + return false; + } + + return $this->isPrivateIp($host); + } catch (\Exception $e) { + throw new \InvalidArgumentException('URL validation failed: '.$e->getMessage()); + } + } + + public function isPrivateIp(string $ip): bool + { + // Remove zone index if present + if (($pos = strpos($ip, '%')) !== false) { + $ip = substr($ip, 0, $pos); + } + + $binaryIp = @inet_pton($ip); + if (false === $binaryIp) { + return false; + } + + foreach (self::PRIVATE_IP_RANGES as $range) { + [$networkIp, $netmask] = explode('/', $range); + + $binaryNetwork = @inet_pton($networkIp); + if (false === $binaryNetwork) { + continue; + } + + $maskLen = (int) $netmask; + $numBytes = (int) ($maskLen / 8); + $numBits = $maskLen % 8; + + if (substr($binaryIp, 0, $numBytes) !== substr($binaryNetwork, 0, $numBytes)) { + continue; + } + + if ($numBits > 0) { + $mask = 0xFF << (8 - $numBits); + if ((ord($binaryIp[$numBytes]) & $mask) !== (ord($binaryNetwork[$numBytes]) & $mask)) { + continue; + } + } + + return true; + } + + return false; + } + + /** + * Checks if the given URL is allowed based on the allowed private addresses list. + * Returns true if the URL is either public or in the allowed private addresses list. + */ + public function isAllowedUrl(string $url): bool + { + try { + // If it's not a private URL, it's allowed + if (!$this->isPrivateUrl($url)) { + return true; + } + + // If no allowed private addresses are set, all private URLs are forbidden + if (empty($this->allowedPrivateAddresses)) { + return false; + } + + $parsedUrl = parse_url($url); + if (!isset($parsedUrl['host'])) { + throw new \InvalidArgumentException('Invalid URL format'); + } + + $host = strtolower($parsedUrl['host']); + + // Check if the host is directly in allowed addresses + if (in_array($host, $this->allowedPrivateAddresses, true)) { + return true; + } + + // Handle IPv6 addresses with brackets + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); // Remove brackets + } + + // If the host is an IP address, check if it's in allowed addresses + if (filter_var($host, FILTER_VALIDATE_IP)) { + return in_array($host, $this->allowedPrivateAddresses, true); + } + + // Resolve hostname to IPs and check if any are in allowed addresses + $ips = ($this->dnsResolver)($host); + if (false === $ips) { + throw new \InvalidArgumentException('Could not resolve hostname'); + } + + foreach ($ips as $ip) { + if (in_array($ip, $this->allowedPrivateAddresses, true)) { + return true; + } + } + + return false; + } catch (\Exception $e) { + throw new \InvalidArgumentException('URL validation failed: '.$e->getMessage()); + } + } +}
app/bundles/CoreBundle/Tests/Helper/PrivateAddressCheckerTest.php+220 −0 added@@ -0,0 +1,220 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\CoreBundle\Tests\Helper; + +use Mautic\CoreBundle\Helper\PrivateAddressChecker; +use PHPUnit\Framework\TestCase; + +class PrivateAddressCheckerTest extends TestCase +{ + private PrivateAddressChecker $checker; + private PrivateAddressChecker $checkerWithMockedDns; + + protected function setUp(): void + { + // Regular checker for IP tests + $this->checker = new PrivateAddressChecker(); + + // Checker with mocked DNS resolver for URL tests + $this->checkerWithMockedDns = new PrivateAddressChecker( + function (string $host) { + return match ($host) { + 'private.example.com' => ['192.168.1.1'], + 'public.example.com' => ['203.0.113.1'], + 'api.example.com' => ['8.8.8.8'], + 'localhost' => ['127.0.0.1'], + default => false, + }; + } + ); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('privateIpProvider')] + public function testIsPrivateIpReturnsTrue(string $ip): void + { + $this->assertTrue($this->checker->isPrivateIp($ip)); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('publicIpProvider')] + public function testIsPrivateIpReturnsFalse(string $ip): void + { + $this->assertFalse($this->checker->isPrivateIp($ip)); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('privateUrlProvider')] + public function testIsPrivateUrlReturnsTrue(string $url): void + { + $this->assertTrue($this->checkerWithMockedDns->isPrivateUrl($url)); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('publicUrlProvider')] + public function testIsPrivateUrlReturnsFalse(string $url): void + { + $this->assertFalse($this->checkerWithMockedDns->isPrivateUrl($url)); + } + + /** + * @return array<string, array{string}> + */ + public static function privateIpProvider(): array + { + return [ + 'IPv4 Local' => ['127.0.0.1'], + 'IPv4 Private Class A' => ['10.0.0.1'], + 'IPv4 Private Class B' => ['172.16.0.1'], + 'IPv4 Private Class C' => ['192.168.0.1'], + 'IPv4 Link Local' => ['169.254.0.1'], + 'IPv6 Localhost' => ['::1'], + 'IPv6 Unique Local' => ['fc00::1'], + 'IPv6 Link Local' => ['fe80::1'], + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function publicIpProvider(): array + { + return [ + 'IPv4 Public' => ['8.8.8.8'], + 'IPv4 Public Alternative' => ['203.0.113.1'], + 'IPv6 Public' => ['2001:4860:4860::8888'], + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function privateUrlProvider(): array + { + return [ + 'Localhost' => ['http://localhost'], + 'Localhost with port' => ['http://localhost:8080'], + 'IPv4 Private' => ['http://192.168.1.1'], + 'IPv4 Private with path' => ['https://10.0.0.1/path'], + 'IPv6 Localhost' => ['http://[::1]'], + 'IPv6 Private' => ['http://[fc00::1]'], + 'Domain resolving to private IP' => ['http://private.example.com'], + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function publicUrlProvider(): array + { + return [ + 'Public Domain' => ['http://public.example.com'], + 'IPv4 Public' => ['http://8.8.8.8'], + 'IPv6 Public' => ['http://[2001:4860:4860::8888]'], + 'HTTPS URL' => ['https://api.example.com/v1'], + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function invalidUrlProvider(): array + { + return [ + 'Empty URL' => [''], + 'Invalid Format' => ['not-a-url'], + 'Missing Protocol' => ['example.com'], + 'Invalid Characters' => ['http://example.com\\invalid'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidUrlProvider')] + public function testIsPrivateUrlThrowsExceptionForInvalidUrls(string $url): void + { + $this->expectException(\InvalidArgumentException::class); + $this->checkerWithMockedDns->isPrivateUrl($url); + } + + public function testUnresolvableHostname(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('URL validation failed: Could not resolve hostname'); + $this->checkerWithMockedDns->isPrivateUrl('http://unresolvable.example.com'); + } + + public function testIsPrivateIpWithInvalidIPFormat(): void + { + $this->assertFalse($this->checker->isPrivateIp('invalid-ip')); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('edgeCaseUrlProvider')] + public function testEdgeCaseUrls(string $url, bool $expectedResult): void + { + $this->assertEquals($expectedResult, $this->checkerWithMockedDns->isPrivateUrl($url)); + } + + /** + * @return array<string, array{string, bool}> + */ + public static function edgeCaseUrlProvider(): array + { + return [ + 'URL with Port' => ['http://[::1]:8080', true], + 'IPv6 with Zone Index' => ['http://[fe80::1%eth0]', true], + 'IPv6 Full Format' => ['http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]', false], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('allowedUrlProvider')] + public function testIsAllowedUrlReturnsTrue(string $url): void + { + $this->checkerWithMockedDns->setAllowedPrivateAddresses(['192.168.1.1', 'localhost', '::1']); + $this->assertTrue($this->checkerWithMockedDns->isAllowedUrl($url)); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('disallowedUrlProvider')] + public function testIsAllowedUrlReturnsFalse(string $url): void + { + $this->checkerWithMockedDns->setAllowedPrivateAddresses(['192.168.1.2', '10.0.0.1']); + $this->assertFalse($this->checkerWithMockedDns->isAllowedUrl($url)); + } + + public function testIsAllowedUrlWithEmptyAllowedAddresses(): void + { + $this->checkerWithMockedDns->setAllowedPrivateAddresses([]); + $this->assertTrue($this->checkerWithMockedDns->isAllowedUrl('http://public.example.com')); + $this->assertFalse($this->checkerWithMockedDns->isAllowedUrl('http://private.example.com')); + } + + /** + * @return array<string, array{string}> + */ + public static function allowedUrlProvider(): array + { + return [ + 'Public Domain' => ['http://public.example.com'], + 'Allowed Private IP' => ['http://192.168.1.1'], + 'Allowed Localhost' => ['http://localhost'], + 'Allowed IPv6 Localhost' => ['http://[::1]'], + 'Domain to Allowed Private IP' => ['http://private.example.com'], // Resolves to 192.168.1.1 + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function disallowedUrlProvider(): array + { + return [ + 'Disallowed Private IP' => ['http://192.168.1.1'], + 'Different Private IP' => ['http://192.168.1.3'], + 'Domain to Disallowed Private IP' => ['http://private.example.com'], + 'Localhost when not allowed' => ['http://localhost'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidUrlProvider')] + public function testIsAllowedUrlThrowsExceptionForInvalidUrls(string $url): void + { + $this->expectException(\InvalidArgumentException::class); + $this->checkerWithMockedDns->isAllowedUrl($url); + } +}
app/bundles/WebhookBundle/Assets/js/webhook.js+9 −4 modified@@ -30,15 +30,20 @@ Mautic.sendHookTest = function() { type: 'POST', dataType: "json", success: function(response) { - if (response.success) { + if (response.html) { mQuery('#tester').html(response.html); } }, - error: function (request, textStatus, errorThrown) { - Mautic.processAjaxError(request, textStatus, errorThrown); + error: function (response, textStatus, errorThrown) { + console.log(response.responseJSON); + if (response.responseJSON.html) { + mQuery('#tester').html(response.responseJSON.html); + } else { + Mautic.processAjaxError(response, textStatus, errorThrown); + } }, complete: function(response) { spinner.addClass('hide'); } }) -}; \ No newline at end of file +};
app/bundles/WebhookBundle/Config/config.php+1 −7 modified@@ -61,13 +61,6 @@ 'event_dispatcher', ], ], - 'mautic.webhook.http.client' => [ - 'class' => Mautic\WebhookBundle\Http\Client::class, - 'arguments' => [ - 'mautic.helper.core_parameters', - 'mautic.http.client', - ], - ], ], ], @@ -81,5 +74,6 @@ 'queue_mode' => Mautic\WebhookBundle\Model\WebhookModel::IMMEDIATE_PROCESS, // Trigger the webhook immediately or queue it for faster response times 'events_orderby_dir' => Doctrine\Common\Collections\Order::Ascending->value, // Order the queued events chronologically or the other way around 'webhook_email_details' => true, // If enabled, email related webhooks send detailed data + 'webhook_allowed_private_addresses' => [], ], ];
app/bundles/WebhookBundle/Controller/AjaxController.php+70 −40 modified@@ -5,56 +5,86 @@ use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController; use Mautic\CoreBundle\Helper\InputHelper; use Mautic\CoreBundle\Helper\PathsHelper; +use Mautic\WebhookBundle\Exception\PrivateAddressException; use Mautic\WebhookBundle\Http\Client; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; class AjaxController extends CommonAjaxController { - public function sendHookTestAction(Request $request, Client $client, PathsHelper $pathsHelper): \Symfony\Component\HttpFoundation\JsonResponse + public function sendHookTestAction(Request $request, Client $client, PathsHelper $pathsHelper): JsonResponse { - $url = InputHelper::url($request->request->get('url')); - - // validate the URL - if ('' == $url || !$url) { - // default to an error message - $dataArray = [ - 'success' => 1, - 'html' => '<div class="has-error"><span class="help-block">' - .$this->translator->trans('mautic.webhook.label.no.url') - .'</span></div>', - ]; - - return $this->sendJsonResponse($dataArray); + try { + return $this->processWebhookTest($request, $client, $pathsHelper); + } catch (PrivateAddressException) { + return $this->createErrorResponse( + 'mautic.webhook.error.private_address' + ); + } catch (\Exception) { + return $this->createErrorResponse( + 'mautic.webhook.label.warning' + ); } + } - // get the selected types - $selectedTypes = InputHelper::cleanArray($request->request->all()['types']) ?? []; - $payloadPaths = $this->getPayloadPaths($selectedTypes, $pathsHelper); - $payloads = $this->loadPayloads($payloadPaths); - $now = new \DateTime(); - - $payloads['timestamp'] = $now->format('c'); - - // set the response - $response = $client->post($url, $payloads, InputHelper::string($request->request->get('secret'))); - - // default to an error message - $dataArray = [ - 'success' => 1, - 'html' => '<div class="has-error"><span class="help-block">' - .$this->translator->trans('mautic.webhook.label.warning') - .'</span></div>', - ]; - - // if we get a 2xx response convert to success message - if (2 == substr((string) $response->getStatusCode(), 0, 1)) { - $dataArray['html'] = - '<div class="has-success"><span class="help-block">' - .$this->translator->trans('mautic.webhook.label.success') - .'</span></div>'; + private function processWebhookTest(Request $request, Client $client, PathsHelper $pathsHelper): JsonResponse + { + $url = $this->validateUrl($request); + if (!$url) { + return $this->createErrorResponse('mautic.webhook.label.no.url'); } - return $this->sendJsonResponse($dataArray); + $selectedTypes = InputHelper::cleanArray($request->request->all()['types']) ?? []; + $payloadPaths = $this->getPayloadPaths($selectedTypes, $pathsHelper); + $payload = $this->loadPayloads($payloadPaths); + $payload['timestamp'] = (new \DateTimeImmutable())->format('c'); + $secret = InputHelper::string($request->request->get('secret')); + + $response = $client->post($url, $payload, $secret); + + return $this->createResponseFromStatusCode($response->getStatusCode()); + } + + private function validateUrl(Request $request): ?string + { + $url = InputHelper::url($request->request->get('url')); + + return '' !== $url ? $url : null; + } + + private function createResponseFromStatusCode(int $statusCode): JsonResponse + { + $isSuccess = str_starts_with((string) $statusCode, '2'); + $message = $isSuccess + ? 'mautic.webhook.label.success' + : 'mautic.webhook.label.warning'; + + $cssClass = $isSuccess ? 'has-success' : 'has-error'; + + return $this->createJsonResponse($message, $cssClass); + } + + private function createErrorResponse(string $message): JsonResponse + { + return $this->createJsonResponse($message, 'has-error', Response::HTTP_BAD_REQUEST); + } + + private function createJsonResponse( + string $message, + string $cssClass, + int $status = Response::HTTP_OK, + ): JsonResponse { + $html = sprintf( + '<div class="%s"><span class="help-block">%s</span></div>', + $cssClass, + $this->translator->trans($message) + ); + + return $this->sendJsonResponse( + ['html' => $html], + $status + ); } /*
app/bundles/WebhookBundle/Exception/PrivateAddressException.php+15 −0 added@@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\WebhookBundle\Exception; + +class PrivateAddressException extends \Exception +{ + private const DEFAULT_MESSAGE = 'Access to private addresses is not allowed.'; + + public function __construct(string $message = self::DEFAULT_MESSAGE, int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } +}
app/bundles/WebhookBundle/Form/Type/ConfigType.php+19 −0 modified@@ -3,9 +3,11 @@ namespace Mautic\WebhookBundle\Form\Type; use Doctrine\Common\Collections\Order; +use Mautic\CoreBundle\Form\DataTransformer\ArrayLinebreakTransformer; use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Validator\Constraints\NotBlank; @@ -61,6 +63,23 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], ] ); + + $builder->add( + $builder->create( + 'webhook_allowed_private_addresses', + TextareaType::class, + [ + 'label' => 'mautic.webhook.config.allowed_private_addresses', + 'label_attr' => ['class' => 'control-label'], + 'attr' => [ + 'class' => 'form-control', + 'tooltip' => 'mautic.webhook.config.allowed_private_addresses.tooltip', + 'rows' => 8, + ], + 'required' => false, + ] + )->addViewTransformer(new ArrayLinebreakTransformer()) + ); } public function getBlockPrefix(): string
app/bundles/WebhookBundle/Http/Client.php+12 −5 modified@@ -2,19 +2,19 @@ namespace Mautic\WebhookBundle\Http; +use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Psr7\Request; -use Http\Adapter\Guzzle7\Client as GuzzleClient; use Mautic\CoreBundle\Helper\CoreParametersHelper; +use Mautic\CoreBundle\Helper\PrivateAddressChecker; +use Mautic\WebhookBundle\Exception\PrivateAddressException; use Psr\Http\Message\ResponseInterface; class Client { - /** - * @param GuzzleClient $httpClient - */ public function __construct( private CoreParametersHelper $coreParametersHelper, - private $httpClient, + private GuzzleClient $httpClient, + private PrivateAddressChecker $privateAddressChecker, ) { } @@ -31,6 +31,13 @@ public function post($url, array $payload, ?string $secret = null): ResponseInte 'Webhook-Signature' => $signature, ]; + $allowedPrivateAddresses = $this->coreParametersHelper->get('webhook_allowed_private_addresses'); + $this->privateAddressChecker->setAllowedPrivateAddresses($allowedPrivateAddresses); + + if (!$this->privateAddressChecker->isAllowedUrl($url)) { + throw new PrivateAddressException(); + } + return $this->httpClient->sendRequest(new Request('POST', $url, $headers, $jsonPayload)); } }
app/bundles/WebhookBundle/Tests/Functional/Command/ProcessWebhookQueuesCommandTest.php+2 −2 modified@@ -29,7 +29,7 @@ protected function setUp(): void public function testCommand(): void { - $webhook = $this->createWebhook('test', 'http://domain.tld', 'secret'); + $webhook = $this->createWebhook('test', 'https://httpbin.org/post', 'secret'); $event = $this->createWebhookEvent($webhook, 'Type'); $handlerStack = static::getContainer()->get(MockHandler::class); $queueIds = []; @@ -42,7 +42,7 @@ public function testCommand(): void $handlerStack->append( function (RequestInterface $request) { Assert::assertSame('POST', $request->getMethod()); - Assert::assertSame('http://domain.tld', $request->getUri()->__toString()); + Assert::assertSame('https://httpbin.org/post', $request->getUri()->__toString()); return new Response(SymfonyResponse::HTTP_OK); }
app/bundles/WebhookBundle/Tests/Functional/WebhookFunctionalTest.php+2 −2 modified@@ -52,7 +52,7 @@ public function testWebhookWorkflowWithCommandProcess(): void // One resource is going to be found in the Transifex project: $handlerStack->append( function (RequestInterface $request) use (&$sendRequestCounter) { - Assert::assertSame('://whatever.url', $request->getUri()->getPath()); + Assert::assertSame('/post', $request->getUri()->getPath()); $jsonPayload = json_decode($request->getBody()->getContents(), true); Assert::assertCount(3, $jsonPayload['mautic.lead_post_save_new']); Assert::assertNotEmpty($request->getHeader('Webhook-Signature')); @@ -93,7 +93,7 @@ private function createWebhook(): Webhook $webhook->addEvent($event); $webhook->setName('Webhook from a functional test'); - $webhook->setWebhookUrl('https:://whatever.url'); + $webhook->setWebhookUrl('https://httpbin.org/post'); $webhook->setSecret('any_secret_will_do'); $webhook->isPublished(true); $webhook->setCreatedBy(1);
app/bundles/WebhookBundle/Tests/Unit/Http/ClientTest.php+27 −7 modified@@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use Mautic\CoreBundle\Helper\CoreParametersHelper; +use Mautic\CoreBundle\Helper\PrivateAddressChecker; use Mautic\WebhookBundle\Http\Client; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -30,32 +31,51 @@ protected function setUp(): void $this->parametersMock = $this->createMock(CoreParametersHelper::class); $this->httpClientMock = $this->createMock(GuzzleClient::class); - $this->client = new Client($this->parametersMock, $this->httpClientMock); + $this->client = new Client($this->parametersMock, $this->httpClientMock, new PrivateAddressChecker()); } public function testPost(): void { $method = 'POST'; - $url = 'url'; + $url = 'https://8.8.8.8'; $payload = ['payload']; + $secret = 'secret123'; $siteUrl = 'siteUrl'; + + // Calculate the expected signature the same way as the Client class + $jsonPayload = json_encode($payload); + $expectedSignature = base64_encode(hash_hmac('sha256', $jsonPayload, $secret, true)); + $headers = [ 'Content-Type' => 'application/json', 'X-Origin-Base-URL' => $siteUrl, + 'Webhook-Signature' => $expectedSignature, ]; $response = new Response(); - $this->parametersMock->expects($this->once()) + $matcher = $this->exactly(2); + $this->parametersMock->expects($matcher) ->method('get') - ->with('site_url') - ->willReturn($siteUrl); + ->willReturnCallback(function (string $parameter) use ($matcher, $siteUrl) { + if (1 === $matcher->numberOfInvocations()) { + $this->assertSame('site_url', $parameter); + + return $siteUrl; + } + if (2 === $matcher->numberOfInvocations()) { + $this->assertSame('webhook_allowed_private_addresses', $parameter); + + return []; + } + throw new \RuntimeException('Unexpected method call'); + }); $this->httpClientMock->expects($this->once()) ->method('sendRequest') ->with($this->callback(function (Request $request) use ($method, $url, $headers, $payload) { $this->assertSame($method, $request->getMethod()); - $this->assertSame($url, $request->getUri()->getPath()); + $this->assertSame($url, (string) $request->getUri()); foreach ($headers as $headerName => $headerValue) { $header = $request->getHeader($headerName); @@ -68,6 +88,6 @@ public function testPost(): void })) ->willReturn($response); - $this->assertEquals($response, $this->client->post($url, $payload)); + $this->assertEquals($response, $this->client->post($url, $payload, $secret)); } }
app/bundles/WebhookBundle/Translations/en_US/messages.ini+3 −0 modified@@ -5,6 +5,7 @@ mautic.webhook.config.form.queue.mode.tooltip="Select how to process webhook eve mautic.webhook.config.form.queue.mode="Queue Mode" mautic.webhook.config.immediate_process="Process Events Immediately" mautic.webhook.error.notfound="Webhook Not Found" +mautic.webhook.error.private_address="The URL you provided for the webhook is in a private IP address range, which is not allowed by default. Please provide a URL that points to a publicly accessible server, or add the address to the whitelist in the system settings if you need to use a private IP." mautic.webhook.form.confirmbatchdelete="Delete the selected Webhooks?" mautic.webhook.form.confirmdelete="Delete the Webhook, %name%?" mautic.webhook.form.description="Webhook Description" @@ -57,3 +58,5 @@ mautic.webhook.card.interesting="You may also find interesting" mautic.webhook.card.image.alt="A conceptual image symbolizing webhooks, featuring interconnected blue and purple crystalline structures that represent data packets being automatically sent between applications." mautic.webhook.card.heading="Send data from your account into another system or database" mautic.webhook.card.copy="Event driven webhooks allow you to update 3rd party applications with contact and campaign activity as they happen, using an API that can listen for webhooks." +mautic.webhook.config.allowed_private_addresses="Allowed private addresses (one per line)" +mautic.webhook.config.allowed_private_addresses.tooltip="Specify a list of private IPs or domains that are permitted for webhooks. This allows webhooks to interact with internal services securely."
dc5bb1466c9aMST-78 Fix SSRF in webhooks M7
12 files changed · +561 −67
app/bundles/CoreBundle/Helper/PrivateAddressChecker.php+181 −0 added@@ -0,0 +1,181 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\CoreBundle\Helper; + +class PrivateAddressChecker +{ + private const PRIVATE_IP_RANGES = [ + '10.0.0.0/8', // RFC1918 + '172.16.0.0/12', // RFC1918 + '192.168.0.0/16', // RFC1918 + '127.0.0.0/8', // Localhost + '169.254.0.0/16', // Link-local + 'fc00::/7', // Unique local address + 'fe80::/10', // Link-local address + '::1/128', // Localhost IPv6 + ]; + + /** @var array<string> */ + private array $allowedPrivateAddresses = []; + + /** + * @param callable|null $dnsResolver + */ + public function __construct( + private $dnsResolver = null, + ) { + $this->dnsResolver = $dnsResolver ?? 'gethostbynamel'; + } + + /** + * @param array<string> $allowedPrivateAddresses + */ + public function setAllowedPrivateAddresses(array $allowedPrivateAddresses): PrivateAddressChecker + { + $this->allowedPrivateAddresses = $allowedPrivateAddresses; + + return $this; + } + + public function isPrivateUrl(string $url): bool + { + try { + $parsedUrl = parse_url($url); + + if (!isset($parsedUrl['host'])) { + throw new \InvalidArgumentException('Invalid URL format'); + } + + $host = strtolower($parsedUrl['host']); + + if ('localhost' === $host) { + return true; + } + + // Handle IPv6 addresses with brackets + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $ip = substr($host, 1, -1); // Remove brackets + + return $this->isPrivateIp($ip); + } + + if (!filter_var($host, FILTER_VALIDATE_IP)) { + $ips = ($this->dnsResolver)($host); + if (false === $ips) { + throw new \InvalidArgumentException('Could not resolve hostname'); + } + foreach ($ips as $ip) { + if ($this->isPrivateIp($ip)) { + return true; + } + } + + return false; + } + + return $this->isPrivateIp($host); + } catch (\Exception $e) { + throw new \InvalidArgumentException('URL validation failed: '.$e->getMessage()); + } + } + + public function isPrivateIp(string $ip): bool + { + // Remove zone index if present + if (($pos = strpos($ip, '%')) !== false) { + $ip = substr($ip, 0, $pos); + } + + $binaryIp = @inet_pton($ip); + if (false === $binaryIp) { + return false; + } + + foreach (self::PRIVATE_IP_RANGES as $range) { + [$networkIp, $netmask] = explode('/', $range); + + $binaryNetwork = @inet_pton($networkIp); + if (false === $binaryNetwork) { + continue; + } + + $maskLen = (int) $netmask; + $numBytes = (int) ($maskLen / 8); + $numBits = $maskLen % 8; + + if (substr($binaryIp, 0, $numBytes) !== substr($binaryNetwork, 0, $numBytes)) { + continue; + } + + if ($numBits > 0) { + $mask = 0xFF << (8 - $numBits); + if ((ord($binaryIp[$numBytes]) & $mask) !== (ord($binaryNetwork[$numBytes]) & $mask)) { + continue; + } + } + + return true; + } + + return false; + } + + /** + * Checks if the given URL is allowed based on the allowed private addresses list. + * Returns true if the URL is either public or in the allowed private addresses list. + */ + public function isAllowedUrl(string $url): bool + { + try { + // If it's not a private URL, it's allowed + if (!$this->isPrivateUrl($url)) { + return true; + } + + // If no allowed private addresses are set, all private URLs are forbidden + if (empty($this->allowedPrivateAddresses)) { + return false; + } + + $parsedUrl = parse_url($url); + if (!isset($parsedUrl['host'])) { + throw new \InvalidArgumentException('Invalid URL format'); + } + + $host = strtolower($parsedUrl['host']); + + // Check if the host is directly in allowed addresses + if (in_array($host, $this->allowedPrivateAddresses, true)) { + return true; + } + + // Handle IPv6 addresses with brackets + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); // Remove brackets + } + + // If the host is an IP address, check if it's in allowed addresses + if (filter_var($host, FILTER_VALIDATE_IP)) { + return in_array($host, $this->allowedPrivateAddresses, true); + } + + // Resolve hostname to IPs and check if any are in allowed addresses + $ips = ($this->dnsResolver)($host); + if (false === $ips) { + throw new \InvalidArgumentException('Could not resolve hostname'); + } + + foreach ($ips as $ip) { + if (in_array($ip, $this->allowedPrivateAddresses, true)) { + return true; + } + } + + return false; + } catch (\Exception $e) { + throw new \InvalidArgumentException('URL validation failed: '.$e->getMessage()); + } + } +}
app/bundles/CoreBundle/Tests/Helper/PrivateAddressCheckerTest.php+220 −0 added@@ -0,0 +1,220 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\CoreBundle\Tests\Helper; + +use Mautic\CoreBundle\Helper\PrivateAddressChecker; +use PHPUnit\Framework\TestCase; + +class PrivateAddressCheckerTest extends TestCase +{ + private PrivateAddressChecker $checker; + private PrivateAddressChecker $checkerWithMockedDns; + + protected function setUp(): void + { + // Regular checker for IP tests + $this->checker = new PrivateAddressChecker(); + + // Checker with mocked DNS resolver for URL tests + $this->checkerWithMockedDns = new PrivateAddressChecker( + function (string $host) { + return match ($host) { + 'private.example.com' => ['192.168.1.1'], + 'public.example.com' => ['203.0.113.1'], + 'api.example.com' => ['8.8.8.8'], + 'localhost' => ['127.0.0.1'], + default => false, + }; + } + ); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('privateIpProvider')] + public function testIsPrivateIpReturnsTrue(string $ip): void + { + $this->assertTrue($this->checker->isPrivateIp($ip)); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('publicIpProvider')] + public function testIsPrivateIpReturnsFalse(string $ip): void + { + $this->assertFalse($this->checker->isPrivateIp($ip)); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('privateUrlProvider')] + public function testIsPrivateUrlReturnsTrue(string $url): void + { + $this->assertTrue($this->checkerWithMockedDns->isPrivateUrl($url)); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('publicUrlProvider')] + public function testIsPrivateUrlReturnsFalse(string $url): void + { + $this->assertFalse($this->checkerWithMockedDns->isPrivateUrl($url)); + } + + /** + * @return array<string, array{string}> + */ + public static function privateIpProvider(): array + { + return [ + 'IPv4 Local' => ['127.0.0.1'], + 'IPv4 Private Class A' => ['10.0.0.1'], + 'IPv4 Private Class B' => ['172.16.0.1'], + 'IPv4 Private Class C' => ['192.168.0.1'], + 'IPv4 Link Local' => ['169.254.0.1'], + 'IPv6 Localhost' => ['::1'], + 'IPv6 Unique Local' => ['fc00::1'], + 'IPv6 Link Local' => ['fe80::1'], + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function publicIpProvider(): array + { + return [ + 'IPv4 Public' => ['8.8.8.8'], + 'IPv4 Public Alternative' => ['203.0.113.1'], + 'IPv6 Public' => ['2001:4860:4860::8888'], + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function privateUrlProvider(): array + { + return [ + 'Localhost' => ['http://localhost'], + 'Localhost with port' => ['http://localhost:8080'], + 'IPv4 Private' => ['http://192.168.1.1'], + 'IPv4 Private with path' => ['https://10.0.0.1/path'], + 'IPv6 Localhost' => ['http://[::1]'], + 'IPv6 Private' => ['http://[fc00::1]'], + 'Domain resolving to private IP' => ['http://private.example.com'], + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function publicUrlProvider(): array + { + return [ + 'Public Domain' => ['http://public.example.com'], + 'IPv4 Public' => ['http://8.8.8.8'], + 'IPv6 Public' => ['http://[2001:4860:4860::8888]'], + 'HTTPS URL' => ['https://api.example.com/v1'], + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function invalidUrlProvider(): array + { + return [ + 'Empty URL' => [''], + 'Invalid Format' => ['not-a-url'], + 'Missing Protocol' => ['example.com'], + 'Invalid Characters' => ['http://example.com\\invalid'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidUrlProvider')] + public function testIsPrivateUrlThrowsExceptionForInvalidUrls(string $url): void + { + $this->expectException(\InvalidArgumentException::class); + $this->checkerWithMockedDns->isPrivateUrl($url); + } + + public function testUnresolvableHostname(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('URL validation failed: Could not resolve hostname'); + $this->checkerWithMockedDns->isPrivateUrl('http://unresolvable.example.com'); + } + + public function testIsPrivateIpWithInvalidIPFormat(): void + { + $this->assertFalse($this->checker->isPrivateIp('invalid-ip')); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('edgeCaseUrlProvider')] + public function testEdgeCaseUrls(string $url, bool $expectedResult): void + { + $this->assertEquals($expectedResult, $this->checkerWithMockedDns->isPrivateUrl($url)); + } + + /** + * @return array<string, array{string, bool}> + */ + public static function edgeCaseUrlProvider(): array + { + return [ + 'URL with Port' => ['http://[::1]:8080', true], + 'IPv6 with Zone Index' => ['http://[fe80::1%eth0]', true], + 'IPv6 Full Format' => ['http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]', false], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('allowedUrlProvider')] + public function testIsAllowedUrlReturnsTrue(string $url): void + { + $this->checkerWithMockedDns->setAllowedPrivateAddresses(['192.168.1.1', 'localhost', '::1']); + $this->assertTrue($this->checkerWithMockedDns->isAllowedUrl($url)); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('disallowedUrlProvider')] + public function testIsAllowedUrlReturnsFalse(string $url): void + { + $this->checkerWithMockedDns->setAllowedPrivateAddresses(['192.168.1.2', '10.0.0.1']); + $this->assertFalse($this->checkerWithMockedDns->isAllowedUrl($url)); + } + + public function testIsAllowedUrlWithEmptyAllowedAddresses(): void + { + $this->checkerWithMockedDns->setAllowedPrivateAddresses([]); + $this->assertTrue($this->checkerWithMockedDns->isAllowedUrl('http://public.example.com')); + $this->assertFalse($this->checkerWithMockedDns->isAllowedUrl('http://private.example.com')); + } + + /** + * @return array<string, array{string}> + */ + public static function allowedUrlProvider(): array + { + return [ + 'Public Domain' => ['http://public.example.com'], + 'Allowed Private IP' => ['http://192.168.1.1'], + 'Allowed Localhost' => ['http://localhost'], + 'Allowed IPv6 Localhost' => ['http://[::1]'], + 'Domain to Allowed Private IP' => ['http://private.example.com'], // Resolves to 192.168.1.1 + ]; + } + + /** + * @return array<string, array{string}> + */ + public static function disallowedUrlProvider(): array + { + return [ + 'Disallowed Private IP' => ['http://192.168.1.1'], + 'Different Private IP' => ['http://192.168.1.3'], + 'Domain to Disallowed Private IP' => ['http://private.example.com'], + 'Localhost when not allowed' => ['http://localhost'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidUrlProvider')] + public function testIsAllowedUrlThrowsExceptionForInvalidUrls(string $url): void + { + $this->expectException(\InvalidArgumentException::class); + $this->checkerWithMockedDns->isAllowedUrl($url); + } +}
app/bundles/WebhookBundle/Assets/js/webhook.js+9 −4 modified@@ -30,15 +30,20 @@ Mautic.sendHookTest = function() { type: 'POST', dataType: "json", success: function(response) { - if (response.success) { + if (response.html) { mQuery('#tester').html(response.html); } }, - error: function (request, textStatus, errorThrown) { - Mautic.processAjaxError(request, textStatus, errorThrown); + error: function (response, textStatus, errorThrown) { + console.log(response.responseJSON); + if (response.responseJSON.html) { + mQuery('#tester').html(response.responseJSON.html); + } else { + Mautic.processAjaxError(response, textStatus, errorThrown); + } }, complete: function(response) { spinner.addClass('hide'); } }) -}; \ No newline at end of file +};
app/bundles/WebhookBundle/Config/config.php+1 −7 modified@@ -61,13 +61,6 @@ 'event_dispatcher', ], ], - 'mautic.webhook.http.client' => [ - 'class' => Mautic\WebhookBundle\Http\Client::class, - 'arguments' => [ - 'mautic.helper.core_parameters', - 'mautic.http.client', - ], - ], ], ], @@ -81,5 +74,6 @@ 'queue_mode' => Mautic\WebhookBundle\Model\WebhookModel::IMMEDIATE_PROCESS, // Trigger the webhook immediately or queue it for faster response times 'events_orderby_dir' => Doctrine\Common\Collections\Order::Ascending->value, // Order the queued events chronologically or the other way around 'webhook_email_details' => true, // If enabled, email related webhooks send detailed data + 'webhook_allowed_private_addresses' => [], ], ];
app/bundles/WebhookBundle/Controller/AjaxController.php+70 −40 modified@@ -5,56 +5,86 @@ use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController; use Mautic\CoreBundle\Helper\InputHelper; use Mautic\CoreBundle\Helper\PathsHelper; +use Mautic\WebhookBundle\Exception\PrivateAddressException; use Mautic\WebhookBundle\Http\Client; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; class AjaxController extends CommonAjaxController { - public function sendHookTestAction(Request $request, Client $client, PathsHelper $pathsHelper): \Symfony\Component\HttpFoundation\JsonResponse + public function sendHookTestAction(Request $request, Client $client, PathsHelper $pathsHelper): JsonResponse { - $url = InputHelper::url($request->request->get('url')); - - // validate the URL - if ('' == $url || !$url) { - // default to an error message - $dataArray = [ - 'success' => 1, - 'html' => '<div class="has-error"><span class="help-block">' - .$this->translator->trans('mautic.webhook.label.no.url') - .'</span></div>', - ]; - - return $this->sendJsonResponse($dataArray); + try { + return $this->processWebhookTest($request, $client, $pathsHelper); + } catch (PrivateAddressException) { + return $this->createErrorResponse( + 'mautic.webhook.error.private_address' + ); + } catch (\Exception) { + return $this->createErrorResponse( + 'mautic.webhook.label.warning' + ); } + } - // get the selected types - $selectedTypes = InputHelper::cleanArray($request->request->all()['types']) ?? []; - $payloadPaths = $this->getPayloadPaths($selectedTypes, $pathsHelper); - $payloads = $this->loadPayloads($payloadPaths); - $now = new \DateTime(); - - $payloads['timestamp'] = $now->format('c'); - - // set the response - $response = $client->post($url, $payloads, InputHelper::string($request->request->get('secret'))); - - // default to an error message - $dataArray = [ - 'success' => 1, - 'html' => '<div class="has-error"><span class="help-block">' - .$this->translator->trans('mautic.webhook.label.warning') - .'</span></div>', - ]; - - // if we get a 2xx response convert to success message - if (2 == substr((string) $response->getStatusCode(), 0, 1)) { - $dataArray['html'] = - '<div class="has-success"><span class="help-block">' - .$this->translator->trans('mautic.webhook.label.success') - .'</span></div>'; + private function processWebhookTest(Request $request, Client $client, PathsHelper $pathsHelper): JsonResponse + { + $url = $this->validateUrl($request); + if (!$url) { + return $this->createErrorResponse('mautic.webhook.label.no.url'); } - return $this->sendJsonResponse($dataArray); + $selectedTypes = InputHelper::cleanArray($request->request->all()['types']) ?? []; + $payloadPaths = $this->getPayloadPaths($selectedTypes, $pathsHelper); + $payload = $this->loadPayloads($payloadPaths); + $payload['timestamp'] = (new \DateTimeImmutable())->format('c'); + $secret = InputHelper::string($request->request->get('secret')); + + $response = $client->post($url, $payload, $secret); + + return $this->createResponseFromStatusCode($response->getStatusCode()); + } + + private function validateUrl(Request $request): ?string + { + $url = InputHelper::url($request->request->get('url')); + + return '' !== $url ? $url : null; + } + + private function createResponseFromStatusCode(int $statusCode): JsonResponse + { + $isSuccess = str_starts_with((string) $statusCode, '2'); + $message = $isSuccess + ? 'mautic.webhook.label.success' + : 'mautic.webhook.label.warning'; + + $cssClass = $isSuccess ? 'has-success' : 'has-error'; + + return $this->createJsonResponse($message, $cssClass); + } + + private function createErrorResponse(string $message): JsonResponse + { + return $this->createJsonResponse($message, 'has-error', Response::HTTP_BAD_REQUEST); + } + + private function createJsonResponse( + string $message, + string $cssClass, + int $status = Response::HTTP_OK, + ): JsonResponse { + $html = sprintf( + '<div class="%s"><span class="help-block">%s</span></div>', + $cssClass, + $this->translator->trans($message) + ); + + return $this->sendJsonResponse( + ['html' => $html], + $status + ); } /*
app/bundles/WebhookBundle/Exception/PrivateAddressException.php+15 −0 added@@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\WebhookBundle\Exception; + +class PrivateAddressException extends \Exception +{ + private const DEFAULT_MESSAGE = 'Access to private addresses is not allowed.'; + + public function __construct(string $message = self::DEFAULT_MESSAGE, int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } +}
app/bundles/WebhookBundle/Form/Type/ConfigType.php+19 −0 modified@@ -3,9 +3,11 @@ namespace Mautic\WebhookBundle\Form\Type; use Doctrine\Common\Collections\Order; +use Mautic\CoreBundle\Form\DataTransformer\ArrayLinebreakTransformer; use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Validator\Constraints\NotBlank; @@ -61,6 +63,23 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], ] ); + + $builder->add( + $builder->create( + 'webhook_allowed_private_addresses', + TextareaType::class, + [ + 'label' => 'mautic.webhook.config.allowed_private_addresses', + 'label_attr' => ['class' => 'control-label'], + 'attr' => [ + 'class' => 'form-control', + 'tooltip' => 'mautic.webhook.config.allowed_private_addresses.tooltip', + 'rows' => 8, + ], + 'required' => false, + ] + )->addViewTransformer(new ArrayLinebreakTransformer()) + ); } public function getBlockPrefix(): string
app/bundles/WebhookBundle/Http/Client.php+12 −5 modified@@ -2,19 +2,19 @@ namespace Mautic\WebhookBundle\Http; +use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Psr7\Request; -use Http\Adapter\Guzzle7\Client as GuzzleClient; use Mautic\CoreBundle\Helper\CoreParametersHelper; +use Mautic\CoreBundle\Helper\PrivateAddressChecker; +use Mautic\WebhookBundle\Exception\PrivateAddressException; use Psr\Http\Message\ResponseInterface; class Client { - /** - * @param GuzzleClient $httpClient - */ public function __construct( private CoreParametersHelper $coreParametersHelper, - private $httpClient, + private GuzzleClient $httpClient, + private PrivateAddressChecker $privateAddressChecker, ) { } @@ -31,6 +31,13 @@ public function post($url, array $payload, ?string $secret = null): ResponseInte 'Webhook-Signature' => $signature, ]; + $allowedPrivateAddresses = $this->coreParametersHelper->get('webhook_allowed_private_addresses'); + $this->privateAddressChecker->setAllowedPrivateAddresses($allowedPrivateAddresses); + + if (!$this->privateAddressChecker->isAllowedUrl($url)) { + throw new PrivateAddressException(); + } + return $this->httpClient->sendRequest(new Request('POST', $url, $headers, $jsonPayload)); } }
app/bundles/WebhookBundle/Tests/Functional/Command/ProcessWebhookQueuesCommandTest.php+2 −2 modified@@ -29,7 +29,7 @@ protected function setUp(): void public function testCommand(): void { - $webhook = $this->createWebhook('test', 'http://domain.tld', 'secret'); + $webhook = $this->createWebhook('test', 'https://httpbin.org/post', 'secret'); $event = $this->createWebhookEvent($webhook, 'Type'); $handlerStack = static::getContainer()->get(MockHandler::class); $queueIds = []; @@ -42,7 +42,7 @@ public function testCommand(): void $handlerStack->append( function (RequestInterface $request) { Assert::assertSame('POST', $request->getMethod()); - Assert::assertSame('http://domain.tld', $request->getUri()->__toString()); + Assert::assertSame('https://httpbin.org/post', $request->getUri()->__toString()); return new Response(SymfonyResponse::HTTP_OK); }
app/bundles/WebhookBundle/Tests/Functional/WebhookFunctionalTest.php+2 −2 modified@@ -52,7 +52,7 @@ public function testWebhookWorkflowWithCommandProcess(): void // One resource is going to be found in the Transifex project: $handlerStack->append( function (RequestInterface $request) use (&$sendRequestCounter) { - Assert::assertSame('://whatever.url', $request->getUri()->getPath()); + Assert::assertSame('/post', $request->getUri()->getPath()); $jsonPayload = json_decode($request->getBody()->getContents(), true); Assert::assertCount(3, $jsonPayload['mautic.lead_post_save_new']); Assert::assertNotEmpty($request->getHeader('Webhook-Signature')); @@ -93,7 +93,7 @@ private function createWebhook(): Webhook $webhook->addEvent($event); $webhook->setName('Webhook from a functional test'); - $webhook->setWebhookUrl('https:://whatever.url'); + $webhook->setWebhookUrl('https://httpbin.org/post'); $webhook->setSecret('any_secret_will_do'); $webhook->isPublished(true); $webhook->setCreatedBy(1);
app/bundles/WebhookBundle/Tests/Unit/Http/ClientTest.php+27 −7 modified@@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use Mautic\CoreBundle\Helper\CoreParametersHelper; +use Mautic\CoreBundle\Helper\PrivateAddressChecker; use Mautic\WebhookBundle\Http\Client; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -30,32 +31,51 @@ protected function setUp(): void $this->parametersMock = $this->createMock(CoreParametersHelper::class); $this->httpClientMock = $this->createMock(GuzzleClient::class); - $this->client = new Client($this->parametersMock, $this->httpClientMock); + $this->client = new Client($this->parametersMock, $this->httpClientMock, new PrivateAddressChecker()); } public function testPost(): void { $method = 'POST'; - $url = 'url'; + $url = 'https://8.8.8.8'; $payload = ['payload']; + $secret = 'secret123'; $siteUrl = 'siteUrl'; + + // Calculate the expected signature the same way as the Client class + $jsonPayload = json_encode($payload); + $expectedSignature = base64_encode(hash_hmac('sha256', $jsonPayload, $secret, true)); + $headers = [ 'Content-Type' => 'application/json', 'X-Origin-Base-URL' => $siteUrl, + 'Webhook-Signature' => $expectedSignature, ]; $response = new Response(); - $this->parametersMock->expects($this->once()) + $matcher = $this->exactly(2); + $this->parametersMock->expects($matcher) ->method('get') - ->with('site_url') - ->willReturn($siteUrl); + ->willReturnCallback(function (string $parameter) use ($matcher, $siteUrl) { + if (1 === $matcher->numberOfInvocations()) { + $this->assertSame('site_url', $parameter); + + return $siteUrl; + } + if (2 === $matcher->numberOfInvocations()) { + $this->assertSame('webhook_allowed_private_addresses', $parameter); + + return []; + } + throw new \RuntimeException('Unexpected method call'); + }); $this->httpClientMock->expects($this->once()) ->method('sendRequest') ->with($this->callback(function (Request $request) use ($method, $url, $headers, $payload) { $this->assertSame($method, $request->getMethod()); - $this->assertSame($url, $request->getUri()->getPath()); + $this->assertSame($url, (string) $request->getUri()); foreach ($headers as $headerName => $headerValue) { $header = $request->getHeader($headerName); @@ -68,6 +88,6 @@ public function testPost(): void })) ->willReturn($response); - $this->assertEquals($response, $this->client->post($url, $payload)); + $this->assertEquals($response, $this->client->post($url, $payload, $secret)); } }
app/bundles/WebhookBundle/Translations/en_US/messages.ini+3 −0 modified@@ -5,6 +5,7 @@ mautic.webhook.config.form.queue.mode.tooltip="Select how to process webhook eve mautic.webhook.config.form.queue.mode="Queue Mode" mautic.webhook.config.immediate_process="Process Events Immediately" mautic.webhook.error.notfound="Webhook Not Found" +mautic.webhook.error.private_address="The URL you provided for the webhook is in a private IP address range, which is not allowed by default. Please provide a URL that points to a publicly accessible server, or add the address to the whitelist in the system settings if you need to use a private IP." mautic.webhook.form.confirmbatchdelete="Delete the selected Webhooks?" mautic.webhook.form.confirmdelete="Delete the Webhook, %name%?" mautic.webhook.form.description="Webhook Description" @@ -57,3 +58,5 @@ mautic.webhook.card.interesting="You may also find interesting" mautic.webhook.card.image.alt="A conceptual image symbolizing webhooks, featuring interconnected blue and purple crystalline structures that represent data packets being automatically sent between applications." mautic.webhook.card.heading="Send data from your account into another system or database" mautic.webhook.card.copy="Event driven webhooks allow you to update 3rd party applications with contact and campaign activity as they happen, using an API that can listen for webhooks." +mautic.webhook.config.allowed_private_addresses="Allowed private addresses (one per line)" +mautic.webhook.config.allowed_private_addresses.tooltip="Specify a list of private IPs or domains that are permitted for webhooks. This allows webhooks to interact with internal services securely."
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-hj6f-7hp7-xg69ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-9821ghsaADVISORY
- github.com/mautic/mautic/commit/6084f6de4c88d1aeb5f6c73ea4fe1b09c98ea52bghsaWEB
- github.com/mautic/mautic/commit/dc5bb1466c9a48fd34768dc8ff5888716b2916baghsaWEB
- github.com/mautic/mautic/security/advisories/GHSA-hj6f-7hp7-xg69nvdWEB
News mentions
0No linked articles in our index yet.