From 62ee12bbff072ffb211d4dea670f2765c7c926cb Mon Sep 17 00:00:00 2001 From: Alex Pott <alex.a.pott@googlemail.com> Date: Fri, 21 Nov 2014 09:31:37 +0000 Subject: [PATCH] Issue #2304949 by mpdonadio, cilefen, znerol, klausi, gaurav.goyal, regilero: Port HTTP Host header DoS fix from SA-CORE-2014-003 --- core/authorize.php | 14 +++- core/lib/Drupal/Core/DrupalKernel.php | 69 ++++++++++++++++--- core/rebuild.php | 12 +++- .../DrupalKernel/ValidateHostnameTest.php | 67 ++++++++++++++++++ index.php | 6 ++ 5 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 core/tests/Drupal/Tests/Core/DrupalKernel/ValidateHostnameTest.php diff --git a/core/authorize.php b/core/authorize.php index 231ad8d629d1..fe4b7d0c4fef 100644 --- a/core/authorize.php +++ b/core/authorize.php @@ -22,6 +22,7 @@ use Drupal\Core\DrupalKernel; use Drupal\Core\Url; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Drupal\Core\Site\Settings; @@ -54,9 +55,16 @@ function authorize_access_allowed() { return Settings::get('allow_authorize_operations', TRUE) && \Drupal::currentUser()->hasPermission('administer software updates'); } -$request = Request::createFromGlobals(); -$kernel = DrupalKernel::createFromRequest($request, $autoloader, 'prod'); -$kernel->prepareLegacyRequest($request); +try { + $request = Request::createFromGlobals(); + $kernel = DrupalKernel::createFromRequest($request, $autoloader, 'prod'); + $kernel->prepareLegacyRequest($request); +} +catch (HttpExceptionInterface $e) { + $response = new Response('', $e->getStatusCode()); + $response->prepare($request)->send(); + exit; +} // We have to enable the user and system modules, even to check access and // display errors via the maintenance theme. diff --git a/core/lib/Drupal/Core/DrupalKernel.php b/core/lib/Drupal/Core/DrupalKernel.php index 9c926ea3357d..dc40e4fd81f8 100644 --- a/core/lib/Drupal/Core/DrupalKernel.php +++ b/core/lib/Drupal/Core/DrupalKernel.php @@ -29,6 +29,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\TerminableInterface; use Composer\Autoload\ClassLoader; @@ -292,12 +293,19 @@ public function __construct($environment, $class_loader, $allow_dumping = TRUE) * @return string * The path of the matching directory. * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * In case the host name in the request is invalid. + * * @see \Drupal\Core\DrupalKernelInterface::getSitePath() * @see \Drupal\Core\DrupalKernelInterface::setSitePath() * @see default.settings.php * @see example.sites.php */ public static function findSitePath(Request $request, $require_settings = TRUE) { + if (static::validateHostname($request) === FALSE) { + throw new BadRequestHttpException(); + } + // Check for a simpletest override. if ($test_prefix = drupal_valid_test_ua()) { return 'sites/simpletest/' . substr($test_prefix, 10); @@ -313,7 +321,7 @@ public static function findSitePath(Request $request, $require_settings = TRUE) if (!$script_name) { $script_name = $request->server->get('SCRIPT_FILENAME'); } - $http_host = $request->server->get('HTTP_HOST'); + $http_host = $request->getHost(); $sites = array(); include DRUPAL_ROOT . '/sites/sites.php'; @@ -815,8 +823,7 @@ protected function initializeRequestGlobals(Request $request) { } else { // Create base URL. - $http_protocol = $request->isSecure() ? 'https' : 'http'; - $base_root = $http_protocol . '://' . $request->server->get('HTTP_HOST'); + $base_root = $request->getSchemeAndHttpHost(); $base_url = $base_root; @@ -898,16 +905,13 @@ protected function initializeCookieGlobals(Request $request) { // Replace "core" out of session_name so core scripts redirect properly, // specifically install.php. $session_name = preg_replace('/\/core$/', '', $session_name); - // HTTP_HOST can be modified by a visitor, but has been sanitized already - // in DrupalKernel::bootEnvironment(). - if ($cookie_domain = $request->server->get('HTTP_HOST')) { - // Strip leading periods, www., and port numbers from cookie domain. + if ($cookie_domain = $request->getHost()) { + // Strip leading periods and www. from cookie domain. $cookie_domain = ltrim($cookie_domain, '.'); if (strpos($cookie_domain, 'www.') === 0) { $cookie_domain = substr($cookie_domain, 4); } - $cookie_domain = explode(':', $cookie_domain); - $cookie_domain = '.' . $cookie_domain[0]; + $cookie_domain = '.' . $cookie_domain; } } // Per RFC 2109, cookie domains must contain at least one dot other than the @@ -1255,4 +1259,51 @@ protected function classLoaderAddMultiplePsr4(array $namespaces = array()) { } } + /** + * Validates a hostname length. + * + * @param string $host + * A hostname. + * + * @return bool + * TRUE if the length is appropriate, or FALSE otherwise. + */ + protected static function validateHostnameLength($host) { + // Limit the length of the host name to 1000 bytes to prevent DoS attacks + // with long host names. + return strlen($host) <= 1000 + // Limit the number of subdomains and port separators to prevent DoS attacks + // in findSitePath(). + && substr_count($host, '.') <= 100 + && substr_count($host, ':') <= 100; + } + + /** + * Validates the hostname supplied from the HTTP request. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object + * + * @return bool + * TRUE if the hostmame is valid, or FALSE otherwise. + * + * @todo Adjust per resolution to https://github.com/symfony/symfony/issues/12349 + */ + public static function validateHostname(Request $request) { + // $request->getHost() can throw an UnexpectedValueException if it + // detects a bad hostname, but it does not validate the length. + try { + $http_host = $request->getHost(); + } + catch (\UnexpectedValueException $e) { + return FALSE; + } + + if (static::validateHostnameLength($http_host) === FALSE) { + return FALSE; + } + + return TRUE; + } + } diff --git a/core/rebuild.php b/core/rebuild.php index be3e603b3548..7ca8af73bf2f 100644 --- a/core/rebuild.php +++ b/core/rebuild.php @@ -13,7 +13,9 @@ use Drupal\Component\Utility\Crypt; use Drupal\Core\DrupalKernel; use Drupal\Core\Site\Settings; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; // Change the directory to the Drupal root. chdir('..'); @@ -25,7 +27,15 @@ // Manually resemble early bootstrap of DrupalKernel::boot(). require_once __DIR__ . '/includes/bootstrap.inc'; DrupalKernel::bootEnvironment(); -Settings::initialize(dirname(__DIR__), DrupalKernel::findSitePath($request), $autoloader); + +try { + Settings::initialize(dirname(__DIR__), DrupalKernel::findSitePath($request), $autoloader); +} +catch (HttpExceptionInterface $e) { + $response = new Response('', $e->getStatusCode()); + $response->prepare($request)->send(); + exit; +} if (Settings::get('rebuild_access', FALSE) || ($request->get('token') && $request->get('timestamp') && diff --git a/core/tests/Drupal/Tests/Core/DrupalKernel/ValidateHostnameTest.php b/core/tests/Drupal/Tests/Core/DrupalKernel/ValidateHostnameTest.php new file mode 100644 index 000000000000..6d32411cd9a0 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/DrupalKernel/ValidateHostnameTest.php @@ -0,0 +1,67 @@ +<?php + +/** + * @file + * Contains \Drupal\Tests\Core\DrupalKernel\ValidateHostnameTest. + */ + +namespace Drupal\Tests\Core\DrupalKernel; + +use Drupal\Core\DrupalKernel; +use Drupal\Tests\UnitTestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @coversDefaultClass \Drupal\Core\DrupalKernel + * @group DrupalKernel + */ +class ValidateHostnameTest extends UnitTestCase { + + /** + * Tests hostname validation. + * + * @covers ::validateHostname() + * + * @dataProvider providerTestValidateHostname + */ + public function testValidateHostname($hostname, $message, $expected = FALSE) { + $server = ['HTTP_HOST' => $hostname]; + $request = new Request([], [], [], [], [], $server); + $validated_hostname = DrupalKernel::validateHostname($request); + $this->assertSame($expected, $validated_hostname, $message); + } + + /** + * Provides test data for testValidateHostname(). + */ + public function providerTestValidateHostname() { + $data = []; + + // Verifies that DrupalKernel::validateHostname() prevents invalid + // characters per RFC 952/2181. + $data[] = ['security/.drupal.org:80', 'HTTP_HOST with / is invalid']; + $data[] = ['security/.drupal.org:80', 'HTTP_HOST with / is invalid']; + $data[] = ['security\\.drupal.org:80', 'HTTP_HOST with \\ is invalid']; + $data[] = ['security<.drupal.org:80', 'HTTP_HOST with < is invalid']; + $data[] = ['security..drupal.org:80', 'HTTP_HOST with .. is invalid']; + + // Verifies hostnames that are too long, or have too many parts are + // invalid. + $data[] = [str_repeat('x', 1000) . '.security.drupal.org:80', 'HTTP_HOST with more than 1000 characters is invalid.']; + $data[] = [str_repeat('x.', 100) . 'security.drupal.org:80', 'HTTP_HOST with more than 100 subdomains is invalid.']; + $data[] = ['security.drupal.org:80' . str_repeat(':x', 100), 'HTTP_HOST with more than 100 port separators is invalid.']; + + // Verifies that a valid hostname is allowed. + $data[] = ['security.drupal.org:80', 'Properly formed HTTP_HOST is valid.', TRUE]; + + // Verifies that using valid IP address for the hostname is allowed. + $data[] = ['72.21.91.99:80', 'Properly formed HTTP_HOST with IPv4 address valid.', TRUE]; + $data[] = ['2607:f8b0:4004:803::1002:80', 'Properly formed HTTP_HOST with IPv6 address valid.', TRUE]; + + // Verfies that the IPv6 loopback address is valid. + $data[] = ['[::1]:80', 'HTTP_HOST containing IPv6 loopback is valid.', TRUE]; + + return $data; + } + +} diff --git a/index.php b/index.php index 406d3dcbe329..55fc9474985a 100644 --- a/index.php +++ b/index.php @@ -10,7 +10,9 @@ use Drupal\Core\DrupalKernel; use Drupal\Core\Site\Settings; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; $autoloader = require_once __DIR__ . '/core/vendor/autoload.php'; @@ -24,6 +26,10 @@ ->prepare($request)->send(); $kernel->terminate($request, $response); } +catch (HttpExceptionInterface $e) { + $response = new Response('', $e->getStatusCode()); + $response->prepare($request)->send(); +} catch (Exception $e) { $message = 'If you have just changed code (for example deployed a new module or moved an existing one) read <a href="http://drupal.org/documentation/rebuild">http://drupal.org/documentation/rebuild</a>'; if (Settings::get('rebuild_access', FALSE)) { -- GitLab