mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
[SS-2018-019] Add confirmation token to dev/build
This commit is contained in:
parent
3c58ae009e
commit
3dbb10625c
@ -41,7 +41,7 @@ class HTTPApplication implements Application
|
|||||||
*/
|
*/
|
||||||
public function handle(HTTPRequest $request)
|
public function handle(HTTPRequest $request)
|
||||||
{
|
{
|
||||||
$flush = array_key_exists('flush', $request->getVars()) || strpos($request->getURL(), 'dev/build') === 0;
|
$flush = array_key_exists('flush', $request->getVars()) || ($request->getURL() === 'dev/build');
|
||||||
|
|
||||||
// Ensure boot is invoked
|
// Ensure boot is invoked
|
||||||
return $this->execute($request, function (HTTPRequest $request) {
|
return $this->execute($request, function (HTTPRequest $request) {
|
||||||
|
182
src/Core/Startup/ConfirmationToken.php
Normal file
182
src/Core/Startup/ConfirmationToken.php
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Core\Startup;
|
||||||
|
|
||||||
|
use SilverStripe\Control\Controller;
|
||||||
|
use SilverStripe\Control\Director;
|
||||||
|
use SilverStripe\Control\HTTPRequest;
|
||||||
|
use SilverStripe\Control\HTTPResponse;
|
||||||
|
use SilverStripe\Core\Convert;
|
||||||
|
use SilverStripe\Security\RandomGenerator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared functionality for token-based authentication of potentially dangerous URLs or query
|
||||||
|
* string parameters
|
||||||
|
*
|
||||||
|
* @internal This class is designed specifically for use pre-startup and may change without warning
|
||||||
|
*/
|
||||||
|
abstract class ConfirmationToken
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var HTTPRequest
|
||||||
|
*/
|
||||||
|
protected $request = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The validated and checked token for this parameter
|
||||||
|
*
|
||||||
|
* @var string|null A string value, or null if either not provided or invalid
|
||||||
|
*/
|
||||||
|
protected $token = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of token names, suppress all tokens that have not been validated, and
|
||||||
|
* return the non-validated token with the highest priority
|
||||||
|
*
|
||||||
|
* @param array $keys List of token keys in ascending priority (low to high)
|
||||||
|
* @param HTTPRequest $request
|
||||||
|
* @return static The token container for the unvalidated $key given with the highest priority
|
||||||
|
*/
|
||||||
|
public static function prepare_tokens($keys, HTTPRequest $request)
|
||||||
|
{
|
||||||
|
$target = null;
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$token = new static($key, $request);
|
||||||
|
// Validate this token
|
||||||
|
if ($token->reloadRequired() || $token->reloadRequiredIfError()) {
|
||||||
|
$token->suppress();
|
||||||
|
$target = $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a local filesystem path to store a token
|
||||||
|
*
|
||||||
|
* @param $token
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function pathForToken($token)
|
||||||
|
{
|
||||||
|
return TEMP_PATH . DIRECTORY_SEPARATOR . 'token_' . preg_replace('/[^a-z0-9]+/', '', $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new random token and store it
|
||||||
|
*
|
||||||
|
* @return string Token name
|
||||||
|
*/
|
||||||
|
protected function genToken()
|
||||||
|
{
|
||||||
|
// Generate a new random token (as random as possible)
|
||||||
|
$rg = new RandomGenerator();
|
||||||
|
$token = $rg->randomToken('md5');
|
||||||
|
|
||||||
|
// Store a file in the session save path (safer than /tmp, as open_basedir might limit that)
|
||||||
|
file_put_contents($this->pathForToken($token), $token);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the necessary token provided for this parameter?
|
||||||
|
* A value must be provided for the token
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function tokenProvided()
|
||||||
|
{
|
||||||
|
return !empty($this->token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a token
|
||||||
|
*
|
||||||
|
* @param string $token
|
||||||
|
* @return boolean True if the token is valid
|
||||||
|
*/
|
||||||
|
protected function checkToken($token)
|
||||||
|
{
|
||||||
|
if (!$token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $this->pathForToken($token);
|
||||||
|
$content = null;
|
||||||
|
|
||||||
|
if (file_exists($file)) {
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content === $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get redirect url, excluding querystring
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function currentURL()
|
||||||
|
{
|
||||||
|
return Controller::join_links(Director::baseURL(), $this->request->getURL(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces a reload of the request with the token included
|
||||||
|
*
|
||||||
|
* @return HTTPResponse
|
||||||
|
*/
|
||||||
|
public function reloadWithToken()
|
||||||
|
{
|
||||||
|
$location = $this->redirectURL();
|
||||||
|
$locationJS = Convert::raw2js($location);
|
||||||
|
$locationATT = Convert::raw2att($location);
|
||||||
|
$body = <<<HTML
|
||||||
|
<script>location.href='$locationJS';</script>
|
||||||
|
<noscript><meta http-equiv="refresh" content="0; url=$locationATT"></noscript>
|
||||||
|
You are being redirected. If you are not redirected soon, <a href="$locationATT">click here to continue</a>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
$result = new HTTPResponse($body);
|
||||||
|
$result->redirect($location);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this parameter requested without a valid token?
|
||||||
|
*
|
||||||
|
* @return bool True if the parameter is given without a valid token
|
||||||
|
*/
|
||||||
|
abstract public function reloadRequired();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this token is provided either in the backurl, or directly,
|
||||||
|
* but without a token
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
abstract public function reloadRequiredIfError();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress the current parameter for the duration of this request
|
||||||
|
*/
|
||||||
|
abstract public function suppress();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the querystring parameters to include
|
||||||
|
*
|
||||||
|
* @param bool $includeToken Include the token value?
|
||||||
|
* @return array List of querystring parameters, possibly including token parameter
|
||||||
|
*/
|
||||||
|
abstract public function params($includeToken = true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get redirection URL
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
abstract protected function redirectURL();
|
||||||
|
}
|
@ -15,8 +15,7 @@ use Exception;
|
|||||||
* $chain = new ErrorControlChain();
|
* $chain = new ErrorControlChain();
|
||||||
* $chain->then($callback1)->then($callback2)->thenIfErrored($callback3)->execute();
|
* $chain->then($callback1)->then($callback2)->thenIfErrored($callback3)->execute();
|
||||||
*
|
*
|
||||||
* WARNING: This class is experimental and designed specifically for use pre-startup.
|
* @internal This class is designed specifically for use pre-startup and may change without warning
|
||||||
* It will likely be heavily refactored before the release of 3.2
|
|
||||||
*/
|
*/
|
||||||
class ErrorControlChain
|
class ErrorControlChain
|
||||||
{
|
{
|
||||||
|
@ -12,6 +12,8 @@ use SilverStripe\Security\Security;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorates application bootstrapping with errorcontrolchain
|
* Decorates application bootstrapping with errorcontrolchain
|
||||||
|
*
|
||||||
|
* @internal This class is designed specifically for use pre-startup and may change without warning
|
||||||
*/
|
*/
|
||||||
class ErrorControlChainMiddleware implements HTTPMiddleware
|
class ErrorControlChainMiddleware implements HTTPMiddleware
|
||||||
{
|
{
|
||||||
@ -30,27 +32,42 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
|
|||||||
$this->application = $application;
|
$this->application = $application;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param HTTPRequest $request
|
||||||
|
* @return ConfirmationToken|null
|
||||||
|
*/
|
||||||
|
protected function prepareConfirmationTokenIfRequired(HTTPRequest $request)
|
||||||
|
{
|
||||||
|
$token = URLConfirmationToken::prepare_tokens(['dev/build'], $request);
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
$token = ParameterConfirmationToken::prepare_tokens(
|
||||||
|
['isTest', 'isDev', 'flush'],
|
||||||
|
$request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
public function process(HTTPRequest $request, callable $next)
|
public function process(HTTPRequest $request, callable $next)
|
||||||
{
|
{
|
||||||
$result = null;
|
$result = null;
|
||||||
|
|
||||||
// Prepare tokens and execute chain
|
// Prepare tokens and execute chain
|
||||||
$reloadToken = ParameterConfirmationToken::prepare_tokens(
|
$confirmationToken = $this->prepareConfirmationTokenIfRequired($request);
|
||||||
['isTest', 'isDev', 'flush'],
|
|
||||||
$request
|
|
||||||
);
|
|
||||||
$chain = new ErrorControlChain();
|
$chain = new ErrorControlChain();
|
||||||
$chain
|
$chain
|
||||||
->then(function () use ($request, $chain, $reloadToken, $next, &$result) {
|
->then(function () use ($request, $chain, $confirmationToken, $next, &$result) {
|
||||||
// If no redirection is necessary then we can disable error supression
|
// If no redirection is necessary then we can disable error supression
|
||||||
if (!$reloadToken) {
|
if (!$confirmationToken) {
|
||||||
$chain->setSuppression(false);
|
$chain->setSuppression(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if a token is requesting a redirect
|
// Check if a token is requesting a redirect
|
||||||
if ($reloadToken && $reloadToken->reloadRequired()) {
|
if ($confirmationToken && $confirmationToken->reloadRequired()) {
|
||||||
$result = $this->safeReloadWithToken($request, $reloadToken);
|
$result = $this->safeReloadWithToken($request, $confirmationToken);
|
||||||
} else {
|
} else {
|
||||||
// If no reload necessary, process application
|
// If no reload necessary, process application
|
||||||
$result = call_user_func($next, $request);
|
$result = call_user_func($next, $request);
|
||||||
@ -60,9 +77,9 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Finally if a token was requested but there was an error while figuring out if it's allowed, do it anyway
|
// Finally if a token was requested but there was an error while figuring out if it's allowed, do it anyway
|
||||||
->thenIfErrored(function () use ($reloadToken) {
|
->thenIfErrored(function () use ($confirmationToken) {
|
||||||
if ($reloadToken && $reloadToken->reloadRequiredIfError()) {
|
if ($confirmationToken && $confirmationToken->reloadRequiredIfError()) {
|
||||||
$result = $reloadToken->reloadWithToken();
|
$result = $confirmationToken->reloadWithToken();
|
||||||
$result->output();
|
$result->output();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -85,7 +102,7 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
|
|||||||
|
|
||||||
// Ensure session is started
|
// Ensure session is started
|
||||||
$request->getSession()->init($request);
|
$request->getSession()->init($request);
|
||||||
|
|
||||||
// Request with ErrorDirector
|
// Request with ErrorDirector
|
||||||
$result = ErrorDirector::singleton()->handleRequestWithToken(
|
$result = ErrorDirector::singleton()->handleRequestWithToken(
|
||||||
$request,
|
$request,
|
||||||
@ -98,7 +115,7 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
|
|||||||
|
|
||||||
// Fail and redirect the user to the login page
|
// Fail and redirect the user to the login page
|
||||||
$params = array_merge($request->getVars(), $reloadToken->params(false));
|
$params = array_merge($request->getVars(), $reloadToken->params(false));
|
||||||
$backURL = $request->getURL() . '?' . http_build_query($params);
|
$backURL = $reloadToken->currentURL() . '?' . http_build_query($params);
|
||||||
$loginPage = Director::absoluteURL(Security::config()->get('login_url'));
|
$loginPage = Director::absoluteURL(Security::config()->get('login_url'));
|
||||||
$loginPage .= "?BackURL=" . urlencode($backURL);
|
$loginPage .= "?BackURL=" . urlencode($backURL);
|
||||||
$result = new HTTPResponse();
|
$result = new HTTPResponse();
|
||||||
|
@ -21,11 +21,11 @@ class ErrorDirector extends Director
|
|||||||
* Redirect with token if allowed, or null if not allowed
|
* Redirect with token if allowed, or null if not allowed
|
||||||
*
|
*
|
||||||
* @param HTTPRequest $request
|
* @param HTTPRequest $request
|
||||||
* @param ParameterConfirmationToken $token
|
* @param ConfirmationToken $token
|
||||||
* @param Kernel $kernel
|
* @param Kernel $kernel
|
||||||
* @return null|HTTPResponse Redirection response, or null if not able to redirect
|
* @return null|HTTPResponse Redirection response, or null if not able to redirect
|
||||||
*/
|
*/
|
||||||
public function handleRequestWithToken(HTTPRequest $request, ParameterConfirmationToken $token, Kernel $kernel)
|
public function handleRequestWithToken(HTTPRequest $request, ConfirmationToken $token, Kernel $kernel)
|
||||||
{
|
{
|
||||||
Injector::inst()->registerService($request, HTTPRequest::class);
|
Injector::inst()->registerService($request, HTTPRequest::class);
|
||||||
|
|
||||||
|
@ -9,30 +9,21 @@ use SilverStripe\Core\Convert;
|
|||||||
use SilverStripe\Security\RandomGenerator;
|
use SilverStripe\Security\RandomGenerator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class ParameterConfirmationToken
|
* This is used to protect dangerous GET parameters that need to be detected early in the request
|
||||||
|
* lifecycle by generating a one-time-use token & redirecting with that token included in the
|
||||||
|
* redirected URL
|
||||||
*
|
*
|
||||||
* When you need to use a dangerous GET parameter that needs to be set before core/Core.php is
|
* @internal This class is designed specifically for use pre-startup and may change without warning
|
||||||
* established, this class takes care of allowing some other code of confirming the parameter,
|
|
||||||
* by generating a one-time-use token & redirecting with that token included in the redirected URL
|
|
||||||
*
|
|
||||||
* WARNING: This class is experimental and designed specifically for use pre-startup.
|
|
||||||
* It will likely be heavily refactored before the release of 3.2
|
|
||||||
*/
|
*/
|
||||||
class ParameterConfirmationToken
|
class ParameterConfirmationToken extends ConfirmationToken
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The name of the parameter
|
* The name of the parameter
|
||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $parameterName = null;
|
protected $parameterName = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var HTTPRequest
|
|
||||||
*/
|
|
||||||
protected $request = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The parameter given in the main request
|
* The parameter given in the main request
|
||||||
*
|
*
|
||||||
@ -48,60 +39,6 @@ class ParameterConfirmationToken
|
|||||||
protected $parameterBackURL = null;
|
protected $parameterBackURL = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The validated and checked token for this parameter
|
|
||||||
*
|
|
||||||
* @var string|null A string value, or null if either not provided or invalid
|
|
||||||
*/
|
|
||||||
protected $token = null;
|
|
||||||
|
|
||||||
protected function pathForToken($token)
|
|
||||||
{
|
|
||||||
return TEMP_PATH . DIRECTORY_SEPARATOR . 'token_' . preg_replace('/[^a-z0-9]+/', '', $token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a new random token and store it
|
|
||||||
*
|
|
||||||
* @return string Token name
|
|
||||||
*/
|
|
||||||
protected function genToken()
|
|
||||||
{
|
|
||||||
// Generate a new random token (as random as possible)
|
|
||||||
$rg = new RandomGenerator();
|
|
||||||
$token = $rg->randomToken('md5');
|
|
||||||
|
|
||||||
// Store a file in the session save path (safer than /tmp, as open_basedir might limit that)
|
|
||||||
file_put_contents($this->pathForToken($token), $token);
|
|
||||||
|
|
||||||
return $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a token
|
|
||||||
*
|
|
||||||
* @param string $token
|
|
||||||
* @return boolean True if the token is valid
|
|
||||||
*/
|
|
||||||
protected function checkToken($token)
|
|
||||||
{
|
|
||||||
if (!$token) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $this->pathForToken($token);
|
|
||||||
$content = null;
|
|
||||||
|
|
||||||
if (file_exists($file)) {
|
|
||||||
$content = file_get_contents($file);
|
|
||||||
unlink($file);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $content == $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new ParameterConfirmationToken
|
|
||||||
*
|
|
||||||
* @param string $parameterName Name of the querystring parameter to check
|
* @param string $parameterName Name of the querystring parameter to check
|
||||||
* @param HTTPRequest $request
|
* @param HTTPRequest $request
|
||||||
*/
|
*/
|
||||||
@ -176,54 +113,23 @@ class ParameterConfirmationToken
|
|||||||
return $this->parameterBackURL !== null;
|
return $this->parameterBackURL !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the necessary token provided for this parameter?
|
|
||||||
* A value must be provided for the token
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function tokenProvided()
|
|
||||||
{
|
|
||||||
return !empty($this->token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is this parameter requested without a valid token?
|
|
||||||
*
|
|
||||||
* @return bool True if the parameter is given without a valid token
|
|
||||||
*/
|
|
||||||
public function reloadRequired()
|
public function reloadRequired()
|
||||||
{
|
{
|
||||||
return $this->parameterProvided() && !$this->tokenProvided();
|
return $this->parameterProvided() && !$this->tokenProvided();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this token is provided either in the backurl, or directly,
|
|
||||||
* but without a token
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function reloadRequiredIfError()
|
public function reloadRequiredIfError()
|
||||||
{
|
{
|
||||||
// Don't reload if token exists
|
// Don't reload if token exists
|
||||||
return $this->reloadRequired() || $this->existsInReferer();
|
return $this->reloadRequired() || $this->existsInReferer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Suppress the current parameter by unsetting it from $_GET
|
|
||||||
*/
|
|
||||||
public function suppress()
|
public function suppress()
|
||||||
{
|
{
|
||||||
unset($_GET[$this->parameterName]);
|
unset($_GET[$this->parameterName]);
|
||||||
$this->request->offsetUnset($this->parameterName);
|
$this->request->offsetUnset($this->parameterName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the querystring parameters to include
|
|
||||||
*
|
|
||||||
* @param bool $includeToken Include the token value as well?
|
|
||||||
* @return array List of querystring parameters with name and token parameters
|
|
||||||
*/
|
|
||||||
public function params($includeToken = true)
|
public function params($includeToken = true)
|
||||||
{
|
{
|
||||||
$params = array(
|
$params = array(
|
||||||
@ -234,25 +140,7 @@ class ParameterConfirmationToken
|
|||||||
}
|
}
|
||||||
return $params;
|
return $params;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get redirect url, excluding querystring
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
protected function currentURL()
|
|
||||||
{
|
|
||||||
return Controller::join_links(
|
|
||||||
BASE_URL ?: '/',
|
|
||||||
$this->request->getURL(false)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get redirection URL
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
protected function redirectURL()
|
protected function redirectURL()
|
||||||
{
|
{
|
||||||
// If url is encoded via BackURL, defer to home page (prevent redirect to form action)
|
// If url is encoded via BackURL, defer to home page (prevent redirect to form action)
|
||||||
@ -267,48 +155,4 @@ class ParameterConfirmationToken
|
|||||||
// Merge get params with current url
|
// Merge get params with current url
|
||||||
return Controller::join_links($url, '?' . http_build_query($params));
|
return Controller::join_links($url, '?' . http_build_query($params));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Forces a reload of the request with the token included
|
|
||||||
*
|
|
||||||
* @return HTTPResponse
|
|
||||||
*/
|
|
||||||
public function reloadWithToken()
|
|
||||||
{
|
|
||||||
$location = $this->redirectURL();
|
|
||||||
$locationJS = Convert::raw2js($location);
|
|
||||||
$locationATT = Convert::raw2att($location);
|
|
||||||
$body = <<<HTML
|
|
||||||
<script>location.href='$locationJS';</script>
|
|
||||||
<noscript><meta http-equiv="refresh" content="0; url=$locationATT"></noscript>
|
|
||||||
You are being redirected. If you are not redirected soon, <a href="$locationATT">click here to continue the flush</a>
|
|
||||||
HTML;
|
|
||||||
|
|
||||||
// Build response
|
|
||||||
$result = new HTTPResponse($body);
|
|
||||||
$result->redirect($location);
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a list of token names, suppress all tokens that have not been validated, and
|
|
||||||
* return the non-validated token with the highest priority
|
|
||||||
*
|
|
||||||
* @param array $keys List of token keys in ascending priority (low to high)
|
|
||||||
* @param HTTPRequest $request
|
|
||||||
* @return ParameterConfirmationToken The token container for the unvalidated $key given with the highest priority
|
|
||||||
*/
|
|
||||||
public static function prepare_tokens($keys, HTTPRequest $request)
|
|
||||||
{
|
|
||||||
$target = null;
|
|
||||||
foreach ($keys as $key) {
|
|
||||||
$token = new ParameterConfirmationToken($key, $request);
|
|
||||||
// Validate this token
|
|
||||||
if ($token->reloadRequired() || $token->reloadRequiredIfError()) {
|
|
||||||
$token->suppress();
|
|
||||||
$target = $token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $target;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
136
src/Core/Startup/URLConfirmationToken.php
Normal file
136
src/Core/Startup/URLConfirmationToken.php
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Core\Startup;
|
||||||
|
|
||||||
|
use SilverStripe\Control\Controller;
|
||||||
|
use SilverStripe\Control\Director;
|
||||||
|
use SilverStripe\Control\HTTPRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is used to protect dangerous URLs that need to be detected early in the request lifecycle
|
||||||
|
* by generating a one-time-use token & redirecting with that token included in the redirected URL
|
||||||
|
*
|
||||||
|
* @internal This class is designed specifically for use pre-startup and may change without warning
|
||||||
|
*/
|
||||||
|
class URLConfirmationToken extends ConfirmationToken
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $urlToCheck;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $currentURL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $tokenParameterName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $urlExistsInBackURL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $urlToCheck URL to check
|
||||||
|
* @param HTTPRequest $request
|
||||||
|
*/
|
||||||
|
public function __construct($urlToCheck, HTTPRequest $request)
|
||||||
|
{
|
||||||
|
$this->urlToCheck = $urlToCheck;
|
||||||
|
$this->request = $request;
|
||||||
|
$this->currentURL = $request->getURL(false);
|
||||||
|
|
||||||
|
$this->tokenParameterName = preg_replace('/[^a-z0-9]/i', '', $urlToCheck) . 'token';
|
||||||
|
$this->urlExistsInBackURL = $this->getURLExistsInBackURL($request);
|
||||||
|
|
||||||
|
// If the token provided is valid, mark it as such
|
||||||
|
$token = $request->getVar($this->tokenParameterName);
|
||||||
|
if ($this->checkToken($token)) {
|
||||||
|
$this->token = $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param HTTPRequest $request
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function getURLExistsInBackURL(HTTPRequest $request)
|
||||||
|
{
|
||||||
|
$backURL = $request->getVar('BackURL');
|
||||||
|
return (strpos($backURL, $this->urlToCheck) === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function urlMatches()
|
||||||
|
{
|
||||||
|
return ($this->currentURL === $this->urlToCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getURLToCheck()
|
||||||
|
{
|
||||||
|
return $this->urlToCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function urlExistsInBackURL()
|
||||||
|
{
|
||||||
|
return $this->urlExistsInBackURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reloadRequired()
|
||||||
|
{
|
||||||
|
return $this->urlMatches() && !$this->tokenProvided();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reloadRequiredIfError()
|
||||||
|
{
|
||||||
|
return $this->reloadRequired() || $this->urlExistsInBackURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suppress()
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_URI'] = '/';
|
||||||
|
$this->request->setURL('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function params($includeToken = true)
|
||||||
|
{
|
||||||
|
$params = [];
|
||||||
|
if ($includeToken) {
|
||||||
|
$params[$this->tokenParameterName] = $this->genToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentURL()
|
||||||
|
{
|
||||||
|
return Controller::join_links(Director::baseURL(), $this->currentURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function redirectURL()
|
||||||
|
{
|
||||||
|
// If url is encoded via BackURL, defer to home page (prevent redirect to form action)
|
||||||
|
if ($this->urlExistsInBackURL && !$this->urlMatches()) {
|
||||||
|
$url = BASE_URL ?: '/';
|
||||||
|
$params = $this->params();
|
||||||
|
} else {
|
||||||
|
$url = $this->currentURL();
|
||||||
|
$params = array_merge($this->request->getVars(), $this->params());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge get params with current url
|
||||||
|
return Controller::join_links($url, '?' . http_build_query($params));
|
||||||
|
}
|
||||||
|
}
|
@ -73,4 +73,52 @@ class ErrorControlChainMiddlewareTest extends SapphireTest
|
|||||||
$this->assertNotContains('?flush=1&flushtoken=', $location);
|
$this->assertNotContains('?flush=1&flushtoken=', $location);
|
||||||
$this->assertContains('Security/login', $location);
|
$this->assertContains('Security/login', $location);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testLiveBuildAdmin()
|
||||||
|
{
|
||||||
|
// Mock admin
|
||||||
|
$adminID = $this->logInWithPermission('ADMIN');
|
||||||
|
$this->logOut();
|
||||||
|
|
||||||
|
// Mock app
|
||||||
|
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
|
||||||
|
$app->getKernel()->setEnvironment(Kernel::LIVE);
|
||||||
|
|
||||||
|
// Test being logged in as admin
|
||||||
|
$chain = new ErrorControlChainMiddleware($app);
|
||||||
|
$request = new HTTPRequest('GET', '/dev/build/');
|
||||||
|
$request->setSession(new Session(['loggedInAs' => $adminID]));
|
||||||
|
$result = $chain->process($request, function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertInstanceOf(HTTPResponse::class, $result);
|
||||||
|
$location = $result->getHeader('Location');
|
||||||
|
$this->assertContains('/dev/build', $location);
|
||||||
|
$this->assertContains('?devbuildtoken=', $location);
|
||||||
|
$this->assertNotContains('Security/login', $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLiveBuildUnauthenticated()
|
||||||
|
{
|
||||||
|
// Mock app
|
||||||
|
$app = new HTTPApplication(new BlankKernel(BASE_PATH));
|
||||||
|
$app->getKernel()->setEnvironment(Kernel::LIVE);
|
||||||
|
|
||||||
|
// Test being logged in as no one
|
||||||
|
Security::setCurrentUser(null);
|
||||||
|
$chain = new ErrorControlChainMiddleware($app);
|
||||||
|
$request = new HTTPRequest('GET', '/dev/build');
|
||||||
|
$request->setSession(new Session(['loggedInAs' => 0]));
|
||||||
|
$result = $chain->process($request, function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be directed to login, not to flush
|
||||||
|
$this->assertInstanceOf(HTTPResponse::class, $result);
|
||||||
|
$location = $result->getHeader('Location');
|
||||||
|
$this->assertNotContains('/dev/build', $location);
|
||||||
|
$this->assertNotContains('?devbuildtoken=', $location);
|
||||||
|
$this->assertContains('Security/login', $location);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -149,14 +149,14 @@ class ParameterConfirmationTokenTest extends SapphireTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* currentAbsoluteURL needs to handle base or url being missing, or any combination of slashes.
|
* currentURL needs to handle base or url being missing, or any combination of slashes.
|
||||||
*
|
*
|
||||||
* There should always be exactly one slash between each part in the result, and any trailing slash
|
* There should always be exactly one slash between each part in the result, and any trailing slash
|
||||||
* should be preserved.
|
* should be preserved.
|
||||||
*
|
*
|
||||||
* @dataProvider dataProviderURLs
|
* @dataProvider dataProviderURLs
|
||||||
*/
|
*/
|
||||||
public function testCurrentAbsoluteURLHandlesSlashes($url)
|
public function testCurrentURLHandlesSlashes($url)
|
||||||
{
|
{
|
||||||
$this->request->setUrl($url);
|
$this->request->setUrl($url);
|
||||||
|
|
||||||
|
148
tests/php/Core/Startup/URLConfirmationTokenTest.php
Normal file
148
tests/php/Core/Startup/URLConfirmationTokenTest.php
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Core\Tests\Startup;
|
||||||
|
|
||||||
|
use SilverStripe\Control\Controller;
|
||||||
|
use SilverStripe\Control\HTTPRequest;
|
||||||
|
use SilverStripe\Control\Session;
|
||||||
|
use SilverStripe\Core\Startup\URLConfirmationToken;
|
||||||
|
use SilverStripe\Core\Tests\Startup\URLConfirmationTokenTest\StubToken;
|
||||||
|
use SilverStripe\Core\Tests\Startup\URLConfirmationTokenTest\StubValidToken;
|
||||||
|
use SilverStripe\Dev\SapphireTest;
|
||||||
|
|
||||||
|
class URLConfirmationTokenTest extends SapphireTest
|
||||||
|
{
|
||||||
|
public function testValidToken()
|
||||||
|
{
|
||||||
|
$request = new HTTPRequest('GET', 'token/test/url', ['tokentesturltoken' => 'value']);
|
||||||
|
$validToken = new StubValidToken('token/test/url', $request);
|
||||||
|
$this->assertTrue($validToken->urlMatches());
|
||||||
|
$this->assertFalse($validToken->urlExistsInBackURL());
|
||||||
|
$this->assertTrue($validToken->tokenProvided()); // Actually forced to true for this test
|
||||||
|
$this->assertFalse($validToken->reloadRequired());
|
||||||
|
$this->assertFalse($validToken->reloadRequiredIfError());
|
||||||
|
$this->assertStringStartsWith(Controller::join_links(BASE_URL, '/', 'token/test/url'), $validToken->redirectURL());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenWithLeadingSlashInUrl()
|
||||||
|
{
|
||||||
|
$request = new HTTPRequest('GET', '/leading/slash/url', []);
|
||||||
|
$leadingSlash = new StubToken('leading/slash/url', $request);
|
||||||
|
$this->assertTrue($leadingSlash->urlMatches());
|
||||||
|
$this->assertFalse($leadingSlash->urlExistsInBackURL());
|
||||||
|
$this->assertFalse($leadingSlash->tokenProvided());
|
||||||
|
$this->assertTrue($leadingSlash->reloadRequired());
|
||||||
|
$this->assertTrue($leadingSlash->reloadRequiredIfError());
|
||||||
|
$this->assertContains('leading/slash/url', $leadingSlash->redirectURL());
|
||||||
|
$this->assertContains('leadingslashurltoken', $leadingSlash->redirectURL());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenWithTrailingSlashInUrl()
|
||||||
|
{
|
||||||
|
$request = new HTTPRequest('GET', 'trailing/slash/url/', []);
|
||||||
|
$trailingSlash = new StubToken('trailing/slash/url', $request);
|
||||||
|
$this->assertTrue($trailingSlash->urlMatches());
|
||||||
|
$this->assertFalse($trailingSlash->urlExistsInBackURL());
|
||||||
|
$this->assertFalse($trailingSlash->tokenProvided());
|
||||||
|
$this->assertTrue($trailingSlash->reloadRequired());
|
||||||
|
$this->assertTrue($trailingSlash->reloadRequiredIfError());
|
||||||
|
$this->assertContains('trailing/slash/url', $trailingSlash->redirectURL());
|
||||||
|
$this->assertContains('trailingslashurltoken', $trailingSlash->redirectURL());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTokenWithUrlMatchedInBackUrl()
|
||||||
|
{
|
||||||
|
$request = new HTTPRequest('GET', '/', ['BackURL' => 'back/url']);
|
||||||
|
$backUrl = new StubToken('back/url', $request);
|
||||||
|
$this->assertFalse($backUrl->urlMatches());
|
||||||
|
$this->assertTrue($backUrl->urlExistsInBackURL());
|
||||||
|
$this->assertFalse($backUrl->tokenProvided());
|
||||||
|
$this->assertFalse($backUrl->reloadRequired());
|
||||||
|
$this->assertTrue($backUrl->reloadRequiredIfError());
|
||||||
|
$home = (BASE_URL ?: '/') . '?';
|
||||||
|
$this->assertStringStartsWith($home, $backUrl->redirectURL());
|
||||||
|
$this->assertContains('backurltoken', $backUrl->redirectURL());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUrlSuppressionWhenTokenMissing()
|
||||||
|
{
|
||||||
|
// Check suppression
|
||||||
|
$request = new HTTPRequest('GET', 'test/url', []);
|
||||||
|
$token = new StubToken('test/url', $request);
|
||||||
|
$this->assertEquals('test/url', $request->getURL(false));
|
||||||
|
$token->suppress();
|
||||||
|
$this->assertEquals('', $request->getURL(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPrepareTokens()
|
||||||
|
{
|
||||||
|
$request = new HTTPRequest('GET', 'test/url', []);
|
||||||
|
$token = URLConfirmationToken::prepare_tokens(
|
||||||
|
[
|
||||||
|
'test/url',
|
||||||
|
'test',
|
||||||
|
'url'
|
||||||
|
],
|
||||||
|
$request
|
||||||
|
);
|
||||||
|
// Test no invalid tokens
|
||||||
|
$this->assertEquals('test/url', $token->getURLToCheck());
|
||||||
|
$this->assertNotEquals('test/url', $request->getURL(false), 'prepare_tokens() did not suppress URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPrepareTokensDoesntSuppressWhenNotMatched()
|
||||||
|
{
|
||||||
|
$request = new HTTPRequest('GET', 'test/url', []);
|
||||||
|
$token = URLConfirmationToken::prepare_tokens(
|
||||||
|
['another/url'],
|
||||||
|
$request
|
||||||
|
);
|
||||||
|
$this->assertEmpty($token);
|
||||||
|
$this->assertEquals('test/url', $request->getURL(false), 'prepare_tokens() incorrectly suppressed URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPrepareTokensWithUrlMatchedInBackUrl()
|
||||||
|
{
|
||||||
|
// Test backurl token
|
||||||
|
$request = new HTTPRequest('GET', '/', ['BackURL' => 'back/url']);
|
||||||
|
$token = URLConfirmationToken::prepare_tokens(
|
||||||
|
[ 'back/url' ],
|
||||||
|
$request
|
||||||
|
);
|
||||||
|
$this->assertNotEmpty($token);
|
||||||
|
$this->assertEquals('back/url', $token->getURLToCheck());
|
||||||
|
$this->assertNotEquals('back/url', $request->getURL(false), 'prepare_tokens() did not suppress URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dataProviderURLs()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[''],
|
||||||
|
['/'],
|
||||||
|
['bar'],
|
||||||
|
['bar/'],
|
||||||
|
['/bar'],
|
||||||
|
['/bar/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* currentURL needs to handle base or url being missing, or any combination of slashes.
|
||||||
|
*
|
||||||
|
* There should always be exactly one slash between each part in the result, and any trailing slash
|
||||||
|
* should be preserved.
|
||||||
|
*
|
||||||
|
* @dataProvider dataProviderURLs
|
||||||
|
*/
|
||||||
|
public function testCurrentURLHandlesSlashes($url)
|
||||||
|
{
|
||||||
|
$request = new HTTPRequest('GET', $url, []);
|
||||||
|
|
||||||
|
$token = new StubToken(
|
||||||
|
'another/url',
|
||||||
|
$request
|
||||||
|
);
|
||||||
|
$expected = rtrim(Controller::join_links(BASE_URL, '/', $url), '/') ?: '/';
|
||||||
|
$this->assertEquals($expected, $token->currentURL(), "Invalid redirect for request url $url");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Core\Tests\Startup\URLConfirmationTokenTest;
|
||||||
|
|
||||||
|
use SilverStripe\Core\Startup\URLConfirmationToken;
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy url token
|
||||||
|
*/
|
||||||
|
class StubToken extends URLConfirmationToken implements TestOnly
|
||||||
|
{
|
||||||
|
public function urlMatches()
|
||||||
|
{
|
||||||
|
return parent::urlMatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentURL()
|
||||||
|
{
|
||||||
|
return parent::currentURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redirectURL()
|
||||||
|
{
|
||||||
|
return parent::redirectURL();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\Core\Tests\Startup\URLConfirmationTokenTest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A token that always validates a given token
|
||||||
|
*/
|
||||||
|
class StubValidToken extends StubToken
|
||||||
|
{
|
||||||
|
|
||||||
|
protected function checkToken($token)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user