API Storing alternative DB name in cookie rather than session

Session is not initialized by the time we need to use
the setting in DB::connect(). Cookie values get initialized
automatically for each request.

Tightened name format validation to ensure it can only
be used for temporary databases, rather than switching
the browser session to a different production database.

Encrypting token for secure cookie usage.
Added dev/generatesecuretoken to generate this token.
Not storing in YML config directly because of web access issues.
This commit is contained in:
Ingo Schommer 2012-12-06 16:25:45 +01:00
parent 7be8a2252f
commit c6b1d4aa6b
6 changed files with 181 additions and 32 deletions

View File

@ -18,15 +18,16 @@ class DevelopmentAdmin extends Controller {
); );
static $allowed_actions = array( static $allowed_actions = array(
'index', 'index',
'tests', 'tests',
'jstests', 'jstests',
'tasks', 'tasks',
'viewmodel', 'viewmodel',
'build', 'build',
'reset', 'reset',
'viewcode' 'viewcode',
); 'generatesecuretoken',
);
public function init() { public function init() {
parent::init(); parent::init();
@ -56,7 +57,7 @@ class DevelopmentAdmin extends Controller {
$matched = false; $matched = false;
if(isset($_FILE_TO_URL_MAPPING[$testPath])) { if(isset($_FILE_TO_URL_MAPPING[$testPath])) {
$matched = true; $matched = true;
break; break;
} }
$testPath = dirname($testPath); $testPath = dirname($testPath);
} }
@ -172,6 +173,25 @@ class DevelopmentAdmin extends Controller {
} }
} }
/**
* Generate a secure token which can be used as a crypto key.
* Returns the token and suggests PHP configuration to set it.
*/
public function generatesecuretoken() {
$generator = Injector::inst()->create('RandomGenerator');
$token = $generator->randomToken('sha1');
echo <<<TXT
Token: $token
Please add this to your mysite/_config.php with the following code:
Config::inst()->update('Security', 'token', '$token');
TXT;
}
public function reset() { public function reset() {
$link = BASE_URL.'/dev/tests/startsession'; $link = BASE_URL.'/dev/tests/startsession';

View File

@ -349,6 +349,9 @@ class TestRunner extends Controller {
* See {@link setdb()} for an alternative approach which just sets a database * See {@link setdb()} for an alternative approach which just sets a database
* name, and is used for more advanced use cases like interacting with test databases * name, and is used for more advanced use cases like interacting with test databases
* directly during functional tests. * directly during functional tests.
*
* Requires PHP's mycrypt extension in order to set the database name
* as an encrypted cookie.
*/ */
public function startsession() { public function startsession() {
if(!Director::isLive()) { if(!Director::isLive()) {
@ -420,7 +423,7 @@ HTML;
} }
/** /**
* Set an alternative database name in the current browser session. * Set an alternative database name in the current browser session as a cookie.
* Useful for functional testing libraries like behat to create a "clean slate". * Useful for functional testing libraries like behat to create a "clean slate".
* Does not actually create the database, that's usually handled * Does not actually create the database, that's usually handled
* by {@link SapphireTest::create_temp_db()}. * by {@link SapphireTest::create_temp_db()}.
@ -432,33 +435,33 @@ HTML;
* *
* See {@link startsession()} for a different approach which actually creates * See {@link startsession()} for a different approach which actually creates
* the DB and loads a fixture file instead. * the DB and loads a fixture file instead.
*
* Requires PHP's mycrypt extension in order to set the database name
* as an encrypted cookie.
*/ */
public function setdb() { public function setdb() {
if(Director::isLive()) { if(Director::isLive()) {
return $this->permissionFailure("dev/tests/setdb can only be used on dev and test sites"); return $this->httpError(403, "dev/tests/setdb can only be used on dev and test sites");
} }
if(!isset($_GET['database'])) { if(!isset($_GET['database'])) {
return $this->permissionFailure("dev/tests/setdb must be used with a 'database' parameter"); return $this->httpError(400, "dev/tests/setdb must be used with a 'database' parameter");
} }
$database_name = $_GET['database']; $name = $_GET['database'];
$prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_'; $prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_';
$pattern = strtolower(sprintf('#^%stmpdb\d{7}#', $prefix)); $pattern = strtolower(sprintf('#^%stmpdb\d{7}#', $prefix));
if(!preg_match($pattern, $database_name)) { if($name && !preg_match($pattern, $name)) {
return $this->permissionFailure("Invalid database name format"); return $this->httpError(400, "Invalid database name format");
} }
DB::set_alternative_database_name($database_name); DB::set_alternative_database_name($name);
return "<p>Set database session to '$database_name'. Time to start testing; where would you like to start?</p> if($name) {
<ul> return "<p>Set database session to '$name'.</p>";
<li><a id=\"home-link\" href=\"" .Director::baseURL() . "\">Homepage - published site</a></li> } else {
<li><a id=\"draft-link\" href=\"" .Director::baseURL() . "?stage=Stage\">Homepage - draft site</a></li> return "<p>Unset database session.</p>";
<li><a id=\"admin-link\" href=\"" .Director::baseURL() . "admin/\">CMS Admin</a></li> }
<li><a id=\"endsession-link\" href=\"" .Director::baseURL() . "dev/tests/endsession\">
End your test session</a></li>
</ul>";
} }
public function emptydb() { public function emptydb() {

View File

@ -0,0 +1,12 @@
# 3.0.4
## Overview
* Changed `dev/tests/setdb` and `dev/tests/startsession` from session to cookie storage.
## Upgrading
* If you are using `dev/tests/setdb` and `dev/tests/startsession`,
you'll need to configure a secure token in order to encrypt the cookie value:
Simply run `sake dev/generatesecuretoken` and add the resulting code to your `mysite/_config.php`.
Note that this functionality now requires the PHP `mcrypt` extension.

View File

@ -60,19 +60,89 @@ class DB {
} }
/** /**
* Set an alternative database to use for this browser session. * Set an alternative database in a browser cookie,
* This is useful when using testing systems other than SapphireTest; for example, Windmill. * with the cookie lifetime set to the browser session.
* This is useful for integration testing on temporary databases.
*
* There is a strict naming convention for temporary databases to avoid abuse:
* <prefix> (default: 'ss_') + tmpdb + <7 digits>
* As an additional security measure, temporary databases will
* be ignored in "live" mode.
*
* Note that the database will be set on the next request.
* Set it to null to revert to the main database. * Set it to null to revert to the main database.
*/ */
public static function set_alternative_database_name($dbname) { public static function set_alternative_database_name($name = null) {
Session::set("alternativeDatabaseName", $dbname); if($name) {
if(!self::valid_alternative_database_name($name)) {
throw new InvalidArgumentException(sprintf(
'Invalid alternative database name: "%s"',
$name
));
}
$key = Config::inst()->get('Security', 'token');
if(!$key) {
throw new LogicException('"Security.token" not found, run "sake dev/generatesecuretoken"');
}
if(!function_exists('mcrypt_encrypt')) {
throw new LogicException('DB::set_alternative_database_name() requires the mcrypt PHP extension');
}
$key = md5($key); // Ensure key is correct length for chosen cypher
$ivSize = mcrypt_get_iv_size(MCRYPT_RIJNDAEL_256, MCRYPT_MODE_CFB);
$iv = mcrypt_create_iv($ivSize);
$encrypted = mcrypt_encrypt(
MCRYPT_RIJNDAEL_256, $key, $name, MCRYPT_MODE_CFB, $iv
);
// Set to browser session lifetime, and restricted to HTTP access only
Cookie::set("alternativeDatabaseName", base64_encode($encrypted), 0, null, null, false, true);
Cookie::set("alternativeDatabaseNameIv", base64_encode($iv), 0, null, null, false, true);
} else {
Cookie::set("alternativeDatabaseName", null, 0, null, null, false, true);
Cookie::set("alternativeDatabaseNameIv", null, 0, null, null, false, true);
}
} }
/** /**
* Get the name of the database in use * Get the name of the database in use
*/ */
public static function get_alternative_database_name() { public static function get_alternative_database_name() {
return Session::get("alternativeDatabaseName"); $name = Cookie::get("alternativeDatabaseName");
$iv = Cookie::get("alternativeDatabaseNameIv");
if($name) {
$key = Config::inst()->get('Security', 'token');
if(!$key) {
throw new LogicException('"Security.token" not found, run "sake dev/generatesecuretoken"');
}
if(!function_exists('mcrypt_encrypt')) {
throw new LogicException('DB::set_alternative_database_name() requires the mcrypt PHP extension');
}
$key = md5($key); // Ensure key is correct length for chosen cypher
$decrypted = mcrypt_decrypt(
MCRYPT_RIJNDAEL_256, $key, base64_decode($name), MCRYPT_MODE_CFB, base64_decode($iv)
);
return (self::valid_alternative_database_name($decrypted)) ? $decrypted : false;
} else {
return false;
}
}
/**
* Determines if the name is valid, as a security
* measure against setting arbitrary databases.
*
* @param String $name
* @return Boolean
*/
public static function valid_alternative_database_name($name) {
if(Director::isLive()) return false;
$prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_';
$pattern = strtolower(sprintf('/^%stmpdb\d{7}$/', $prefix));
return (bool)preg_match($pattern, $name);
} }
/** /**
@ -84,7 +154,7 @@ class DB {
*/ */
public static function connect($databaseConfig) { public static function connect($databaseConfig) {
// This is used by TestRunner::startsession() to test up a test session using an alt // This is used by TestRunner::startsession() to test up a test session using an alt
if($name = Session::get('alternativeDatabaseName')) { if($name = self::get_alternative_database_name()) {
$databaseConfig['database'] = $name; $databaseConfig['database'] = $name;
} }

View File

@ -82,6 +82,14 @@ class Security extends Controller {
* @var array|string * @var array|string
*/ */
protected static $default_message_set = ''; protected static $default_message_set = '';
/**
* Random secure token, can be used as a crypto key internally.
* Generate one through 'sake dev/generatesecuretoken'.
*
* @var String
*/
public static $token;
/** /**
* Get location of word list file * Get location of word list file

36
tests/model/DBTest.php Normal file
View File

@ -0,0 +1,36 @@
<?php
/**
* @package framework
* @subpackage tests
*/
class DBTest extends SapphireTest {
protected $origEnvType;
function setUp() {
$this->origEnvType = Director::get_environment_type();
Director::set_environment_type('dev');
parent::setUp();
}
function tearDown() {
Director::set_environment_type($this->origEnvType);
parent::tearDown();
}
function testValidAlternativeDatabaseName() {
$this->assertTrue(DB::valid_alternative_database_name('ss_tmpdb1234567'));
$this->assertFalse(DB::valid_alternative_database_name('ss_tmpdb12345678'));
$this->assertFalse(DB::valid_alternative_database_name('tmpdb1234567'));
$this->assertFalse(DB::valid_alternative_database_name('random'));
$this->assertFalse(DB::valid_alternative_database_name(''));
$origEnvType = Director::get_environment_type();
Director::set_environment_type('live');
$this->assertFalse(DB::valid_alternative_database_name('ss_tmpdb1234567'));
Director::set_environment_type($origEnvType);
}
}