CVE-2024-28235 (published 2024-04-09T16:15Z)

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.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/contao/contao
commit_details: repo_url: https://github.com/contao/contao
author: Yanick Witschi
author_date: 2024-04-09 07:19:52+02:00
msg: 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>
repository: repo_url: https://github.com/contao/contao
repo_name: contao/contao
description: Contao Open Source CMS
repo_language: PHP
owner: contao
file_changes: filename: Factory.php
change_type: ModificationType.MODIFY
diff: @@ -17,7 +17,10 @@ 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;
@@ -36,6 +39,7 @@ class Factory
 
     private Connection $connection;
     private ContaoFramework $framework;
+    private RequestStack $requestStack;
     private array $defaultHttpClientOptions;
 
     /**
@@ -48,12 +52,19 @@ class Factory
      */
     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
@@ -163,18 +174,77 @@ class Factory
 
     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

programming_language: PHP
filename: ContaoCoreExtension.php
change_type: ModificationType.MODIFY
diff: @@ -277,8 +277,8 @@ class ContaoCoreExtension extends Extension implements PrependExtensionInterface
         }
 
         $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']);
     }
 
     /**

programming_language: PHP
filename: services.yml
change_type: ModificationType.MODIFY
diff: @@ -82,6 +82,7 @@ services:
         arguments:
             - '@database_connection'
             - '@contao.framework'
+            - '@request_stack'
 
     contao.crawl.escargot.search_index_subscriber:
         class: Contao\CoreBundle\Crawl\Escargot\Subscriber\SearchIndexSubscriber

programming_language: CoffeeScript
filename: Crawl.php
change_type: ModificationType.MODIFY
diff: @@ -125,12 +125,9 @@ class Crawl extends Backend implements MaintenanceModuleInterface
 			}
 			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

programming_language: PHP
filename: FactoryTest.php
change_type: ModificationType.MODIFY
diff: @@ -18,6 +18,10 @@ 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;
 
@@ -37,7 +41,7 @@ class FactoryTest extends TestCase
             ->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);
 
@@ -65,6 +69,7 @@ class FactoryTest extends TestCase
         $factory = new Factory(
             $this->createMock(Connection::class),
             $this->mockContaoFramework([PageModel::class => $pageModelAdapter]),
+            new RequestStack(),
             ['https://example.com']
         );
 
@@ -87,7 +92,7 @@ class FactoryTest extends TestCase
             ->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')]);
@@ -110,7 +115,7 @@ class FactoryTest extends TestCase
             ->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();
@@ -126,4 +131,83 @@ class FactoryTest extends TestCase
         $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());
+    }
 }

programming_language: PHP
filename: ContaoCoreExtensionTest.php
change_type: ModificationType.MODIFY
diff: @@ -338,8 +338,8 @@ class ContaoCoreExtensionTest extends TestCase
 
         $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

programming_language: PHP
repo_url: https://github.com/contao/contao
commit_details: repo_url: https://github.com/contao/contao
author: Yanick Witschi
author_date: 2024-04-09 07:19:52+02:00
msg: 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>
repository: repo_url: https://github.com/contao/contao
repo_name: contao/contao
description: Contao Open Source CMS
repo_language: PHP
owner: contao
file_changes: filename: services.yaml
change_type: ModificationType.MODIFY
diff: @@ -76,6 +76,7 @@ services:
             - '@database_connection'
             - '@contao.framework'
             - '@contao.routing.content_url_generator'
+            - '@request_stack'
 
     contao.crawl.escargot.search_index_subscriber:
         class: Contao\CoreBundle\Crawl\Escargot\Subscriber\SearchIndexSubscriber

programming_language: CoffeeScript
filename: Crawl.php
change_type: ModificationType.MODIFY
diff: @@ -128,12 +128,9 @@ class Crawl extends Backend implements MaintenanceModuleInterface
 			}
 			else
 			{
+				// TODO: we need a way to authenticate with a token instead of our own cookie
 				$session = System::getContainer()->get('request_stack')->getSession();
 				$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

programming_language: PHP
filename: Factory.php
change_type: ModificationType.MODIFY
diff: @@ -18,7 +18,10 @@ use Contao\CoreBundle\Routing\ContentUrlGenerator;
 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\Routing\Exception\ExceptionInterface;
 use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
 use Symfony\Component\Uid\Uuid;
@@ -43,15 +46,24 @@ class Factory
     private array $subscribers = [];
 
     /**
-     * @param array<string> $additionalUris
+     * @var \Closure(array<string, mixed>): HttpClientInterface|null
+     */
+    private readonly \Closure|null $httpClientFactory;
+
+    /**
+     * @param array<string>                                            $additionalUris
+     * @param \Closure(array<string, mixed>): HttpClientInterface|null $httpClientFactory
      */
     public function __construct(
         private readonly Connection $connection,
         private readonly ContaoFramework $framework,
         private readonly ContentUrlGenerator $urlGenerator,
+        private readonly RequestStack $requestStack,
         private readonly array $additionalUris = [],
         private readonly array $defaultHttpClientOptions = [],
+        \Closure|null $httpClientFactory = null,
     ) {
+        $this->httpClientFactory = $httpClientFactory ?? static fn (array $defaultOptions) => HttpClient::create($defaultOptions);
     }
 
     public function addSubscriber(EscargotSubscriberInterface $subscriber): self
