[SS-2018-019] Add confirmation token to dev/build

This commit is contained in:
Loz Calver 2018-08-21 11:20:15 +01:00 committed by Aaron Carlino
parent 3c58ae009e
commit 3dbb10625c
12 changed files with 600 additions and 184 deletions

View File

@ -41,7 +41,7 @@ class HTTPApplication implements Application
*/
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
return $this->execute($request, function (HTTPRequest $request) {

View 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();
}

View File

@ -15,8 +15,7 @@ use Exception;
* $chain = new ErrorControlChain();
* $chain->then($callback1)->then($callback2)->thenIfErrored($callback3)->execute();
*
* WARNING: This class is experimental and designed specifically for use pre-startup.
* It will likely be heavily refactored before the release of 3.2
* @internal This class is designed specifically for use pre-startup and may change without warning
*/
class ErrorControlChain
{

View File

@ -12,6 +12,8 @@ use SilverStripe\Security\Security;
/**
* Decorates application bootstrapping with errorcontrolchain
*
* @internal This class is designed specifically for use pre-startup and may change without warning
*/
class ErrorControlChainMiddleware implements HTTPMiddleware
{
@ -30,27 +32,42 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
$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)
{
$result = null;
// Prepare tokens and execute chain
$reloadToken = ParameterConfirmationToken::prepare_tokens(
['isTest', 'isDev', 'flush'],
$request
);
$confirmationToken = $this->prepareConfirmationTokenIfRequired($request);
$chain = new ErrorControlChain();
$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 (!$reloadToken) {
if (!$confirmationToken) {
$chain->setSuppression(false);
}
try {
// Check if a token is requesting a redirect
if ($reloadToken && $reloadToken->reloadRequired()) {
$result = $this->safeReloadWithToken($request, $reloadToken);
if ($confirmationToken && $confirmationToken->reloadRequired()) {
$result = $this->safeReloadWithToken($request, $confirmationToken);
} else {
// If no reload necessary, process application
$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
->thenIfErrored(function () use ($reloadToken) {
if ($reloadToken && $reloadToken->reloadRequiredIfError()) {
$result = $reloadToken->reloadWithToken();
->thenIfErrored(function () use ($confirmationToken) {
if ($confirmationToken && $confirmationToken->reloadRequiredIfError()) {
$result = $confirmationToken->reloadWithToken();
$result->output();
}
})
@ -85,7 +102,7 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
// Ensure session is started
$request->getSession()->init($request);
// Request with ErrorDirector
$result = ErrorDirector::singleton()->handleRequestWithToken(
$request,
@ -98,7 +115,7 @@ class ErrorControlChainMiddleware implements HTTPMiddleware
// Fail and redirect the user to the login page
$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 .= "?BackURL=" . urlencode($backURL);
$result = new HTTPResponse();

View File

@ -21,11 +21,11 @@ class ErrorDirector extends Director
* Redirect with token if allowed, or null if not allowed
*
* @param HTTPRequest $request
* @param ParameterConfirmationToken $token
* @param ConfirmationToken $token
* @param Kernel $kernel
* @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);

View File

@ -9,30 +9,21 @@ use SilverStripe\Core\Convert;
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
* 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
* @internal This class is designed specifically for use pre-startup and may change without warning
*/
class ParameterConfirmationToken
class ParameterConfirmationToken extends ConfirmationToken
{
/**
* The name of the parameter
*
* @var string
*/
protected $parameterName = null;
/**
* @var HTTPRequest
*/
protected $request = null;
/**
* The parameter given in the main request
*
@ -48,60 +39,6 @@ class ParameterConfirmationToken
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 HTTPRequest $request
*/
@ -176,54 +113,23 @@ class ParameterConfirmationToken
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()
{
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()
{
// Don't reload if token exists
return $this->reloadRequired() || $this->existsInReferer();
}
/**
* Suppress the current parameter by unsetting it from $_GET
*/
public function suppress()
{
unset($_GET[$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)
{
$params = array(
@ -234,25 +140,7 @@ class ParameterConfirmationToken
}
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()
{
// 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
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;
}
}

View 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));
}
}

View File

@ -73,4 +73,52 @@ class ErrorControlChainMiddlewareTest extends SapphireTest
$this->assertNotContains('?flush=1&flushtoken=', $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);
}
}

View File

@ -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
* should be preserved.
*
* @dataProvider dataProviderURLs
*/
public function testCurrentAbsoluteURLHandlesSlashes($url)
public function testCurrentURLHandlesSlashes($url)
{
$this->request->setUrl($url);

View 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");
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}