CVE-2024-28235: Insufficient Information (NVD-CWE-noinfo)
value: Contao is an open source content management system. Starting in version 4.9.0 and prior to versions 4.13.40 and 5.3.4, when checking for broken links on protected pages, Contao sends the cookie header to external urls as well, the passed  options for the http client are used for all requests. Contao versions 4.13.40 and 5.3.4 have a patch for this issue. As a workaround, disable crawling protected pages.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-04-09T16:15Z
Repository: https://github.com/contao/contao
Language: PHP
Files changed: core-bundle/src/Crawl/Escargot/Factory.php, core-bundle/src/DependencyInjection/ContaoCoreExtension.php, core-bundle/src/Resources/config/services.yml, core-bundle/src/Resources/contao/classes/Crawl.php, core-bundle/tests/Crawl/Escargot/FactoryTest.php, core-bundle/tests/DependencyInjection/ContaoCoreExtensionTest.php
Commit date: 2024-04-09 07:19:52 +0200
Commit message: Merge pull request from GHSA-9jh5-qf84-x6pr

* Ensure confidential headers are not leaked to external URIs when crawling

* CS

---------

Co-authored-by: Leo Feyer <1192057+leofeyer@users.noreply.github.com>
Diff: diff --git a/core-bundle/src/Crawl/Escargot/Factory.php b/core-bundle/src/Crawl/Escargot/Factory.php
index 547e0e3a25f..0b48413a2e7 100644
--- a/core-bundle/src/Crawl/Escargot/Factory.php
+++ b/core-bundle/src/Crawl/Escargot/Factory.php
@@ -15,11 +15,14 @@ namespace Contao\CoreBundle\Crawl\Escargot;
 use Contao\CoreBundle\Crawl\Escargot\Subscriber\EscargotSubscriberInterface;
 use Contao\CoreBundle\Framework\ContaoFramework;
 use Contao\PageModel;
 use Doctrine\DBAL\Connection;
 use Nyholm\Psr7\Uri;
+use Psr\Http\Message\UriInterface;
 use Symfony\Component\HttpClient\HttpClient;
+use Symfony\Component\HttpClient\ScopingHttpClient;
+use Symfony\Component\HttpFoundation\RequestStack;
 use Symfony\Component\Uid\Uuid;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Terminal42\Escargot\BaseUriCollection;
 use Terminal42\Escargot\Escargot;
 use Terminal42\Escargot\Exception\InvalidJobIdException;