@@ -162,18 +174,77 @@ class Factory
 
     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
+        if ($this->requestStack->getMainRequest()?->hasSession()) {
+            $this->requestStack->getMainRequest()->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

programming_language: PHP
filename: ContaoCoreExtension.php
change_type: ModificationType.MODIFY
diff: @@ -322,8 +322,8 @@ class ContaoCoreExtension extends Extension implements PrependExtensionInterface
         }
 
         $factory = $container->getDefinition('contao.crawl.escargot.factory');
-        $factory->setArgument(3, $config['crawl']['additional_uris']);
-        $factory->setArgument(4, $config['crawl']['default_http_client_options']);
+        $factory->setArgument(4, $config['crawl']['additional_uris']);
+        $factory->setArgument(5, $config['crawl']['default_http_client_options']);
     }
 
     /**

programming_language: PHP
filename: FactoryTest.php
change_type: ModificationType.MODIFY
diff: @@ -19,6 +19,10 @@ 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 Symfony\Component\Routing\Generator\UrlGeneratorInterface;
 use Terminal42\Escargot\BaseUriCollection;
 use Terminal42\Escargot\Queue\InMemoryQueue;
@@ -39,7 +43,7 @@ class FactoryTest extends TestCase
             ->willReturn('subscriber-2')
         ;
 
-        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), $this->createMock(ContentUrlGenerator::class));
+        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), $this->createMock(ContentUrlGenerator::class), new RequestStack());
         $factory->addSubscriber($subscriber1);
         $factory->addSubscriber($subscriber2);
 
@@ -71,6 +75,7 @@ class FactoryTest extends TestCase
             $this->createMock(Connection::class),
             $this->mockContaoFramework([PageModel::class => $pageModelAdapter]),
             $urlGenerator,
+            new RequestStack(),
             ['https://example.com'],
         );
 
@@ -93,7 +98,7 @@ class FactoryTest extends TestCase
             ->willReturn('subscriber-1')
         ;
 
-        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), $this->createMock(ContentUrlGenerator::class));
+        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), $this->createMock(ContentUrlGenerator::class), new RequestStack());
         $factory->addSubscriber($subscriber1);
 
         $uriCollection = new BaseUriCollection([new Uri('https://contao.org')]);
@@ -116,7 +121,7 @@ class FactoryTest extends TestCase
             ->willReturn('subscriber-1')
         ;
 
-        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), $this->createMock(ContentUrlGenerator::class));
+        $factory = new Factory($this->createMock(Connection::class), $this->mockContaoFramework(), $this->createMock(ContentUrlGenerator::class), new RequestStack());
         $factory->addSubscriber($subscriber1);
 
         $queue = new InMemoryQueue();
@@ -132,4 +137,81 @@ class FactoryTest extends TestCase
         $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->mockClassWithProperties(PageModel::class, ['dns' => 'contao.org']);
+        $rootPage2 = $this->mockClassWithProperties(PageModel::class, ['dns' => 'contao.de']);
+
+        $urlGenerator = $this->createMock(ContentUrlGenerator::class);
+        $urlGenerator
+            ->method('generate')
+            ->willReturnCallback(static fn (PageModel $rootPage) => 'https://'.$rootPage->dns)
+        ;
+
+        $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]),
+            $urlGenerator,
+            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());
+    }
 }

programming_language: PHP
filename: ContaoCoreExtensionTest.php
change_type: ModificationType.MODIFY
diff: @@ -277,8 +277,8 @@ class ContaoCoreExtensionTest extends TestCase
 
         $definition = $container->getDefinition('contao.crawl.escargot.factory');
 
-        $this->assertSame(['https://example.com'], $definition->getArgument(3));
-        $this->assertSame(['proxy' => 'http://localhost:7080', 'headers' => ['Foo' => 'Bar']], $definition->getArgument(4));
+        $this->assertSame(['https://example.com'], $definition->getArgument(4));
+        $this->assertSame(['proxy' => 'http://localhost:7080', 'headers' => ['Foo' => 'Bar']], $definition->getArgument(5));
     }
 
     public function testConfiguresTheBackupManagerCorrectly(): void

programming_language: PHP