Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redirect to FE preview page after login and ensure integrity of target links #1164

Merged
merged 5 commits into from
Jan 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions core-bundle/src/Controller/BackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,16 @@ public function loginAction(Request $request): Response
$this->initializeContaoFramework();

if ($this->isGranted('IS_AUTHENTICATED_FULLY')) {
$queryString = '';
if ($request->query->has('redirect')) {
$uriSigner = $this->get('uri_signer');

if ($request->query->has('referer')) {
$queryString = '?'.base64_decode($request->query->get('referer'), true);
// We cannot use $request->getUri() here as we want to work with the original URI (no query string reordering)
if ($uriSigner->check($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().(null !== ($qs = $request->server->get('QUERY_STRING')) ? '?'.$qs : ''))) {
aschempp marked this conversation as resolved.
Show resolved Hide resolved
return new RedirectResponse($request->query->get('redirect'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this base64_decode the redirect?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's not encoded here. Only for the Base64Controller which redirects to this one after decoding. I think.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it's encoded for the front end (ModuleLogin) but not for the back end??

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. The value you get here, is not base64 encoded. The base64 encoding is only used in places where you echo the target URL in the HTML.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PreviewAuthenticationListener redirects without base64 encoding.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the back end, yes. But the front end seems to work with encoding: https://github.com/contao/contao/pull/1164/files#diff-fced920e5b0ab4f5a25d857b2c04fa02R80

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, you don't understand. It works exactly the same in both cases. The ones that read the redirect query parameter and acutally redirect to the target page, both work the same.
The base64 encoding is only needed if you want to output the desired target URL in HTML which is never done in the back end.

}
}

return new RedirectResponse($this->generateUrl('contao_backend').$queryString);
return new RedirectResponse($this->generateUrl('contao_backend'));
}

$controller = new BackendIndex();
Expand Down Expand Up @@ -214,6 +217,7 @@ public static function getSubscribedServices(): array
$services = parent::getSubscribedServices();

$services['contao.picker.builder'] = PickerBuilderInterface::class;
$services['uri_signer'] = 'uri_signer'; // FIXME: adjust this once https://github.com/symfony/symfony/pull/35298 has been merged

return $services;
}
Expand Down
53 changes: 53 additions & 0 deletions core-bundle/src/Controller/Base64EncodedRedirectController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

/*
* This file is part of Contao.
*
* (c) Leo Feyer
*
* @license LGPL-3.0-or-later
*/

namespace Contao\CoreBundle\Controller;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\UriSigner;
use Symfony\Component\Routing\Annotation\Route;

/**
* @internal
*/
class Base64EncodedRedirectController
{
/**
* @var UriSigner
*/
private $uriSigner;

public function __construct(UriSigner $uriSigner)
{
$this->uriSigner = $uriSigner;
}

/**
* @Route("/_contao/base64_redirect", name="contao_base64_redirect")
*/
public function renderAction(Request $request): Response
{
if (!$request->query->has('redirect')) {
throw new BadRequestHttpException();
}

// We cannot use $request->getUri() here as we want to work with the original URI (no query string reordering)
if (!$this->uriSigner->check($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().(null !== ($qs = $request->server->get('QUERY_STRING')) ? '?'.$qs : ''))) {
leofeyer marked this conversation as resolved.
Show resolved Hide resolved
throw new BadRequestHttpException();
}

return new RedirectResponse(base64_decode($request->query->get('redirect'), true));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class MakeServicesPublicPass implements CompilerPassInterface
'security.authentication.trust_resolver',
'security.firewall.map',
'security.logout_url_generator',
'uri_signer',
];

private const ALIASES = [
Expand Down
7 changes: 7 additions & 0 deletions core-bundle/src/Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ services:
tags:
- controller.service_arguments

Contao\CoreBundle\Controller\Base64EncodedRedirectController:
arguments:
- '@uri_signer'
tags:
- controller.service_arguments

# Backwards compatibility
contao.controller.backend_csv_import:
alias: Contao\CoreBundle\Controller\BackendCsvImportController
Expand Down Expand Up @@ -607,6 +613,7 @@ services:
arguments:
- '@security.http_utils'
- '@router'
- '@uri_signer'

contao.security.expiring_token_based_remember_me_services:
class: Contao\CoreBundle\Security\Authentication\RememberMe\ExpiringTokenBasedRememberMeServices
Expand Down
28 changes: 18 additions & 10 deletions core-bundle/src/Resources/contao/controllers/BackendIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Scheb\TwoFactorBundle\Security\Authentication\Exception\InvalidTwoFactorCodeException;
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorToken;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\UriSigner;
use Symfony\Component\Routing\Router;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
Expand Down Expand Up @@ -66,18 +67,25 @@ public function run()
Message::addError($GLOBALS['TL_LANG']['ERR']['invalidLogin']);
}

$queryString = '';
$arrParams = array();
$router = $container->get('router');
$targetPath = $router->generate('contao_backend', array(), Router::ABSOLUTE_URL);
$failurePath = $router->generate('contao_backend_login', array(), Router::ABSOLUTE_URL);
$request = $container->get('request_stack')->getCurrentRequest();

if ($referer = Input::get('referer', true))
if ($request && $request->query->has('redirect'))
{
// Decode the referer and urlencode insert tags
$queryString = '?' . str_replace(array('{', '}'), array('%7B', '%7D'), base64_decode($referer, true));
$arrParams['referer'] = $referer;
/** @var UriSigner $uriSigner */
$uriSigner = $container->get('uri_signer');

// We cannot use $request->getUri() here as we want to work with the original URI (no query string reordering)
if ($uriSigner->check($request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo() . (null !== ($qs = $request->server->get('QUERY_STRING')) ? '?' . $qs : '')))
{
$redirectParams = array('redirect' => base64_encode($request->query->get('redirect')));
$targetPath = $uriSigner->sign($router->generate('contao_base64_redirect', $redirectParams, Router::ABSOLUTE_URL));
$failurePath = $uriSigner->sign($router->generate('contao_base64_redirect', array('redirect' => $router->generate('contao_backend_login', $redirectParams, Router::ABSOLUTE_URL)), Router::ABSOLUTE_URL));
}
}

$router = $container->get('router');

$objTemplate = new BackendTemplate('be_login');
$objTemplate->action = ampersand(Environment::get('request'));
$objTemplate->headline = $GLOBALS['TL_LANG']['MSC']['loginBT'];
Expand Down Expand Up @@ -110,8 +118,8 @@ public function run()
$objTemplate->feLink = $GLOBALS['TL_LANG']['MSC']['feLink'];
$objTemplate->default = $GLOBALS['TL_LANG']['MSC']['default'];
$objTemplate->jsDisabled = $GLOBALS['TL_LANG']['MSC']['jsDisabled'];
$objTemplate->targetPath = StringUtil::specialchars($router->generate('contao_backend', array(), Router::ABSOLUTE_URL) . $queryString);
$objTemplate->failurePath = StringUtil::specialchars($router->generate('contao_backend_login', $arrParams, Router::ABSOLUTE_URL));
$objTemplate->targetPath = StringUtil::specialchars($targetPath);
$objTemplate->failurePath = StringUtil::specialchars($failurePath);

return $objTemplate->getResponse();
}
Expand Down
31 changes: 26 additions & 5 deletions core-bundle/src/Resources/contao/modules/ModuleLogin.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Contao\CoreBundle\Security\Exception\LockedException;
use Patchwork\Utf8;
use Scheb\TwoFactorBundle\Security\Authentication\Exception\InvalidTwoFactorCodeException;
use Symfony\Component\HttpKernel\UriSigner;
use Symfony\Component\Routing\Router;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

Expand Down Expand Up @@ -59,14 +60,25 @@ public function generate()
return $objTemplate->parse();
}

$container = System::getContainer();
$request = $container->get('request_stack')->getCurrentRequest();

// If the form was submitted and the credentials were wrong, take the target path from the submitted data
// as otherwise it would take the current page
if ($_POST)
{
$this->targetPath = (string) Input::post('_target_path');
}
elseif ($this->redirectBack && ($referer = Input::get('referer', true)))
elseif ($this->redirectBack && $request && $request->query->has('redirect'))
{
// Decode the referer and urlencode insert tags
$this->targetPath = Environment::get('base') . str_replace(array('{', '}'), array('%7B', '%7D'), base64_decode($referer, true));
/** @var UriSigner $uriSigner */
$uriSigner = $container->get('uri_signer');

// We cannot use $request->getUri() here as we want to work with the original URI (no query string reordering)
if ($uriSigner->check($request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo() . (null !== ($qs = $request->server->get('QUERY_STRING')) ? '?' . $qs : '')))
{
$this->targetPath = base64_decode($request->query->get('redirect'));
}
}

return parent::generate();
Expand All @@ -85,6 +97,9 @@ protected function compile()
/** @var Router $router */
$router = $container->get('router');

/** @var UriSigner $uriSigner */
$uriSigner = $container->get('uri_signer');

/** @var AuthenticationException|null $exception */
$exception = $container->get('security.authentication_utils')->getLastAuthenticationError();
$authorizationChecker = $container->get('security.authorization_checker');
Expand Down Expand Up @@ -172,6 +187,12 @@ protected function compile()
$strRedirect = $objTarget->getAbsoluteUrl();
}