@@ -34,10 +37,11 @@ class Factory
 {
     public const USER_AGENT = 'contao/crawler';
 
     private Connection $connection;
     private ContaoFramework $framework;
+    private RequestStack $requestStack;
     private array $defaultHttpClientOptions;
 
     /**
      * @var array<string>
      */
@@ -46,16 +50,23 @@ class Factory
     /**
      * @var array<EscargotSubscriberInterface>
      */
     private array $subscribers = [];
 
-    public function __construct(Connection $connection, ContaoFramework $framework, array $additionalUris = [], array $defaultHttpClientOptions = [])
+    /**
+     * @var \Closure(array<string, mixed>): HttpClientInterface|null
+     */
+    private ?\Closure $httpClientFactory;
+
+    public function __construct(Connection $connection, ContaoFramework $framework, RequestStack $requestStack, array $additionalUris = [], array $defaultHttpClientOptions = [], \Closure $httpClientFactory = null)
     {
         $this->connection = $connection;
         $this->framework = $framework;
+        $this->requestStack = $requestStack;
         $this->additionalUris = $additionalUris;
         $this->defaultHttpClientOptions = $defaultHttpClientOptions;
+        $this->httpClientFactory = $httpClientFactory ?? static fn (array $defaultOptions) => HttpClient::create($defaultOptions);
     }
 
     public function addSubscriber(EscargotSubscriberInterface $subscriber): self
     {
         $this->subscribers[] = $subscriber;
@@ -161,22 +172,81 @@ class Factory
         return $escargot;
     }
 
     private function createHttpClient(array $options = []): HttpClientInterface
     {
-        return HttpClient::create(
-            array_merge_recursive(
-                [
-                    'headers' => [
-                        'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
-                        'user-agent' => self::USER_AGENT,
-                    ],
-                    'max_duration' => 10, // Ignore requests that take longer than 10 seconds
+        $options = array_merge_recursive(
+            [
+                'headers' => [
+                    'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+                    'user-agent' => self::USER_AGENT,
                 ],
-                array_merge_recursive($this->getDefaultHttpClientOptions(), $options)
-            )
+                'max_duration' => 10, // Ignore requests that take longer than 10 seconds
+            ],
+            array_merge_recursive($this->getDefaultHttpClientOptions(), $options)
         );
+
+        // Make sure confidential headers force a scoped client so external domains do not leak data
+        $cleanOptions = $this->cleanOptionsFromConfidentialData($options);
+
+        if ($options === $cleanOptions) {
+            return ($this->httpClientFactory)($options);
+        }
+
+        $scopedOptionsByRegex = [];
+
+        // All options including the confidential headers for our root page collection
+        foreach ($this->getRootPageUriCollection()->all() as $rootPageUri) {
+            $scopedOptionsByRegex[preg_quote($this->getOriginFromUri($rootPageUri))] = $options;
+        }
+
+        // Closing the session is necessary here as otherwise we might run into our own session lock
+        $request = $this->requestStack->getMainRequest();
+
+        if ($request && $request->hasSession()) {
+            $request->getSession()->save();
+        }
+
+        return new ScopingHttpClient(($this->httpClientFactory)($cleanOptions), $scopedOptionsByRegex);
+    }
+
+    private function getOriginFromUri(UriInterface $uri): string
+    {
+        $origin = $uri->getScheme().'://'.$uri->getHost();
+
+        if ($uri->getPort()) {
+            $origin .= ':'.$uri->getPort();
+        }
+
+        return $origin.'/';
+    }
+
+    private function cleanOptionsFromConfidentialData(array $options): array
+    {
+        $cleanOptions = [];
+
+        foreach ($options as $k => $v) {
+            if ('headers' === $k) {
+                foreach ($v as $header => $value) {
+                    if (\in_array(strtolower($header), ['authorization', 'cookie'], true)) {
+                        continue;
+                    }
+
+                    $cleanOptions['headers'][$header] = $value;
+                }
+
+                continue;
+            }
+
+            if ('basic_auth' === $k || 'bearer_auth' === $k) {
+                continue;
+            }
+
+            $cleanOptions[$k] = $v;
+        }
+
+        return $cleanOptions;
     }
 
     private function registerDefaultSubscribers(Escargot $escargot): void
     {
         $escargot->addSubscriber(new RobotsSubscriber());
diff --git a/core-bundle/src/DependencyInjection/ContaoCoreExtension.php b/core-bundle/src/DependencyInjection/ContaoCoreExtension.php
index c17057103f4..0ef3a2fcb02 100644
--- a/core-bundle/src/DependencyInjection/ContaoCoreExtension.php
+++ b/core-bundle/src/DependencyInjection/ContaoCoreExtension.php
@@ -275,12 +275,12 @@ class ContaoCoreExtension extends Extension implements PrependExtensionInterface
         if (!$container->hasDefinition('contao.crawl.escargot.factory')) {
             return;
         }
 
         $factory = $container->getDefinition('contao.crawl.escargot.factory');
-        $factory->setArgument(2, $config['crawl']['additional_uris']);
-        $factory->setArgument(3, $config['crawl']['default_http_client_options']);
+        $factory->setArgument(3, $config['crawl']['additional_uris']);
+        $factory->setArgument(4, $config['crawl']['default_http_client_options']);
     }
 
     /**
      * Validates and sets the "contao.image.sizes" parameter.
      */
diff --git a/core-bundle/src/Resources/config/services.yml b/core-bundle/src/Resources/config/services.yml
index 2ff64a78e29..b49d5b490c3 100644
--- a/core-bundle/src/Resources/config/services.yml
+++ b/core-bundle/src/Resources/config/services.yml
@@ -80,10 +80,11 @@ services:
         class: Contao\CoreBundle\Crawl\Escargot\Factory
         public: true
         arguments:
             - '@database_connection'
             - '@contao.framework'
+            - '@request_stack'
 
     contao.crawl.escargot.search_index_subscriber:
         class: Contao\CoreBundle\Crawl\Escargot\Subscriber\SearchIndexSubscriber
         arguments:
             - '@contao.search.indexer'
diff --git a/core-bundle/src/Resources/contao/classes/Crawl.php b/core-bundle/src/Resources/contao/classes/Crawl.php
index 8d98e14a7bf..14dad9472b2 100644
--- a/core-bundle/src/Resources/contao/classes/Crawl.php
+++ b/core-bundle/src/Resources/contao/classes/Crawl.php
@@ -123,16 +123,13 @@ class Crawl extends Backend implements MaintenanceModuleInterface
 				$objAuthenticator->removeFrontendAuthentication();
 				$clientOptions = array();
 			}
 			else
 			{
+				// TODO: we need a way to authenticate with a token instead of our own cookie
 				$session = System::getContainer()->get('session');
 				$clientOptions = array('headers' => array('Cookie' => sprintf('%s=%s', $session->getName(), $session->getId())));
-
-				// Closing the session is necessary here as otherwise we run into our own session lock
-				// TODO: we need a way to authenticate with a token instead of our own cookie
-				$session->save();
 			}
 		}
 		else
 		{
 			$objAuthenticator->removeFrontendAuthentication();
diff --git a/core-bundle/tests/Crawl/Escargot/FactoryTest.php b/core-bundle/tests/Crawl/Escargot/FactoryTest.php
index cfea1c57dff..e1f240d0e7b 100644
--- a/core-bundle/tests/Crawl/Escargot/FactoryTest.php
+++ b/core-bundle/tests/Crawl/Escargot/FactoryTest.php
@@ -16,10 +16,14 @@ use Contao\CoreBundle\Crawl\Escargot\Factory;
 use Contao\CoreBundle\Crawl\Escargot\Subscriber\EscargotSubscriberInterface;
 use Contao\CoreBundle\Tests\TestCase;
 use Contao\PageModel;
 use Doctrine\DBAL\Connection;
 use Nyholm\Psr7\Uri;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\Component\HttpClient\ScopingHttpClient;
+use Symfony\Component\HttpFoundation\RequestStack;
 use Terminal42\Escargot\BaseUriCollection;
 use Terminal42\Escargot\Queue\InMemoryQueue;
 
 class FactoryTest extends TestCase
 {
@@ -35,11 +39,11 @@ class FactoryTest extends TestCase
         $subscriber2
             ->method('getName')
             ->willReturn('subscriber-2')
         ;
 
-        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework());
+        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), new RequestStack());
         $factory->addSubscriber($subscriber1);
         $factory->addSubscriber($subscriber2);
 
         $this->assertCount(2, $factory->getSubscribers());
         $this->assertCount(2, $factory->getSubscribers(['subscriber-1', 'subscriber-2']));
