Unverified Commit dcd44643 authored by larowlan's avatar larowlan

Issue #1538118 by dww, swentel, dawehner, pwolanin, sanduhrs, alexpott,...

Issue #1538118 by dww, swentel, dawehner, pwolanin, sanduhrs, alexpott, ayushmishra206, Wim Leers, yogeshmpawar, mgifford, cilefen, David_Rothstein, drumm, larowlan, Heine, colan, tedbow, benjifisher, klausi, borisson_, quietone: Update status does not verify the identity or authenticity of the release history URL
parent d598f541
......@@ -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:
*
......
......@@ -47270,7 +47270,7 @@
'name' => 'update',
'type' => 'module',
'owner' => '',
'status' => '0',
'status' => '1',
'throttle' => '0',
'bootstrap' => '0',
'schema_version' => '-1',
......@@ -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',
......
# 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'
......@@ -5,7 +5,7 @@ migration_tags:
- Drupal 7
- Configuration
source:
plugin: variable
plugin: update_settings
variables:
- update_max_fetch_attempts
- update_fetch_url
......
......@@ -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;
}
......
......@@ -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.'),
......
<?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;
}
}
......@@ -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;
}
......
{#
/**
* @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 }}
......@@ -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'));
......
......@@ -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']);
}
}
......@@ -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'];
}
}
......@@ -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' => []],
],
];
}
......
......@@ -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.