$request = $container->get('request_stack')->getCurrentRequest();

// Ensure we do not output any possible dangerous data (redirect back to previous URL allows for URL param injection)
$targetPath = $uriSigner->sign($router->generate('contao_base64_redirect', array('redirect' => base64_encode($strRedirect)), Router::ABSOLUTE_URL));
$failurePath = $uriSigner->sign($router->generate('contao_base64_redirect', array('redirect' => base64_encode($request->getUri())), Router::ABSOLUTE_URL));

$this->Template->username = $GLOBALS['TL_LANG']['MSC']['username'];
$this->Template->password = $GLOBALS['TL_LANG']['MSC']['password'][0];
$this->Template->action = $router->generate('contao_frontend_login');
Expand All @@ -181,8 +202,8 @@ protected function compile()
$this->Template->autologin = $this->autologin;
$this->Template->autoLabel = $GLOBALS['TL_LANG']['MSC']['autologin'];
$this->Template->forceTargetPath = (int) $blnRedirectBack;
$this->Template->targetPath = StringUtil::specialchars($strRedirect);
$this->Template->failurePath = StringUtil::specialchars(Environment::get('base') . Environment::get('request'));
$this->Template->targetPath = StringUtil::specialchars($targetPath);
$this->Template->failurePath = StringUtil::specialchars($failurePath);
}
}