@@ -63,10 +67,11 @@ class FactoryTest extends TestCase
         ;
 
         $factory = new Factory(
             $this->createMock(Connection::class),
             $this->mockContaoFramework([PageModel::class => $pageModelAdapter]),
+            new RequestStack(),
             ['https://example.com']
         );
 
         $this->assertCount(1, $factory->getAdditionalCrawlUriCollection());
         $this->assertTrue($factory->getAdditionalCrawlUriCollection()->containsHost('example.com'));
@@ -85,11 +90,11 @@ class FactoryTest extends TestCase
         $subscriber1
             ->method('getName')
             ->willReturn('subscriber-1')
         ;
 
-        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework());
+        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), new RequestStack());
         $factory->addSubscriber($subscriber1);
 
         $uriCollection = new BaseUriCollection([new Uri('https://contao.org')]);
         $escargot = $factory->create($uriCollection, new InMemoryQueue(), ['subscriber-1']);
 
@@ -108,11 +113,11 @@ class FactoryTest extends TestCase
         $subscriber1
             ->method('getName')
             ->willReturn('subscriber-1')
         ;
 
-        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework());
+        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), new RequestStack());
         $factory->addSubscriber($subscriber1);
 
         $queue = new InMemoryQueue();
         $jobId = $queue->createJobId(new BaseUriCollection([new Uri('https://contao.org')]));
 
