diff --git a/core/assets/scaffold/files/default.settings.php b/core/assets/scaffold/files/default.settings.php index f43915bf11693ac786737ff836e3da862793aab3..800024129ec00a35bf9c19d39e5aa63a17828ef7 100644 --- a/core/assets/scaffold/files/default.settings.php +++ b/core/assets/scaffold/files/default.settings.php @@ -307,6 +307,20 @@ */ $settings['update_free_access'] = FALSE; +/** + * Fallback to HTTP for Update Manager. + * + * If your Drupal site fails to connect to updates.drupal.org using HTTPS to + * fetch Drupal core, module and theme update status, you may uncomment this + * setting and set it to TRUE to allow an insecure fallback to HTTP. Note that + * doing so will open your site up to a potential man-in-the-middle attack. You + * should instead attempt to resolve the issues before enabling this option. + * @see https://www.drupal.org/docs/system-requirements/php-requirements#openssl + * @see https://en.wikipedia.org/wiki/Man-in-the-middle_attack + * @see \Drupal\update\UpdateFetcher + */ +# $settings['update_fetch_with_http_fallback'] = TRUE; + /** * External access proxy settings: * diff --git a/core/modules/migrate_drupal/tests/fixtures/drupal6.php b/core/modules/migrate_drupal/tests/fixtures/drupal6.php index beab94a3a66c18706231b63e7fc02bfc759ad0ff..5918895b6fbffb885129076d3deeca600dd16d0e 100644 --- a/core/modules/migrate_drupal/tests/fixtures/drupal6.php +++ b/core/modules/migrate_drupal/tests/fixtures/drupal6.php @@ -47270,7 +47270,7 @@ 'name' => 'update', 'type' => 'module', 'owner' => '', - 'status' => '0', + 'status' => '1', 'throttle' => '0', 'bootstrap' => '0', 'schema_version' => '-1', diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php index 393540712053ec732379891b54b1f45ee2cd450c..6436b282b04fab0f43790ee87adefcd708b78c28 100644 --- a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php +++ b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php @@ -29,6 +29,7 @@ class Upgrade6Test extends MigrateUpgradeExecuteTestBase { 'book', 'forum', 'statistics', + 'update', ]; /** @@ -166,6 +167,7 @@ protected function getAvailablePaths() { 'System', 'Taxonomy', 'Text', + 'Update status', 'Upload', 'User', 'User Reference', diff --git a/core/modules/update/config/schema/update.source.schema.yml b/core/modules/update/config/schema/update.source.schema.yml new file mode 100644 index 0000000000000000000000000000000000000000..02777ac809e5596e05f5d43c4aa926b4d6af61b1 --- /dev/null +++ b/core/modules/update/config/schema/update.source.schema.yml @@ -0,0 +1,12 @@ +# Schema for the migration source plugins. + +migrate.source.update_settings: + type: migrate_source_sql + label: 'Drupal update settings' + mapping: + variables: + type: sequence + label: 'Variables' + sequence: + type: string + label: 'Variable' diff --git a/core/modules/update/migrations/update_settings.yml b/core/modules/update/migrations/update_settings.yml index 4bd4a2b60f6ad0b490a2ad30d9793859b91b7c6f..6525a30e33e73094fb57a7c2a3a68b09e6e3d181 100644 --- a/core/modules/update/migrations/update_settings.yml +++ b/core/modules/update/migrations/update_settings.yml @@ -5,7 +5,7 @@ migration_tags: - Drupal 7 - Configuration source: - plugin: variable + plugin: update_settings variables: - update_max_fetch_attempts - update_fetch_url diff --git a/core/modules/update/src/Controller/UpdateController.php b/core/modules/update/src/Controller/UpdateController.php index 6a8ec6abcbe8e8d295078ef34f42ae967e358e49..bf0f1684ecf6d54eb164abb2eb169d1f587bf89f 100644 --- a/core/modules/update/src/Controller/UpdateController.php +++ b/core/modules/update/src/Controller/UpdateController.php @@ -2,9 +2,11 @@ namespace Drupal\update\Controller; +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Render\RendererInterface; +use Drupal\update\UpdateFetcherInterface; use Drupal\update\UpdateManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; -use Drupal\Core\Controller\ControllerBase; /** * Controller routines for update routes. @@ -18,14 +20,28 @@ class UpdateController extends ControllerBase { */ protected $updateManager; + /** + * The renderer. + * + * @var \Drupal\Core\Render\RendererInterface + */ + protected $renderer; + /** * Constructs update status data. * * @param \Drupal\update\UpdateManagerInterface $update_manager * Update Manager Service. + * @param \Drupal\Core\Render\RendererInterface|null $renderer + * The renderer. */ - public function __construct(UpdateManagerInterface $update_manager) { + public function __construct(UpdateManagerInterface $update_manager, RendererInterface $renderer = NULL) { $this->updateManager = $update_manager; + if (is_null($renderer)) { + @trigger_error('The renderer service should be passed to UpdateController::__construct() since 9.1.0. This will be required in Drupal 10.0.0. See https://www.drupal.org/node/3179315', E_USER_DEPRECATED); + $renderer = \Drupal::service('renderer'); + } + $this->renderer = $renderer; } /** @@ -33,7 +49,8 @@ public function __construct(UpdateManagerInterface $update_manager) { */ public static function create(ContainerInterface $container) { return new static( - $container->get('update.manager') + $container->get('update.manager'), + $container->get('renderer') ); } @@ -50,6 +67,20 @@ public function updateStatus() { if ($available = update_get_available(TRUE)) { $this->moduleHandler()->loadInclude('update', 'compare.inc'); $build['#data'] = update_calculate_project_data($available); + + // @todo Consider using 'fetch_failures' from the 'update' collection + // in the key_value_expire service for this? + $fetch_failed = FALSE; + foreach ($build['#data'] as $project) { + if ($project['status'] === UpdateFetcherInterface::NOT_FETCHED) { + $fetch_failed = TRUE; + break; + } + } + if ($fetch_failed) { + $message = ['#theme' => 'update_fetch_error_message']; + $this->messenger()->addError($this->renderer->renderPlain($message)); + } } return $build; } diff --git a/core/modules/update/src/Form/UpdateManagerUpdate.php b/core/modules/update/src/Form/UpdateManagerUpdate.php index 6e23903322a0552b545f55b2144e49f92dd97e18..aeabd99a8311c9b2d00d813edee46483f40b543d 100644 --- a/core/modules/update/src/Form/UpdateManagerUpdate.php +++ b/core/modules/update/src/Form/UpdateManagerUpdate.php @@ -104,7 +104,13 @@ public function buildForm(array $form, FormStateInterface $form_state) { $form['project_downloads'] = ['#tree' => TRUE]; $this->moduleHandler->loadInclude('update', 'inc', 'update.compare'); $project_data = update_calculate_project_data($available); + + $fetch_failed = FALSE; foreach ($project_data as $name => $project) { + if ($project['status'] === UpdateFetcherInterface::NOT_FETCHED) { + $fetch_failed = TRUE; + } + // Filter out projects which are up to date already. if ($project['status'] == UpdateManagerInterface::CURRENT) { continue; @@ -245,6 +251,11 @@ public function buildForm(array $form, FormStateInterface $form_state) { } } + if ($fetch_failed) { + $message = ['#theme' => 'update_fetch_error_message']; + $this->messenger()->addError(\Drupal::service('renderer')->renderPlain($message)); + } + if (empty($projects)) { $form['message'] = [ '#markup' => $this->t('All of your projects are up to date.'), diff --git a/core/modules/update/src/Plugin/migrate/source/UpdateSettings.php b/core/modules/update/src/Plugin/migrate/source/UpdateSettings.php new file mode 100644 index 0000000000000000000000000000000000000000..400133847612e596ff724f6974cf77e72fd53f24 --- /dev/null +++ b/core/modules/update/src/Plugin/migrate/source/UpdateSettings.php @@ -0,0 +1,28 @@ +<?php + +namespace Drupal\update\Plugin\migrate\source; + +use Drupal\migrate_drupal\Plugin\migrate\source\Variable; + +/** + * Update settings source plugin. + * + * @MigrateSource( + * id = "update_settings", + * source_module = "update" + * ) + */ +class UpdateSettings extends Variable { + + /** + * {@inheritdoc} + */ + protected function values() { + $values = parent::values(); + if (empty($values['update_fetch_url']) || strpos($values['update_fetch_url'], 'http://updates.drupal.org/release-history') !== FALSE) { + $values['update_fetch_url'] = 'https://updates.drupal.org/release-history'; + } + return $values; + } + +} diff --git a/core/modules/update/src/UpdateFetcher.php b/core/modules/update/src/UpdateFetcher.php index 05cce763a67d38b4884e499c523a9be6a4cb02af..015b354a2c8d6b574932e0e45e0296a8df7011ac 100644 --- a/core/modules/update/src/UpdateFetcher.php +++ b/core/modules/update/src/UpdateFetcher.php @@ -4,6 +4,7 @@ use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\DependencyInjection\DependencySerializationTrait; +use Drupal\Core\Site\Settings; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\RequestException; @@ -17,7 +18,7 @@ class UpdateFetcher implements UpdateFetcherInterface { /** * URL to check for updates, if a given project doesn't define its own. */ - const UPDATE_DEFAULT_URL = 'http://updates.drupal.org/release-history'; + const UPDATE_DEFAULT_URL = 'https://updates.drupal.org/release-history'; /** * The fetch url configured in the update settings. @@ -40,6 +41,13 @@ class UpdateFetcher implements UpdateFetcherInterface { */ protected $httpClient; + /** + * Whether to use HTTP fallback if HTTPS fails. + * + * @var bool + */ + protected $withHttpFallback; + /** * Constructs a UpdateFetcher. * @@ -47,11 +55,18 @@ class UpdateFetcher implements UpdateFetcherInterface { * The config factory. * @param \GuzzleHttp\ClientInterface $http_client * A Guzzle client object. + * @param \Drupal\Core\Site\Settings|null $settings + * The settings instance. */ - public function __construct(ConfigFactoryInterface $config_factory, ClientInterface $http_client) { + public function __construct(ConfigFactoryInterface $config_factory, ClientInterface $http_client, Settings $settings = NULL) { $this->fetchUrl = $config_factory->get('update.settings')->get('fetch.url'); $this->httpClient = $http_client; $this->updateSettings = $config_factory->get('update.settings'); + if (is_null($settings)) { + @trigger_error('The settings service should be passed to UpdateFetcher::__construct() since 9.1.0. This will be required in Drupal 10.0.0. See https://www.drupal.org/node/3179315', E_USER_DEPRECATED); + $settings = \Drupal::service('settings'); + } + $this->withHttpFallback = $settings->get('update_fetch_with_http_fallback', FALSE); } /** @@ -59,6 +74,26 @@ public function __construct(ConfigFactoryInterface $config_factory, ClientInterf */ public function fetchProjectData(array $project, $site_key = '') { $url = $this->buildFetchUrl($project, $site_key); + return $this->doRequest($url, ['headers' => ['Accept' => 'text/xml']], $this->withHttpFallback); + } + + /** + * Applies a GET request with a possible HTTP fallback. + * + * This method falls back to HTTP in case there was some certificate + * problem. + * + * @param string $url + * The URL. + * @param array $options + * The guzzle client options. + * @param bool $with_http_fallback + * Should the function fall back to HTTP. + * + * @return string + * The body of the HTTP(S) request, or an empty string on failure. + */ + protected function doRequest(string $url, array $options, bool $with_http_fallback): string { $data = ''; try { $data = (string) $this->httpClient @@ -67,6 +102,10 @@ public function fetchProjectData(array $project, $site_key = '') { } catch (RequestException $exception) { watchdog_exception('update', $exception); + if ($with_http_fallback && strpos($url, "http://") === FALSE) { + $url = str_replace('https://', 'http://', $url); + return $this->doRequest($url, $options, FALSE); + } } return $data; } diff --git a/core/modules/update/templates/update-fetch-error-message.html.twig b/core/modules/update/templates/update-fetch-error-message.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..fd4a967e72566ce975e796f96950af6b39d90f8c --- /dev/null +++ b/core/modules/update/templates/update-fetch-error-message.html.twig @@ -0,0 +1,14 @@ +{# +/** + * @file + * Default theme implementation for the message when fetching data fails. + * + * Available variables: + * - error_message: A render array containing the appropriate error message. + * + * @see template_preprocess_update_fetch_error_message() + * + * @ingroup themeable + */ +#} +{{ error_message }} diff --git a/core/modules/update/tests/src/Kernel/Migrate/d6/MigrateUpdateConfigsTest.php b/core/modules/update/tests/src/Kernel/Migrate/d6/MigrateUpdateConfigsTest.php index 7132546aadae24d2b36633a496ef68fbb6c73547..cea75f1fce79afdee176fd000b7f9644c5206713 100644 --- a/core/modules/update/tests/src/Kernel/Migrate/d6/MigrateUpdateConfigsTest.php +++ b/core/modules/update/tests/src/Kernel/Migrate/d6/MigrateUpdateConfigsTest.php @@ -33,7 +33,7 @@ protected function setUp(): void { public function testUpdateSettings() { $config = $this->config('update.settings'); $this->assertIdentical(2, $config->get('fetch.max_attempts')); - $this->assertIdentical('http://updates.drupal.org/release-history', $config->get('fetch.url')); + $this->assertIdentical('https://updates.drupal.org/release-history', $config->get('fetch.url')); $this->assertIdentical('all', $config->get('notification.threshold')); $this->assertIdentical([], $config->get('notification.emails')); $this->assertIdentical(7, $config->get('check.interval_days')); diff --git a/core/modules/update/tests/src/Kernel/UpdateReportTest.php b/core/modules/update/tests/src/Kernel/UpdateReportTest.php index 69aab606a175ae70139ddeb028300906051788fa..522c89f93722dd2d1a1676e926b7a85a8c285e4a 100644 --- a/core/modules/update/tests/src/Kernel/UpdateReportTest.php +++ b/core/modules/update/tests/src/Kernel/UpdateReportTest.php @@ -2,7 +2,10 @@ namespace Drupal\Tests\update\Kernel; +use Drupal\Core\Link; +use Drupal\Core\Url; use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; /** * Tests update report functionality. @@ -12,6 +15,8 @@ */ class UpdateReportTest extends KernelTestBase { + use UserCreationTrait; + /** * {@inheritdoc} */ @@ -54,4 +59,72 @@ public function providerTemplatePreprocessUpdateReport() { ]; } + /** + * Tests the error message when failing to fetch data without dblog enabled. + * + * @see template_preprocess_update_fetch_error_message() + */ + public function testTemplatePreprocessUpdateFetchErrorMessageNoDblog() { + $build = [ + '#theme' => 'update_fetch_error_message', + ]; + $this->render($build); + $this->assertRaw('Failed to fetch available update data:<ul><li>See <a href="https://www.drupal.org/node/3170647">PHP OpenSSL requirements</a> in the Drupal.org handbook for possible reasons this could happen and what you can do to resolve them.</li><li>Check your local system logs for additional error messages.</li></ul>'); + + \Drupal::moduleHandler()->loadInclude('update', 'inc', 'update.report'); + $variables = []; + template_preprocess_update_fetch_error_message($variables); + $this->assertArrayHasKey('error_message', $variables); + $this->assertEquals('Failed to fetch available update data:', $variables['error_message']['message']['#markup']); + $this->assertArrayHasKey('documentation_link', $variables['error_message']['items']['#items']); + $this->assertArrayHasKey('logs', $variables['error_message']['items']['#items']); + $this->assertArrayNotHasKey('dblog', $variables['error_message']['items']['#items']); + } + + /** + * Tests the error message when failing to fetch data with dblog enabled. + * + * @see template_preprocess_update_fetch_error_message() + */ + public function testTemplatePreprocessUpdateFetchErrorMessageWithDblog() { + \Drupal::moduleHandler()->loadInclude('update', 'inc', 'update.report'); + + $this->enableModules(['dblog', 'user']); + $this->installEntitySchema('user'); + + // First, try as a normal user that can't access dblog. + $this->setUpCurrentUser(); + + $build = [ + '#theme' => 'update_fetch_error_message', + ]; + $this->render($build); + $this->assertRaw('Failed to fetch available update data:<ul><li>See <a href="https://www.drupal.org/node/3170647">PHP OpenSSL requirements</a> in the Drupal.org handbook for possible reasons this could happen and what you can do to resolve them.</li><li>Check your local system logs for additional error messages.</li></ul>'); + + $variables = []; + template_preprocess_update_fetch_error_message($variables); + $this->assertArrayHasKey('error_message', $variables); + $this->assertEquals('Failed to fetch available update data:', $variables['error_message']['message']['#markup']); + $this->assertArrayHasKey('documentation_link', $variables['error_message']['items']['#items']); + $this->assertArrayHasKey('logs', $variables['error_message']['items']['#items']); + $this->assertArrayNotHasKey('dblog', $variables['error_message']['items']['#items']); + + // Now, try as an admin that can access dblog. + $this->setUpCurrentUser([], ['access content', 'access site reports']); + + $this->render($build); + $this->assertRaw('Failed to fetch available update data:<ul><li>See <a href="https://www.drupal.org/node/3170647">PHP OpenSSL requirements</a> in the Drupal.org handbook for possible reasons this could happen and what you can do to resolve them.</li><li>Check'); + $dblog_url = Url::fromRoute('dblog.overview', [], ['query' => ['type' => ['update']]]); + $this->assertRaw(Link::fromTextAndUrl('your local system logs', $dblog_url)->toString()); + $this->assertRaw(' for additional error messages.</li></ul>'); + + $variables = []; + template_preprocess_update_fetch_error_message($variables); + $this->assertArrayHasKey('error_message', $variables); + $this->assertEquals('Failed to fetch available update data:', $variables['error_message']['message']['#markup']); + $this->assertArrayHasKey('documentation_link', $variables['error_message']['items']['#items']); + $this->assertArrayNotHasKey('logs', $variables['error_message']['items']['#items']); + $this->assertArrayHasKey('dblog', $variables['error_message']['items']['#items']); + } + } diff --git a/core/modules/update/tests/src/Unit/UpdateFetcherTest.php b/core/modules/update/tests/src/Unit/UpdateFetcherTest.php index e0a56928ca5e376cc0eeb008f8bcc309636ab048..c530848a2dfc101637058b8907974a7074e14a8b 100644 --- a/core/modules/update/tests/src/Unit/UpdateFetcherTest.php +++ b/core/modules/update/tests/src/Unit/UpdateFetcherTest.php @@ -2,15 +2,27 @@ namespace Drupal\Tests\update\Unit; +use Drupal\Core\Logger\LoggerChannelFactory; +use Drupal\Core\Logger\RfcLoggerTrait; +use Drupal\Core\Site\Settings; use Drupal\Tests\UnitTestCase; use Drupal\update\UpdateFetcher; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Response; +use Psr\Log\LoggerInterface; /** * Tests update functionality unrelated to the database. * + * @coversDefaultClass \Drupal\update\UpdateFetcher + * * @group update */ -class UpdateFetcherTest extends UnitTestCase { +class UpdateFetcherTest extends UnitTestCase implements LoggerInterface { + use RfcLoggerTrait; /** * The update fetcher to use. @@ -19,13 +31,68 @@ class UpdateFetcherTest extends UnitTestCase { */ protected $updateFetcher; + /** + * History of requests/responses. + * + * @var array + */ + protected $history = []; + + /** + * Mock HTTP client. + * + * @var \GuzzleHttp\ClientInterface + */ + protected $mockHttpClient; + + /** + * Mock config factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $mockConfigFactory; + + /** + * A test project to fetch with. + * + * @var array + */ + protected $testProject; + + /** + * @var array + */ + protected $logMessages = []; + /** * {@inheritdoc} */ protected function setUp(): void { - $config_factory = $this->getConfigFactoryStub(['update.settings' => ['fetch_url' => 'http://www.example.com']]); - $http_client_mock = $this->createMock('\GuzzleHttp\ClientInterface'); - $this->updateFetcher = new UpdateFetcher($config_factory, $http_client_mock); + parent::setUp(); + $this->mockConfigFactory = $this->getConfigFactoryStub(['update.settings' => ['fetch_url' => 'http://www.example.com']]); + $this->mockHttpClient = $this->createMock('\GuzzleHttp\ClientInterface'); + $settings = new Settings([]); + $this->updateFetcher = new UpdateFetcher($this->mockConfigFactory, $this->mockHttpClient, $settings); + $this->testProject = [ + 'name' => 'update_test', + 'project_type' => '', + 'info' => [ + 'version' => '', + 'project status url' => 'https://www.example.com', + ], + 'includes' => ['module1' => 'Module 1', 'module2' => 'Module 2'], + ]; + + // Set up logger factory so that watchdog_exception() does not break and + // register this class as the logger so we can test messages. + $container = $this->createMock('Symfony\Component\DependencyInjection\ContainerInterface'); + $logger_factory = new LoggerChannelFactory(); + $logger_factory->addLogger($this); + $container->expects($this->any()) + ->method('get') + ->with('logger.factory') + ->will($this->returnValue($logger_factory)); + \Drupal::setContainer($container); } /** @@ -46,6 +113,7 @@ protected function setUp(): void { public function testUpdateBuildFetchUrl(array $project, $site_key, $expected) { $url = $this->updateFetcher->buildFetchUrl($project, $site_key); $this->assertEquals($url, $expected); + $this->assertSame([], $this->logMessages); } /** @@ -97,4 +165,86 @@ public function providerTestUpdateBuildFetchUrl() { return $data; } + /** + * Mocks the HTTP client. + * + * @param \GuzzleHttp\Psr7\Response ... + * Variable number of Response objects that the mocked client should return. + */ + protected function mockClient(Response ...$responses) { + // Create a mock and queue responses. + $mock_handler = new MockHandler($responses); + $handler_stack = HandlerStack::create($mock_handler); + $history = Middleware::history($this->history); + $handler_stack->push($history); + $this->mockHttpClient = new Client(['handler' => $handler_stack]); + } + + /** + * @covers ::doRequest + * @covers ::fetchProjectData + */ + public function testUpdateFetcherNoFallback() { + // First, try without the HTTP fallback setting, and HTTPS mocked to fail. + $settings = new Settings([]); + $this->mockClient( + new Response('500', [], 'HTTPS failed'), + ); + $update_fetcher = new UpdateFetcher($this->mockConfigFactory, $this->mockHttpClient, $settings); + + $data = $update_fetcher->fetchProjectData($this->testProject, ''); + // There should only be one request / response pair. + $this->assertCount(1, $this->history); + $request = $this->history[0]['request']; + $this->assertNotEmpty($request); + // It should have only been an HTTPS request. + $this->assertEquals('https', $request->getUri()->getScheme()); + // And it should have failed. + $response = $this->history[0]['response']; + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEmpty($data); + $this->assertSame(["Server error: `GET https://www.example.com/update_test/current` resulted in a `500 Internal Server Error` response:\nHTTPS failed\n"], $this->logMessages); + } + + /** + * @covers ::doRequest + * @covers ::fetchProjectData + */ + public function testUpdateFetcherHttpFallback() { + $settings = new Settings(['update_fetch_with_http_fallback' => TRUE]); + $this->mockClient( + new Response('500', [], 'HTTPS failed'), + new Response('200', [], 'HTTP worked'), + ); + $update_fetcher = new UpdateFetcher($this->mockConfigFactory, $this->mockHttpClient, $settings); + + $data = $update_fetcher->fetchProjectData($this->testProject, ''); + + // There should be two request / response pairs. + $this->assertCount(2, $this->history); + + // The first should have been HTTPS and should have failed. + $first_try = $this->history[0]; + $this->assertNotEmpty($first_try); + $this->assertEquals('https', $first_try['request']->getUri()->getScheme()); + $this->assertEquals(500, $first_try['response']->getStatusCode()); + + // The second should have been the HTTP fallback and should have worked. + $second_try = $this->history[1]; + $this->assertNotEmpty($second_try); + $this->assertEquals('http', $second_try['request']->getUri()->getScheme()); + $this->assertEquals(200, $second_try['response']->getStatusCode()); + // Although this is a bogus mocked response, it's what fetchProjectData() + // should return in this case. + $this->assertEquals('HTTP worked', $data); + $this->assertSame(["Server error: `GET https://www.example.com/update_test/current` resulted in a `500 Internal Server Error` response:\nHTTPS failed\n"], $this->logMessages); + } + + /** + * {@inheritdoc} + */ + public function log($level, $message, array $context = []) { + $this->logMessages[] = $context['@message']; + } + } diff --git a/core/modules/update/update.module b/core/modules/update/update.module index d777a0669e15144c93ae00a8de1bcf22c3f7c718..d6107962d606ef65852f8044a0ac5eeee19fc88d 100644 --- a/core/modules/update/update.module +++ b/core/modules/update/update.module @@ -161,6 +161,11 @@ function update_theme() { 'variables' => ['version' => NULL, 'title' => NULL, 'attributes' => []], 'file' => 'update.report.inc', ], + 'update_fetch_error_message' => [ + 'file' => 'update.report.inc', + 'render element' => 'element', + 'variables' => ['error_message' => []], + ], ]; } diff --git a/core/modules/update/update.report.inc b/core/modules/update/update.report.inc index 172860f3fda9166adb6fd898d19c810eec5a2e0e..7e65011954b7ef06e0ee5d0361903558e5bc2567 100644 --- a/core/modules/update/update.report.inc +++ b/core/modules/update/update.report.inc @@ -334,3 +334,34 @@ function template_preprocess_update_project_status(&$variables) { '#title' => $text, ]; } + +/** + * Prepares variables for update fetch error message templates. + * + * Default template: update-fetch-error-message.html.twig. + * + * @param array $variables + * An associative array of template variables. + */ +function template_preprocess_update_fetch_error_message(&$variables): void { + $variables['error_message'] = [ + 'message' => [ + '#markup' => t('Failed to fetch available update data:'), + ], + 'items' => [ + '#theme' => 'item_list', + '#items' => [ + 'documentation_link' => t('See <a href="@url">PHP OpenSSL requirements</a> in the Drupal.org handbook for possible reasons this could happen and what you can do to resolve them.', ['@url' => 'https://www.drupal.org/node/3170647']), + ], + ], + ]; + if (\Drupal::moduleHandler()->moduleExists('dblog') && \Drupal::currentUser()->hasPermission('access site reports')) { + $options = ['query' => ['type' => ['update']]]; + $dblog_url = Url::fromRoute('dblog.overview', [], $options); + $variables['error_message']['items']['#items']['dblog'] = t('Check <a href="@url">your local system logs</a> for additional error messages.', ['@url' => $dblog_url->toString()]); + } + else { + $variables['error_message']['items']['#items']['logs'] = t('Check your local system logs for additional error messages.'); + } + +} diff --git a/core/modules/update/update.services.yml b/core/modules/update/update.services.yml index 197e3d5f1d39eea4d3c2599d248f5153b837544d..df328443cd6129e00a2cb6fcc069dd76f5be4f71 100644 --- a/core/modules/update/update.services.yml +++ b/core/modules/update/update.services.yml @@ -12,7 +12,7 @@ services: arguments: ['@config.factory', '@queue', '@update.fetcher', '@state', '@private_key', '@keyvalue', '@keyvalue.expirable'] update.fetcher: class: Drupal\update\UpdateFetcher - arguments: ['@config.factory', '@http_client'] + arguments: ['@config.factory', '@http_client', '@settings'] update.root: class: SplString factory: ['@update.root.factory', 'get'] diff --git a/core/themes/stable/templates/admin/update-fetch-error-message.html.twig b/core/themes/stable/templates/admin/update-fetch-error-message.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..fd4a967e72566ce975e796f96950af6b39d90f8c --- /dev/null +++ b/core/themes/stable/templates/admin/update-fetch-error-message.html.twig @@ -0,0 +1,14 @@ +{# +/** + * @file + * Default theme implementation for the message when fetching data fails. + * + * Available variables: + * - error_message: A render array containing the appropriate error message. + * + * @see template_preprocess_update_fetch_error_message() + * + * @ingroup themeable + */ +#} +{{ error_message }} diff --git a/core/themes/stable9/templates/admin/update-fetch-error-message.html.twig b/core/themes/stable9/templates/admin/update-fetch-error-message.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..fd4a967e72566ce975e796f96950af6b39d90f8c --- /dev/null +++ b/core/themes/stable9/templates/admin/update-fetch-error-message.html.twig @@ -0,0 +1,14 @@ +{# +/** + * @file + * Default theme implementation for the message when fetching data fails. + * + * Available variables: + * - error_message: A render array containing the appropriate error message. + * + * @see template_preprocess_update_fetch_error_message() + * + * @ingroup themeable + */ +#} +{{ error_message }} diff --git a/sites/default/default.settings.php b/sites/default/default.settings.php index f43915bf11693ac786737ff836e3da862793aab3..800024129ec00a35bf9c19d39e5aa63a17828ef7 100644 --- a/sites/default/default.settings.php +++ b/sites/default/default.settings.php @@ -307,6 +307,20 @@ */ $settings['update_free_access'] = FALSE; +/** + * Fallback to HTTP for Update Manager. + * + * If your Drupal site fails to connect to updates.drupal.org using HTTPS to + * fetch Drupal core, module and theme update status, you may uncomment this + * setting and set it to TRUE to allow an insecure fallback to HTTP. Note that + * doing so will open your site up to a potential man-in-the-middle attack. You + * should instead attempt to resolve the issues before enabling this option. + * @see https://www.drupal.org/docs/system-requirements/php-requirements#openssl + * @see https://en.wikipedia.org/wiki/Man-in-the-middle_attack + * @see \Drupal\update\UpdateFetcher + */ +# $settings['update_fetch_with_http_fallback'] = TRUE; + /** * External access proxy settings: *