Expand Down
8 changes: 6 additions & 2 deletions core-bundle/src/Resources/contao/pages/PageError401.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Contao\CoreBundle\Exception\ForwardPageNotFoundException;
use Contao\CoreBundle\Exception\InsufficientAuthenticationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\UriSigner;

/**
* Provide methods to handle an error 401 page.
Expand Down Expand Up @@ -121,9 +122,12 @@ protected function prepare($objRootPage=null)
}

// Add the referer so the login module can redirect back
$referer = base64_encode(Environment::get('request'));
$url = $objNextPage->getAbsoluteUrl() . '?redirect=' . base64_encode(Environment::get('base') . Environment::get('request'));
leofeyer marked this conversation as resolved.
Show resolved Hide resolved

$this->redirect($objNextPage->getFrontendUrl() . '?referer=' . $referer, (($obj401->redirect == 'temporary') ? 302 : 301));
/** @var UriSigner $uriSigner */
$uriSigner = System::getContainer()->get('uri_signer');

$this->redirect($uriSigner->sign($url), (($obj401->redirect == 'temporary') ? 302 : 301));
}

return $obj401;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
namespace Contao\CoreBundle\Security\Authentication;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\UriSigner;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
Expand All @@ -31,13 +32,19 @@ class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
*/
private $router;

/**
* @var UriSigner
*/
private $uriSigner;

/**
* @internal Do not inherit from this class; decorate the "contao.security.entry_point" service instead
*/
public function __construct(HttpUtils $httpUtils, RouterInterface $router)
public function __construct(HttpUtils $httpUtils, RouterInterface $router, UriSigner $uriSigner)
{
$this->httpUtils = $httpUtils;
$this->router = $router;
$this->uriSigner = $uriSigner;
}

/**
Expand All @@ -51,10 +58,10 @@ public function start(Request $request, AuthenticationException $authException =

$url = $this->router->generate(
'contao_backend_login',
['referer' => base64_encode($request->getQueryString())],
['redirect' => $request->getUri()],
aschempp marked this conversation as resolved.
Show resolved Hide resolved
UrlGeneratorInterface::ABSOLUTE_URL
);

return $this->httpUtils->createRedirectResponse($request, $url);
return $this->httpUtils->createRedirectResponse($request, $this->uriSigner->sign($url));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

/*
* This file is part of Contao.
*
* (c) Leo Feyer
*
* @license LGPL-3.0-or-later
*/

namespace Contao\CoreBundle\Tests\Controller;

use Contao\CoreBundle\Controller\Base64EncodedRedirectController;
use Contao\CoreBundle\Tests\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\UriSigner;

class Base64EncodedRedirectControllerTest extends TestCase
{
public function testReturnsBadRequestIfNoRedirectQueryParameterWasProvided(): void
{
$request = Request::create('https://contao.org/_contao/base64_redirect?foobar=nonsense');
$controller = new Base64EncodedRedirectController(new UriSigner('secret'));

$this->expectException(BadRequestHttpException::class);

$controller->renderAction($request);
}

public function testReturnsBadRequestIfUrlSigningWasIncorrect(): void
{
$redirect = base64_encode('https://contao.org/preview.php/about-contao.html');
$request = Request::create('https://contao.org/_contao/base64_redirect?_hash=nonsense&redirect='.$redirect);
$controller = new Base64EncodedRedirectController(new UriSigner('secret'));

$this->expectException(BadRequestHttpException::class);

$controller->renderAction($request);
}

public function testRedirectsToCorrectUrlSigningMatches(): void
{
$redirectUrl = 'https://contao.org/preview.php/about-contao.html';

$uriSigner = new UriSigner('secret');
$signedUri = $uriSigner->sign('https://contao.org/_contao/base64_redirect?redirect='.base64_encode($redirectUrl));

$controller = new Base64EncodedRedirectController($uriSigner);

/** @var RedirectResponse $response */
$response = $controller->renderAction(Request::create($signedUri));

$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame($redirectUrl, $response->getTargetUrl());
}
}
Loading