@@ -124,6 +129,85 @@ class FactoryTest extends TestCase
         $this->expectExceptionMessage('You have to specify at least one valid subscriber name. Valid subscribers are: subscriber-1');
 
         $escargot = $factory->createFromJobId($jobId, $queue, ['subscriber-8']);
         $this->assertSame(Factory::USER_AGENT, $escargot->getUserAgent());
     }
+
+    public function testScopesConfidentialHeadersAutomatically(): void
+    {
+        $expectedRequests = [
+            function (string $method, string $url, array $options): MockResponse {
+                $this->assertSame('GET', $method);
+                $this->assertSame('https://contao.org/robots.txt', $url);
+                $this->assertContains('Cookie: Confidential', $options['headers']);
+                $this->assertContains('Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=', $options['headers']);
+
+                return new MockResponse();
+            },
+            function (string $method, string $url, array $options): MockResponse {
+                $this->assertSame('GET', $method);
+                $this->assertSame('https://contao.de/robots.txt', $url);
+                $this->assertContains('Cookie: Confidential', $options['headers']);
+                $this->assertContains('Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=', $options['headers']);
+
+                return new MockResponse();
+            },
+            function (string $method, string $url, array $options): MockResponse {
+                $this->assertSame('GET', $method);
+                $this->assertSame('https://www.foreign-domain.com/robots.txt', $url);
+                $this->assertNotContains('Cookie: Confidential', $options['headers']);
+                $this->assertNotContains('Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=', $options['headers']);
+
+                return new MockResponse();
+            },
+        ];
+
+        $mockClient = new MockHttpClient($expectedRequests);
+        $clientFactory = static fn (array $defaultOptions) => $mockClient;
+
+        $rootPage1 = $this->createMock(PageModel::class);
+        $rootPage1
+            ->method('getAbsoluteUrl')
+            ->willReturn('https://contao.org')
+        ;
+
+        $rootPage2 = $this->createMock(PageModel::class);
+        $rootPage2
+            ->method('getAbsoluteUrl')
+            ->willReturn('https://contao.de')
+        ;
+
+        $pageModelAdapter = $this->mockAdapter(['findPublishedRootPages']);
+        $pageModelAdapter
+            ->method('findPublishedRootPages')
+            ->willReturn([$rootPage1, $rootPage2])
+        ;
+
+        $subscriber1 = $this->createMock(EscargotSubscriberInterface::class);
+        $subscriber1
+            ->method('getName')
+            ->willReturn('subscriber-1')
+        ;
+
+        $factory = new Factory(
+            $this->createMock(Connection::class),
+            $this->mockContaoFramework([PageModel::class => $pageModelAdapter]),
+            new RequestStack(),
+            ['https://www.foreign-domain.com'],
+            [
+                'headers' => [
+                    'Cookie' => 'Confidential',
+                ],
+                'auth_basic' => 'username:password',
+            ],
+            $clientFactory
+        );
+
+        $factory->addSubscriber($subscriber1);
+
+        $escargot = $factory->create($factory->getCrawlUriCollection(), new InMemoryQueue(), ['subscriber-1']);
+        $escargot->crawl();
+
+        $this->assertSame(3, $mockClient->getRequestsCount());
+        $this->assertInstanceOf(ScopingHttpClient::class, $escargot->getClient());
+    }
 }
