Issue #2455083 by dawehner, larowlan, klausi, David_Rothstein, hefox, tsphethean, dstol, DamienMcKenna, Pere Orga, benjy: Open redirect fixes from SA-CORE-2015-001 need to be ported to Drupal 8

8.0.x
Alex Pott 2015-03-20 13:20:42 +00:00
parent 8c5ffa3242
commit d2304f840c
13 changed files with 365 additions and 13 deletions

View File

@ -214,10 +214,15 @@ class UrlHelper {
*/
public static function isExternal($path) {
$colonpos = strpos($path, ':');
// Avoid calling stripDangerousProtocols() if there is any
// slash (/), hash (#) or question_mark (?) before the colon (:)
// occurrence - if any - as this would clearly mean it is not a URL.
return $colonpos !== FALSE && !preg_match('![/?#]!', substr($path, 0, $colonpos)) && static::stripDangerousProtocols($path) == $path;
// Avoid calling drupal_strip_dangerous_protocols() if there is any slash
// (/), hash (#) or question_mark (?) before the colon (:) occurrence - if
// any - as this would clearly mean it is not a URL. If the path starts with
// 2 slashes then it is always considered an external URL without an
// explicit protocol part.
return (strpos($path, '//') === 0)
|| ($colonpos !== FALSE
&& !preg_match('![/?#]!', substr($path, 0, $colonpos))
&& static::stripDangerousProtocols($path) == $path);
}
/**

View File

@ -10,6 +10,7 @@ namespace Drupal\Core\EventSubscriber;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Routing\RequestContext;
use Drupal\Core\Routing\UrlGeneratorInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
@ -92,6 +93,36 @@ class RedirectResponseSubscriber implements EventSubscriberInterface {
}
}
/**
* Sanitize the destination parameter to prevent open redirect attacks.
*
* @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
* The Event to process.
*/
public function sanitizeDestination(GetResponseEvent $event) {
$request = $event->getRequest();
// Sanitize the destination parameter (which is often used for redirects) to
// prevent open redirect attacks leading to other domains. Sanitize both
// $_GET['destination'] and $_REQUEST['destination'] to protect code that
// relies on either, but do not sanitize $_POST to avoid interfering with
// unrelated form submissions. The sanitization happens here because
// url_is_external() requires the variable system to be available.
$query_info = $request->query;
$request_info = $request->request;
if ($query_info->has('destination') || $request_info->has('destination')) {
// If the destination is an external URL, remove it.
if ($query_info->has('destination') && UrlHelper::isExternal($query_info->get('destination'))) {
$query_info->remove('destination');
$request_info->remove('destination');
}
// If there's still something in $_REQUEST['destination'] that didn't come
// from $_GET, check it too.
if ($request_info->has('destination') && (!$query_info->has('destination') || $request_info->get('destination') != $query_info->get('destination')) && UrlHelper::isExternal($request_info->get('destination'))) {
$request_info->remove('destination');
}
}
}
/**
* Registers the methods in this class that should be listeners.
*
@ -100,6 +131,7 @@ class RedirectResponseSubscriber implements EventSubscriberInterface {
*/
static function getSubscribedEvents() {
$events[KernelEvents::RESPONSE][] = array('checkRedirectUrl');
$events[KernelEvents::REQUEST][] = array('sanitizeDestination', 100);
return $events;
}
}

View File

@ -62,7 +62,10 @@ class RedirectDestination implements RedirectDestinationInterface {
public function get() {
if (!isset($this->destination)) {
$query = $this->requestStack->getCurrentRequest()->query;
if ($query->has('destination')) {
if (UrlHelper::isExternal($query->get('destination'))) {
$this->destination = '/';
}
elseif ($query->has('destination')) {
$this->destination = $query->get('destination');
}
else {

View File

@ -245,15 +245,20 @@ class UrlGenerator extends ProviderBasedGenerator implements UrlGeneratorInterfa
'prefix' => '',
);
// A duplicate of the code from
// \Drupal\Component\Utility\UrlHelper::isExternal() to avoid needing
// another function call, since performance inside url() is critical.
if (!isset($options['external'])) {
// Return an external link if $path contains an allowed absolute URL. Only
// call the slow
// \Drupal\Component\Utility\UrlHelper::stripDangerousProtocols() if $path
// contains a ':' before any / ? or #. Note: we could use
// \Drupal\Component\Utility\UrlHelper::isExternal($path) here, but that
// would require another function call, and performance here is critical.
$colonpos = strpos($path, ':');
$options['external'] = ($colonpos !== FALSE && !preg_match('![/?#]!', substr($path, 0, $colonpos)) && UrlHelper::stripDangerousProtocols($path) == $path);
// Avoid calling drupal_strip_dangerous_protocols() if there is any slash
// (/), hash (#) or question_mark (?) before the colon (:) occurrence -
// if any - as this would clearly mean it is not a URL. If the path starts
// with 2 slashes then it is always considered an external URL without an
// explicit protocol part.
$options['external'] = (strpos($path, '//') === 0)
|| ($colonpos !== FALSE
&& !preg_match('![/?#]!', substr($path, 0, $colonpos))
&& UrlHelper::stripDangerousProtocols($path) == $path);
}
if (isset($options['fragment']) && $options['fragment'] !== '') {

View File

@ -113,6 +113,11 @@ class UnroutedUrlAssembler implements UnroutedUrlAssemblerInterface {
// https://www.drupal.org/node/2417459
$uri = substr($uri, 5);
// Strip leading slashes from internal paths to prevent them becoming
// external URLs without protocol. /example.com should not be turned into
// //example.com.
$uri = ltrim($uri, '/');
// Allow (outbound) path processing, if needed. A valid use case is the path
// alias overview form:
// @see \Drupal\path\Controller\PathController::adminOverview().

View File

@ -7,6 +7,8 @@
namespace Drupal\system\Tests\Form;
use Drupal\Component\Utility\String;
use Drupal\Core\Url;
use Drupal\simpletest\WebTestBase;
/**
@ -50,4 +52,35 @@ class ConfirmFormTest extends WebTestBase {
$this->assertUrl('form-test/confirm-form', array('query' => array('destination' => 'admin/config')), "The form's complex cancel link was followed.");
}
/**
* Tests that the confirm form does not use external destinations.
*/
public function testConfirmFormWithExternalDestination() {
$this->drupalGet('form-test/confirm-form');
$this->assertCancelLinkUrl(Url::fromRoute('form_test.route8'));
$this->drupalGet('form-test/confirm-form', array('query' => array('destination' => 'node')));
$this->assertCancelLinkUrl(Url::fromUri('internal:/node'));
$this->drupalGet('form-test/confirm-form', array('query' => array('destination' => 'http://example.com')));
$this->assertCancelLinkUrl(Url::fromRoute('form_test.route8'));
}
/**
* Asserts that a cancel link is present pointing to the provided URL.
*
* @param \Drupal\Core\Url $url
* The url to check for.
* @param string $message
* The assert message.
* @param string $group
* The assertion group.
*
* @return bool
* Result of the assertion.
*/
public function assertCancelLinkUrl(Url $url, $message = '', $group = 'Other') {
$links = $this->xpath('//a[@href=:url]', [':url' => $url->toString()]);
$message = ($message ? $message : String::format('Cancel link with url %url found.', ['%url' => $url->toString()]));
return $this->assertTrue(isset($links[0]), $message, $group);
}
}

View File

@ -0,0 +1,83 @@
<?php
/**
* @file
* Contains \Drupal\system\Tests\Routing\DestinationTest.
*/
namespace Drupal\system\Tests\Routing;
use Drupal\Core\Url;
use Drupal\simpletest\WebTestBase;
/**
* Tests for $_GET['destination'] and $_REQUEST['destination'] validation.
*
* Note: This tests basically the same as
* \Drupal\Tests\Core\EventSubscriber\RedirectResponseSubscriberTest::testSanitizeDestinationForGet
* \Drupal\Tests\Core\EventSubscriber\RedirectResponseSubscriberTest::testSanitizeDestinationForPost
* but we want to be absolutely sure it works.
*
* @group Routing
*/
class DestinationTest extends WebTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['system_test'];
/**
* Tests that $_GET/$_REQUEST['destination'] only contain internal URLs.
*/
public function testDestination() {
$test_cases = [
[
'input' => 'node',
'output' => 'node',
'message' => "Standard internal example node path is present in the 'destination' parameter.",
],
[
'input' => '/example.com',
'output' => '/example.com',
'message' => 'Internal path with one leading slash is allowed.',
],
[
'input' => '//example.com/test',
'output' => '',
'message' => 'External URL without scheme is not allowed.',
],
[
'input' => 'example:test',
'output' => 'example:test',
'message' => 'Internal URL using a colon is allowed.',
],
[
'input' => 'http://example.com',
'output' => '',
'message' => 'External URL is not allowed.',
],
[
'input' => 'javascript:alert(0)',
'output' => 'javascript:alert(0)',
'message' => 'Javascript URL is allowed because it is treated as an internal URL.',
],
];
foreach ($test_cases as $test_case) {
// Test $_GET['destination'].
$this->drupalGet('system-test/get-destination', ['query' => ['destination' => $test_case['input']]]);
$this->assertIdentical($test_case['output'], $this->getRawContent(), $test_case['message']);
// Test $_REQUEST['destination'].
$post_output = $this->drupalPost('system-test/request-destination', '*', ['destination' => $test_case['input']]);
$this->assertIdentical($test_case['output'], $post_output, $test_case['message']);
}
// Make sure that 404 pages do not populate $_GET['destination'] with
// external URLs.
\Drupal::configFactory()->getEditable('system.site')->set('page.404', 'system-test/get-destination')->save();
$this->drupalGet('http://example.com', ['external' => FALSE]);
$this->assertResponse(404);
$this->assertIdentical(Url::fromRoute('<front>')->toString(), $this->getRawContent(), 'External URL is not allowed on 404 pages.');
}
}

View File

@ -87,6 +87,34 @@ class SystemTestController extends ControllerBase {
return [];
}
/**
* Controller to return $_GET['destination'] for testing.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response.
*/
public function getDestination(Request $request) {
$response = new Response($request->query->get('destination'));
return $response;
}
/**
* Controller to return $_REQUEST['destination'] for testing.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response.
*/
public function requestDestination(Request $request) {
$response = new Response($request->request->get('destination'));
return $response;
}
/**
* Try to acquire a named lock and report the outcome.
*/

View File

@ -87,3 +87,19 @@ system_test.configure:
_title_callback: '\Drupal\system_test\Controller\SystemTestController::configureTitle'
requirements:
_access: 'TRUE'
system_test.request_destination:
path: '/system-test/request-destination'
defaults:
_controller: '\Drupal\system_test\Controller\SystemTestController::requestDestination'
requirements:
_access: 'TRUE'
system_test.get_destination:
path: '/system-test/get-destination'
defaults:
_controller: '\Drupal\system_test\Controller\SystemTestController::getDestination'
requirements:
_access: 'TRUE'

View File

@ -358,6 +358,10 @@ class UrlHelperTest extends UnitTestCase {
array('/internal/path', FALSE),
array('https://example.com/external/path', TRUE),
array('javascript://fake-external-path', FALSE),
// External URL without an explicit protocol.
array('//drupal.org/foo/bar?foo=bar&bar=baz&baz#foo', TRUE),
// Internal URL starting with a slash.
array('/drupal.org', FALSE),
);
}

View File

@ -14,6 +14,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
@ -48,7 +49,11 @@ class RedirectResponseSubscriberTest extends UnitTestCase {
->expects($this->any())
->method('generateFromPath')
->willReturnMap([
['test', ['query' => [], 'fragment' => '', 'absolute' => TRUE], 'http://example.com/drupal/test']
['test', ['query' => [], 'fragment' => '', 'absolute' => TRUE], 'http://example.com/drupal/test'],
['example.com', ['query' => [], 'fragment' => '', 'absolute' => TRUE], 'http://example.com/drupal/example.com'],
['example:com', ['query' => [], 'fragment' => '', 'absolute' => TRUE], 'http://example.com/drupal/example:com'],
['javascript:alert(0)', ['query' => [], 'fragment' => '', 'absolute' => TRUE], 'http://example.com/drupal/javascript:alert(0)'],
['/test', ['query' => [], 'fragment' => '', 'absolute' => TRUE], 'http://example.com/test'],
]);
}
@ -58,6 +63,7 @@ class RedirectResponseSubscriberTest extends UnitTestCase {
$request_context->expects($this->any())
->method('getCompleteBaseUrl')
->willReturn('http://example.com/drupal');
$request->headers->set('HOST', 'example.com');
$listener = new RedirectResponseSubscriber($url_generator, $request_context);
$dispatcher->addListener(KernelEvents::RESPONSE, array($listener, 'checkRedirectUrl'));
@ -85,8 +91,118 @@ class RedirectResponseSubscriberTest extends UnitTestCase {
array(new Request(array('destination' => 'http://example.com/foobar')), FALSE),
array(new Request(array('destination' => 'http://example.ca/drupal')), FALSE),
array(new Request(array('destination' => 'test')), 'http://example.com/drupal/test'),
array(new Request(array('destination' => '/test')), 'http://example.com/test'),
array(new Request(array('destination' => '/example.com')), 'http://example.com/example.com'),
array(new Request(array('destination' => 'example:com')), 'http://example.com/drupal/example:com'),
array(new Request(array('destination' => 'javascript:alert(0)')), 'http://example.com/drupal/javascript:alert(0)'),
array(new Request(array('destination' => 'http://example.com/drupal/')), 'http://example.com/drupal/'),
array(new Request(array('destination' => 'http://example.com/drupal/test')), 'http://example.com/drupal/test'),
);
}
/**
* @expectedException \InvalidArgumentException
*
* @dataProvider providerTestDestinationRedirectWithInvalidUrl
*/
public function testDestinationRedirectWithInvalidUrl(Request $request) {
$dispatcher = new EventDispatcher();
$kernel = $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface');
$response = new RedirectResponse('http://example.com/drupal');
$url_generator = $this->getMock('Drupal\Core\Routing\UrlGeneratorInterface');
$request_context = $this->getMockBuilder('Drupal\Core\Routing\RequestContext')
->disableOriginalConstructor()
->getMock();
$listener = new RedirectResponseSubscriber($url_generator, $request_context);
$dispatcher->addListener(KernelEvents::RESPONSE, array($listener, 'checkRedirectUrl'));
$event = new FilterResponseEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST, $response);
$dispatcher->dispatch(KernelEvents::RESPONSE, $event);
}
/**
* Data provider for testDestinationRedirectWithInvalidUrl().
*/
public function providerTestDestinationRedirectWithInvalidUrl() {
$data = [];
$data[] = [new Request(array('destination' => '//example:com'))];
$data[] = [new Request(array('destination' => '//example:com/test'))];
return $data;
}
/**
* Tests that $_GET only contain internal URLs.
*
* @covers ::sanitizeDestination
*
* @dataProvider providerTestSanitizeDestination
*
* @see \Drupal\Component\Utility\UrlHelper::isExternal
*/
public function testSanitizeDestinationForGet($input, $output) {
$request = new Request();
$request->query->set('destination', $input);
$url_generator = $this->getMock('Drupal\Core\Routing\UrlGeneratorInterface');
$request_context = new RequestContext();
$listener = new RedirectResponseSubscriber($url_generator, $request_context);
$kernel = $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface');
$event = new GetResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST);
$dispatcher = new EventDispatcher();
$dispatcher->addListener(KernelEvents::REQUEST, [$listener, 'sanitizeDestination'], 100);
$dispatcher->dispatch(KernelEvents::REQUEST, $event);
$this->assertEquals($output, $request->query->get('destination'));
}
/**
* Tests that $_REQUEST['destination'] only contain internal URLs.
*
* @covers ::sanitizeDestination
*
* @dataProvider providerTestSanitizeDestination
*
* @see \Drupal\Component\Utility\UrlHelper::isExternal
*/
public function testSanitizeDestinationForPost($input, $output) {
$request = new Request();
$request->request->set('destination', $input);
$url_generator = $this->getMock('Drupal\Core\Routing\UrlGeneratorInterface');
$request_context = new RequestContext();
$listener = new RedirectResponseSubscriber($url_generator, $request_context);
$kernel = $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface');
$event = new GetResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST);
$dispatcher = new EventDispatcher();
$dispatcher->addListener(KernelEvents::REQUEST, [$listener, 'sanitizeDestination'], 100);
$dispatcher->dispatch(KernelEvents::REQUEST, $event);
$this->assertEquals($output, $request->request->get('destination'));
}
/**
* Data provider for testSanitizeDestination().
*/
public function providerTestSanitizeDestination() {
$data = [];
// Standard internal example node path is present in the 'destination'
// parameter.
$data[] = ['node', 'node'];
// Internal path with one leading slash is allowed.
$data[] = ['/example.com', '/example.com'];
// External URL without scheme is not allowed.
$data[] = ['//example.com/test', ''];
// Internal URL using a colon is allowed.
$data[] = ['example:test', 'example:test'];
// External URL is not allowed.
$data[] = ['http://example.com', ''];
// Javascript URL is allowed because it is treated as an internal URL.
$data[] = ['javascript:alert(0)', 'javascript:alert(0)'];
return $data;
}
}