diff --git a/core-bundle/tests/DependencyInjection/ContaoCoreExtensionTest.php b/core-bundle/tests/DependencyInjection/ContaoCoreExtensionTest.php
index c3f7eb5d29f..97d2a6a6c00 100644
--- a/core-bundle/tests/DependencyInjection/ContaoCoreExtensionTest.php
+++ b/core-bundle/tests/DependencyInjection/ContaoCoreExtensionTest.php
@@ -336,12 +336,12 @@ class ContaoCoreExtensionTest extends TestCase
             $container
         );
 
         $definition = $container->getDefinition('contao.crawl.escargot.factory');
 
-        $this->assertSame(['https://example.com'], $definition->getArgument(2));
-        $this->assertSame(['proxy' => 'http://localhost:7080', 'headers' => ['Foo' => 'Bar']], $definition->getArgument(3));
+        $this->assertSame(['https://example.com'], $definition->getArgument(3));
+        $this->assertSame(['proxy' => 'http://localhost:7080', 'headers' => ['Foo' => 'Bar']], $definition->getArgument(4));
     }
 
     public function testConfiguresTheBackupManagerCorrectly(): void
     {
         $container = $this->getContainerBuilder();

Vulnerable code:     public function __construct(Connection $connection, ContaoFramework $framework, array $additionalUris = [], array $defaultHttpClientOptions = [])
        return HttpClient::create(
            array_merge_recursive(
                [
                    'headers' => [
                        'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
                        'user-agent' => self::USER_AGENT,
                    ],
                    'max_duration' => 10, // Ignore requests that take longer than 10 seconds
                array_merge_recursive($this->getDefaultHttpClientOptions(), $options)
            )
        $factory->setArgument(2, $config['crawl']['additional_uris']);
        $factory->setArgument(3, $config['crawl']['default_http_client_options']);

				// Closing the session is necessary here as otherwise we run into our own session lock
				// TODO: we need a way to authenticate with a token instead of our own cookie
				$session->save();
        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework());
        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework());
        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework());
        $this->assertSame(['https://example.com'], $definition->getArgument(2));
        $this->assertSame(['proxy' => 'http://localhost:7080', 'headers' => ['Foo' => 'Bar']], $definition->getArgument(3));