View File

@ -64,6 +64,13 @@ class RedirectDestinationTest extends UnitTestCase {
}
/**
* Tests destination passed via $_GET.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request to test.
* @param string $expected_destination
* The expected destination.
*
* @dataProvider providerGet
*
* @covers ::get
@ -108,6 +115,11 @@ class RedirectDestinationTest extends UnitTestCase {
$request->query->set('other', 'value');
$data[] = [$request, '/current-path?other=value'];
// A request with a dedicated specified external destination.
$request = Request::create('/');
$request->query->set('destination', 'https://www.drupal.org');
$data[] = [$request, '/'];
return $data;
}

View File

@ -66,6 +66,14 @@ class UnroutedUrlAssemblerTest extends UnitTestCase {
$this->unroutedUrlAssembler->assemble('wrong-url');
}
/**
* @covers ::assemble
* @expectedException \InvalidArgumentException
*/
public function testAssembleWithLeadingSlash() {
$this->unroutedUrlAssembler->assemble('/drupal.org');
}
/**
* @covers ::assemble
* @covers ::buildExternalUrl
@ -89,6 +97,7 @@ class UnroutedUrlAssemblerTest extends UnitTestCase {
['http://example.com/test', ['https' => TRUE], 'https://example.com/test'],
['https://example.com/test', ['https' => FALSE], 'http://example.com/test'],
['https://example.com/test?foo=1#bar', [], 'https://example.com/test?foo=1#bar'],
['//drupal.org', [], '//drupal.org'],
];
}
@ -115,6 +124,7 @@ class UnroutedUrlAssemblerTest extends UnitTestCase {
['base:example', [], TRUE, '/subdir/example'],
['base:example', ['query' => ['foo' => 'bar']], TRUE, '/subdir/example?foo=bar'],
['base:example', ['fragment' => 'example', ], TRUE, '/subdir/example#example'],
['base:/drupal.org', [], FALSE, '/drupal.org'],
];
}