Fixed code: use Psr\Http\Message\UriInterface;
use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpFoundation\RequestStack;
    private RequestStack $requestStack;
    /**
     * @var \Closure(array<string, mixed>): HttpClientInterface|null
     */
    private ?\Closure $httpClientFactory;

    public function __construct(Connection $connection, ContaoFramework $framework, RequestStack $requestStack, array $additionalUris = [], array $defaultHttpClientOptions = [], \Closure $httpClientFactory = null)
        $this->requestStack = $requestStack;
        $this->httpClientFactory = $httpClientFactory ?? static fn (array $defaultOptions) => HttpClient::create($defaultOptions);
        $options = array_merge_recursive(
            [
                'headers' => [
                    'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
                    'user-agent' => self::USER_AGENT,
                'max_duration' => 10, // Ignore requests that take longer than 10 seconds
            ],
            array_merge_recursive($this->getDefaultHttpClientOptions(), $options)

        // Make sure confidential headers force a scoped client so external domains do not leak data
        $cleanOptions = $this->cleanOptionsFromConfidentialData($options);

        if ($options === $cleanOptions) {
            return ($this->httpClientFactory)($options);
        }

        $scopedOptionsByRegex = [];

        // All options including the confidential headers for our root page collection
        foreach ($this->getRootPageUriCollection()->all() as $rootPageUri) {
            $scopedOptionsByRegex[preg_quote($this->getOriginFromUri($rootPageUri))] = $options;
        }

        // Closing the session is necessary here as otherwise we might run into our own session lock
        $request = $this->requestStack->getMainRequest();

        if ($request && $request->hasSession()) {
            $request->getSession()->save();
        }

        return new ScopingHttpClient(($this->httpClientFactory)($cleanOptions), $scopedOptionsByRegex);
    }

    private function getOriginFromUri(UriInterface $uri): string
    {
        $origin = $uri->getScheme().'://'.$uri->getHost();

        if ($uri->getPort()) {
            $origin .= ':'.$uri->getPort();
        }

        return $origin.'/';
    }

    private function cleanOptionsFromConfidentialData(array $options): array
    {
        $cleanOptions = [];

        foreach ($options as $k => $v) {
            if ('headers' === $k) {
                foreach ($v as $header => $value) {
                    if (\in_array(strtolower($header), ['authorization', 'cookie'], true)) {
                        continue;
                    }

                    $cleanOptions['headers'][$header] = $value;
                }

                continue;
            }

            if ('basic_auth' === $k || 'bearer_auth' === $k) {
                continue;
            }

            $cleanOptions[$k] = $v;
        }

        return $cleanOptions;
        $factory->setArgument(3, $config['crawl']['additional_uris']);
        $factory->setArgument(4, $config['crawl']['default_http_client_options']);
            - '@request_stack'
				// TODO: we need a way to authenticate with a token instead of our own cookie
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpFoundation\RequestStack;
        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), new RequestStack());
            new RequestStack(),
        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), new RequestStack());
        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), new RequestStack());

    public function testScopesConfidentialHeadersAutomatically(): void
    {
        $expectedRequests = [
            function (string $method, string $url, array $options): MockResponse {
                $this->assertSame('GET', $method);
                $this->assertSame('https://contao.org/robots.txt', $url);
                $this->assertContains('Cookie: Confidential', $options['headers']);
                $this->assertContains('Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=', $options['headers']);

                return new MockResponse();
            },
            function (string $method, string $url, array $options): MockResponse {
                $this->assertSame('GET', $method);
                $this->assertSame('https://contao.de/robots.txt', $url);
                $this->assertContains('Cookie: Confidential', $options['headers']);
                $this->assertContains('Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=', $options['headers']);

                return new MockResponse();
            },
            function (string $method, string $url, array $options): MockResponse {
                $this->assertSame('GET', $method);
                $this->assertSame('https://www.foreign-domain.com/robots.txt', $url);
                $this->assertNotContains('Cookie: Confidential', $options['headers']);
                $this->assertNotContains('Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=', $options['headers']);

                return new MockResponse();
            },
        ];

        $mockClient = new MockHttpClient($expectedRequests);
        $clientFactory = static fn (array $defaultOptions) => $mockClient;

        $rootPage1 = $this->createMock(PageModel::class);
        $rootPage1
            ->method('getAbsoluteUrl')
            ->willReturn('https://contao.org')
        ;

        $rootPage2 = $this->createMock(PageModel::class);
        $rootPage2
            ->method('getAbsoluteUrl')
            ->willReturn('https://contao.de')
        ;

        $pageModelAdapter = $this->mockAdapter(['findPublishedRootPages']);
        $pageModelAdapter
            ->method('findPublishedRootPages')
            ->willReturn([$rootPage1, $rootPage2])
        ;

        $subscriber1 = $this->createMock(EscargotSubscriberInterface::class);
        $subscriber1
            ->method('getName')
            ->willReturn('subscriber-1')
        ;

        $factory = new Factory(
            $this->createMock(Connection::class),
            $this->mockContaoFramework([PageModel::class => $pageModelAdapter]),
            new RequestStack(),
            ['https://www.foreign-domain.com'],
            [
                'headers' => [
                    'Cookie' => 'Confidential',
                ],
                'auth_basic' => 'username:password',
            ],
            $clientFactory
        );

        $factory->addSubscriber($subscriber1);

        $escargot = $factory->create($factory->getCrawlUriCollection(), new InMemoryQueue(), ['subscriber-1']);
        $escargot->crawl();

        $this->assertSame(3, $mockClient->getRequestsCount());
        $this->assertInstanceOf(ScopingHttpClient::class, $escargot->getClient());
    }
        $this->assertSame(['https://example.com'], $definition->getArgument(3));
        $this->assertSame(['proxy' => 'http://localhost:7080', 'headers' => ['Foo' => 'Bar']], $definition->getArgument(4));