mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
Merge pull request #2246 from hafriedlander/fix/flush_24
FIX Prevent DOS by checking for env and admin on ?flush=1 (#1692) in 2.4
This commit is contained in:
commit
b774db43fb
@ -173,6 +173,7 @@ define('ASSETS_PATH', BASE_PATH . '/' . ASSETS_DIR);
|
||||
/**
|
||||
* Define the temporary folder if it wasn't defined yet
|
||||
*/
|
||||
require_once('core/TempPath.php');
|
||||
if(!defined('TEMP_FOLDER')) {
|
||||
define('TEMP_FOLDER', getTempFolder());
|
||||
}
|
||||
@ -254,65 +255,6 @@ Debug::loadErrorHandlers();
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
|
||||
function getSysTempDir() {
|
||||
if(function_exists('sys_get_temp_dir')) {
|
||||
$sysTmp = sys_get_temp_dir();
|
||||
} elseif(isset($_ENV['TMP'])) {
|
||||
$sysTmp = $_ENV['TMP'];
|
||||
} else {
|
||||
$tmpFile = tempnam('adfadsfdas','');
|
||||
unlink($tmpFile);
|
||||
$sysTmp = dirname($tmpFile);
|
||||
}
|
||||
return $sysTmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the temporary folder that sapphire/silverstripe should use for its cache files
|
||||
* This is loaded into the TEMP_FOLDER define on start up
|
||||
*
|
||||
* @param $base The base path to use as the basis for the temp folder name. Defaults to BASE_PATH,
|
||||
* which is usually fine; however, the $base argument can be used to help test.
|
||||
*/
|
||||
function getTempFolder($base = null) {
|
||||
if(!$base) $base = BASE_PATH;
|
||||
|
||||
if($base) {
|
||||
$cachefolder = "silverstripe-cache" . str_replace(array(' ', "/", ":", "\\"), "-", $base);
|
||||
} else {
|
||||
$cachefolder = "silverstripe-cache";
|
||||
}
|
||||
|
||||
$ssTmp = BASE_PATH . "/silverstripe-cache";
|
||||
if(@file_exists($ssTmp)) {
|
||||
return $ssTmp;
|
||||
}
|
||||
|
||||
$sysTmp = getSysTempDir();
|
||||
$worked = true;
|
||||
$ssTmp = "$sysTmp/$cachefolder";
|
||||
|
||||
if(!@file_exists($ssTmp)) {
|
||||
@$worked = mkdir($ssTmp);
|
||||
}
|
||||
|
||||
if(!$worked) {
|
||||
$ssTmp = BASE_PATH . "/silverstripe-cache";
|
||||
$worked = true;
|
||||
if(!@file_exists($ssTmp)) {
|
||||
@$worked = mkdir($ssTmp);
|
||||
}
|
||||
}
|
||||
|
||||
if(!$worked) {
|
||||
user_error("Permission problem gaining access to a temp folder. " .
|
||||
"Please create a folder named silverstripe-cache in the base folder " .
|
||||
"of the installation and ensure it has the correct permissions", E_USER_ERROR);
|
||||
}
|
||||
|
||||
return $ssTmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the file where that class is stored.
|
||||
*
|
||||
|
60
core/TempPath.php
Normal file
60
core/TempPath.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
function getSysTempDir() {
|
||||
if(function_exists('sys_get_temp_dir')) {
|
||||
$sysTmp = sys_get_temp_dir();
|
||||
} elseif(isset($_ENV['TMP'])) {
|
||||
$sysTmp = $_ENV['TMP'];
|
||||
} else {
|
||||
$tmpFile = tempnam('adfadsfdas','');
|
||||
unlink($tmpFile);
|
||||
$sysTmp = dirname($tmpFile);
|
||||
}
|
||||
return $sysTmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the temporary folder that sapphire/silverstripe should use for its cache files
|
||||
* This is loaded into the TEMP_FOLDER define on start up
|
||||
*
|
||||
* @param $base The base path to use as the basis for the temp folder name. Defaults to BASE_PATH,
|
||||
* which is usually fine; however, the $base argument can be used to help test.
|
||||
*/
|
||||
function getTempFolder($base = null) {
|
||||
if(!$base) $base = BASE_PATH;
|
||||
|
||||
if($base) {
|
||||
$cachefolder = "silverstripe-cache" . str_replace(array(' ', "/", ":", "\\"), "-", $base);
|
||||
} else {
|
||||
$cachefolder = "silverstripe-cache";
|
||||
}
|
||||
|
||||
$ssTmp = BASE_PATH . "/silverstripe-cache";
|
||||
if(@file_exists($ssTmp)) {
|
||||
return $ssTmp;
|
||||
}
|
||||
|
||||
$sysTmp = getSysTempDir();
|
||||
$worked = true;
|
||||
$ssTmp = "$sysTmp/$cachefolder";
|
||||
|
||||
if(!@file_exists($ssTmp)) {
|
||||
@$worked = mkdir($ssTmp);
|
||||
}
|
||||
|
||||
if(!$worked) {
|
||||
$ssTmp = BASE_PATH . "/silverstripe-cache";
|
||||
$worked = true;
|
||||
if(!@file_exists($ssTmp)) {
|
||||
@$worked = mkdir($ssTmp);
|
||||
}
|
||||
}
|
||||
|
||||
if(!$worked) {
|
||||
user_error("Permission problem gaining access to a temp folder. " .
|
||||
"Please create a folder named silverstripe-cache in the base folder " .
|
||||
"of the installation and ensure it has the correct permissions", E_USER_ERROR);
|
||||
}
|
||||
|
||||
return $ssTmp;
|
||||
}
|
121
core/startup/ErrorControlChain.php
Normal file
121
core/startup/ErrorControlChain.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Class ErrorControlChain
|
||||
*
|
||||
* Runs a set of steps, optionally suppressing (but recording) any errors (even fatal ones) that occur in each step.
|
||||
* If an error does occur, subsequent steps are normally skipped, but can optionally be run anyway
|
||||
*
|
||||
* Normal errors are suppressed even past the end of the chain. Fatal errors are only suppressed until the end
|
||||
* of the chain - the request will then die silently.
|
||||
*
|
||||
* The exception is if an error occurs and BASE_URL is not yet set - in that case the error is never suppressed.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* $chain = new ErrorControlChain();
|
||||
* $chain->then($callback1)->then($callback2)->then(true, $callback3)->execute();
|
||||
*
|
||||
* WARNING: This class is experimental and designed specifically for use pre-startup in main.php
|
||||
* It will likely be heavily refactored before the release of 3.2
|
||||
*/
|
||||
class ErrorControlChain {
|
||||
protected $error = false;
|
||||
protected $steps = array();
|
||||
|
||||
protected $suppression = true;
|
||||
|
||||
/** We can't unregister_shutdown_function, so this acts as a flag to enable handling */
|
||||
protected $handleFatalErrors = false;
|
||||
|
||||
public function hasErrored() {
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
public function setErrored($error) {
|
||||
$this->error = (bool)$error;
|
||||
}
|
||||
|
||||
public function setSuppression($suppression) {
|
||||
$this->suppression = (bool)$suppression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add this callback to the chain of callbacks to call along with the state
|
||||
* that $error must be in this point in the chain for the callback to be called
|
||||
*
|
||||
* @param $callback - The callback to call
|
||||
* @param $onErrorState - false if only call if no errors yet, true if only call if already errors, null for either
|
||||
* @return $this
|
||||
*/
|
||||
public function then($callback, $onErrorState = false) {
|
||||
$this->steps[] = array(
|
||||
'callback' => $callback,
|
||||
'onErrorState' => $onErrorState
|
||||
);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function thenWhileGood($callback) {
|
||||
return $this->then($callback, false);
|
||||
}
|
||||
|
||||
public function thenIfErrored($callback) {
|
||||
return $this->then($callback, true);
|
||||
}
|
||||
|
||||
public function thenAlways($callback) {
|
||||
return $this->then($callback, null);
|
||||
}
|
||||
|
||||
public function handleError() {
|
||||
if ($this->suppression && defined('BASE_URL')) throw new Exception('Generic Error');
|
||||
else return false;
|
||||
}
|
||||
|
||||
protected function lastErrorWasFatal() {
|
||||
$error = error_get_last();
|
||||
return $error && $error['type'] == 1;
|
||||
}
|
||||
|
||||
public function handleFatalError() {
|
||||
if ($this->handleFatalErrors && $this->suppression && defined('BASE_URL')) {
|
||||
if ($this->lastErrorWasFatal()) {
|
||||
ob_clean();
|
||||
$this->error = true;
|
||||
$this->step();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function execute() {
|
||||
set_error_handler(array($this, 'handleError'), error_reporting());
|
||||
register_shutdown_function(array($this, 'handleFatalError'));
|
||||
$this->handleFatalErrors = true;
|
||||
|
||||
$this->step();
|
||||
}
|
||||
|
||||
protected function step() {
|
||||
if ($this->steps) {
|
||||
$step = array_shift($this->steps);
|
||||
|
||||
if ($step['onErrorState'] === null || $step['onErrorState'] === $this->error) {
|
||||
try {
|
||||
call_user_func($step['callback'], $this);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
if ($this->suppression && defined('BASE_URL')) $this->error = true;
|
||||
else throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$this->step();
|
||||
}
|
||||
else {
|
||||
// Now clean up
|
||||
$this->handleFatalErrors = false;
|
||||
restore_error_handler();
|
||||
}
|
||||
}
|
||||
}
|
113
core/startup/ParameterConfirmationToken.php
Normal file
113
core/startup/ParameterConfirmationToken.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Class ParameterConfirmationToken
|
||||
*
|
||||
* 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 in main.php
|
||||
* It will likely be heavily refactored before the release of 3.2
|
||||
*/
|
||||
class ParameterConfirmationToken {
|
||||
protected $parameterName = null;
|
||||
protected $parameter = null;
|
||||
protected $token = null;
|
||||
|
||||
protected function pathForToken($token) {
|
||||
if (defined('BASE_PATH')) {
|
||||
$basepath = BASE_PATH;
|
||||
}
|
||||
else {
|
||||
$basepath = rtrim(dirname(dirname(dirname(dirname(__FILE__)))), DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
require_once('core/TempPath.php');
|
||||
$tempfolder = getTempFolder($basepath ? $basepath : DIRECTORY_SEPARATOR);
|
||||
|
||||
return $tempfolder.'/token_'.preg_replace('/[^a-z0-9]+/', '', $token);
|
||||
}
|
||||
|
||||
protected function genToken() {
|
||||
// Generate a new random token (as random as possible)
|
||||
require_once('security/RandomGenerator.php');
|
||||
$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;
|
||||
}
|
||||
|
||||
protected function checkToken($token) {
|
||||
$file = $this->pathForToken($token);
|
||||
$content = null;
|
||||
|
||||
if (file_exists($file)) {
|
||||
$content = file_get_contents($file);
|
||||
unlink($file);
|
||||
}
|
||||
|
||||
return $content == $token;
|
||||
}
|
||||
|
||||
public function __construct($parameterName) {
|
||||
// Store the parameter name
|
||||
$this->parameterName = $parameterName;
|
||||
// Store the parameter value
|
||||
$this->parameter = isset($_GET[$parameterName]) ? $_GET[$parameterName] : null;
|
||||
// Store the token
|
||||
$this->token = isset($_GET[$parameterName.'token']) ? $_GET[$parameterName.'token'] : null;
|
||||
|
||||
// If a token was provided, but isn't valid, just throw a 403
|
||||
if ($this->token && (!$this->checkToken($this->token))) {
|
||||
header("HTTP/1.0 403 Forbidden", true, 403);
|
||||
die;
|
||||
}
|
||||
}
|
||||
|
||||
public function parameterProvided() {
|
||||
return $this->parameter !== null;
|
||||
}
|
||||
|
||||
public function tokenProvided() {
|
||||
return $this->token !== null;
|
||||
}
|
||||
|
||||
public function params() {
|
||||
return array(
|
||||
$this->parameterName => $this->parameter,
|
||||
$this->parameterName.'token' => $this->genToken()
|
||||
);
|
||||
}
|
||||
|
||||
public function reloadWithToken() {
|
||||
global $url;
|
||||
|
||||
// Are we http or https?
|
||||
$proto = 'http';
|
||||
|
||||
if(isset($_SERVER['HTTP_X_FORWARDED_PROTOCOL'])) {
|
||||
if(strtolower($_SERVER['HTTP_X_FORWARDED_PROTOCOL']) == 'https') $proto = 'https';
|
||||
}
|
||||
|
||||
if((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off')) $proto = 'https';
|
||||
if(isset($_SERVER['SSL'])) $proto = 'https';
|
||||
|
||||
// What's our host
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
|
||||
// What's our GET params (ensuring they include the original parameter + a new token)
|
||||
$params = array_merge($_GET, $this->params());
|
||||
unset($params['url']);
|
||||
|
||||
// Join them all together into the original URL
|
||||
$location = "$proto://" . $host . BASE_URL . $url . ($params ? '?'.http_build_query($params) : '');
|
||||
|
||||
// And redirect
|
||||
header('location: '.$location, true, 302);
|
||||
die;
|
||||
}
|
||||
}
|
23
docs/en/changelogs/2.4.11.md
Normal file
23
docs/en/changelogs/2.4.11.md
Normal file
@ -0,0 +1,23 @@
|
||||
# 2.4.11 (Not yet released)
|
||||
|
||||
## Overview
|
||||
|
||||
* Security: Require ADMIN for `?flush=1` (stop denial of service attacks)
|
||||
([#1692](https://github.com/silverstripe/silverstripe-framework/issues/1692))
|
||||
|
||||
## Details
|
||||
|
||||
### Security: Require ADMIN for ?flush=1 and ?flush=all
|
||||
|
||||
Flushing the various manifests (class, template, config) is performed through a GET
|
||||
parameter (`flush=1`). Since this action requires more server resources than normal requests,
|
||||
it can facilitate [denial-of-service attacks](https://en.wikipedia.org/wiki/Denial-of-service_attack).
|
||||
|
||||
To prevent this, main.php now checks and only allows the flush parameter in the following cases:
|
||||
|
||||
* The [environment](/topics/environment-management) is in "dev mode"
|
||||
* A user is logged in with ADMIN permissions
|
||||
* An error occurs during startup
|
||||
|
||||
This applies to both `flush=1` and `flush=all`but only through web requests made through main.php - CLI requests,
|
||||
or any other request that goes through a custom start up script will still process all flush requests as normal.
|
162
main.php
162
main.php
@ -54,55 +54,129 @@ if(version_compare(phpversion(), 5, '<')) {
|
||||
* @see Director::direct()
|
||||
*/
|
||||
|
||||
require_once('core/startup/ErrorControlChain.php');
|
||||
require_once('core/startup/ParameterConfirmationToken.php');
|
||||
|
||||
/**
|
||||
* Include Sapphire's core code
|
||||
*/
|
||||
require_once("core/Core.php");
|
||||
class SilverStripeMain {
|
||||
static $token;
|
||||
|
||||
if (function_exists('mb_http_output')) {
|
||||
mb_http_output('UTF-8');
|
||||
mb_internal_encoding('UTF-8');
|
||||
}
|
||||
static function filterFlush($chain) {
|
||||
self::$token = new ParameterConfirmationToken('flush');
|
||||
|
||||
Session::start();
|
||||
|
||||
// IIS will sometimes generate this.
|
||||
if(!empty($_SERVER['HTTP_X_ORIGINAL_URL'])) {
|
||||
$_SERVER['REQUEST_URI'] = $_SERVER['HTTP_X_ORIGINAL_URL'];
|
||||
}
|
||||
|
||||
// Apache rewrite rules use this
|
||||
if (isset($_GET['url'])) {
|
||||
$url = $_GET['url'];
|
||||
// IIS includes get variables in url
|
||||
$i = strpos($url, '?');
|
||||
if($i !== false) {
|
||||
$url = substr($url, 0, $i);
|
||||
if (isset($_GET['flush']) && !self::$token->tokenProvided()) {
|
||||
unset($_GET['flush']);
|
||||
}
|
||||
else {
|
||||
$chain->setSuppression(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Lighttpd uses this
|
||||
} else {
|
||||
if(strpos($_SERVER['REQUEST_URI'],'?') !== false) {
|
||||
list($url, $query) = explode('?', $_SERVER['REQUEST_URI'], 2);
|
||||
parse_str($query, $_GET);
|
||||
if ($_GET) $_REQUEST = array_merge((array)$_REQUEST, (array)$_GET);
|
||||
} else {
|
||||
$url = $_SERVER["REQUEST_URI"];
|
||||
|
||||
static function includeCore() {
|
||||
/**
|
||||
* Include Sapphire's core code
|
||||
*/
|
||||
require_once("core/Core.php");
|
||||
|
||||
if (function_exists('mb_http_output')) {
|
||||
mb_http_output('UTF-8');
|
||||
mb_internal_encoding('UTF-8');
|
||||
}
|
||||
|
||||
Session::start();
|
||||
}
|
||||
|
||||
static function parseURL() {
|
||||
global $url;
|
||||
|
||||
// IIS will sometimes generate this.
|
||||
if(!empty($_SERVER['HTTP_X_ORIGINAL_URL'])) {
|
||||
$_SERVER['REQUEST_URI'] = $_SERVER['HTTP_X_ORIGINAL_URL'];
|
||||
}
|
||||
|
||||
// Apache rewrite rules use this
|
||||
if (isset($_GET['url'])) {
|
||||
$url = $_GET['url'];
|
||||
// IIS includes get variables in url
|
||||
$i = strpos($url, '?');
|
||||
if($i !== false) {
|
||||
$url = substr($url, 0, $i);
|
||||
}
|
||||
|
||||
// Lighttpd uses this
|
||||
} else {
|
||||
if(strpos($_SERVER['REQUEST_URI'],'?') !== false) {
|
||||
list($url, $query) = explode('?', $_SERVER['REQUEST_URI'], 2);
|
||||
parse_str($query, $_GET);
|
||||
if ($_GET) $_REQUEST = array_merge((array)$_REQUEST, (array)$_GET);
|
||||
} else {
|
||||
$url = $_SERVER["REQUEST_URI"];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove base folders from the URL if webroot is hosted in a subfolder
|
||||
if (substr(strtolower($url), 0, strlen(BASE_URL)) == strtolower(BASE_URL)) $url = substr($url, strlen(BASE_URL));
|
||||
}
|
||||
|
||||
static function startupDatabase() {
|
||||
if (isset($_GET['debug_profile'])) {
|
||||
Profiler::init();
|
||||
Profiler::mark('all_execution');
|
||||
Profiler::mark('main.php init');
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
require_once("core/model/DB.php");
|
||||
global $databaseConfig;
|
||||
|
||||
if (isset($_GET['debug_profile'])) Profiler::mark('DB::connect');
|
||||
if ($databaseConfig) DB::connect($databaseConfig);
|
||||
if (isset($_GET['debug_profile'])) Profiler::unmark('DB::connect');
|
||||
}
|
||||
|
||||
static function flushIfAllowed() {
|
||||
if (self::$token->parameterProvided() && !self::$token->tokenProvided()) {
|
||||
// First, check if we're in dev mode, or the database doesn't have any security data
|
||||
$canFlush = Director::isDev() || !Security::database_is_ready();
|
||||
|
||||
// Otherwise, we start up the session if needed, then check for admin
|
||||
if (!$canFlush) {
|
||||
if(!isset($_SESSION) && (isset($_COOKIE[session_name()]) || isset($_REQUEST[session_name()]))) {
|
||||
Session::start();
|
||||
}
|
||||
|
||||
if (Permission::check('ADMIN')) {
|
||||
$canFlush = true;
|
||||
}
|
||||
else {
|
||||
$loginPage = Director::absoluteURL(Config::inst()->get('Security', 'login_url'));
|
||||
$loginPage .= "?BackURL=" . urlencode($_SERVER['REQUEST_URI']);
|
||||
|
||||
header('location: '.$loginPage, true, 302);
|
||||
die;
|
||||
}
|
||||
}
|
||||
|
||||
// And if we can flush, reload with an authority token
|
||||
if ($canFlush) self::$token->reloadWithToken();
|
||||
}
|
||||
}
|
||||
|
||||
static function flushIfErrored() {
|
||||
if (self::$token->parameterProvided() && !self::$token->tokenProvided()) {
|
||||
self::$token->reloadWithToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove base folders from the URL if webroot is hosted in a subfolder
|
||||
if (substr(strtolower($url), 0, strlen(BASE_URL)) == strtolower(BASE_URL)) $url = substr($url, strlen(BASE_URL));
|
||||
|
||||
if (isset($_GET['debug_profile'])) {
|
||||
Profiler::init();
|
||||
Profiler::mark('all_execution');
|
||||
Profiler::mark('main.php init');
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
require_once("core/model/DB.php");
|
||||
$chain = new ErrorControlChain();
|
||||
$chain
|
||||
->then(array('SilverStripeMain', 'filterFlush'))
|
||||
->then(array('SilverStripeMain', 'includeCore'))
|
||||
->thenAlways(array('SilverStripeMain', 'parseURL'))
|
||||
->then(array('SilverStripeMain', 'startupDatabase'))
|
||||
->then(array('SilverStripeMain', 'flushIfAllowed'))
|
||||
->thenIfErrored(array('SilverStripeMain', 'flushIfErrored'))
|
||||
->execute();
|
||||
|
||||
// Redirect to the installer if no database is selected
|
||||
if(!isset($databaseConfig) || !isset($databaseConfig['database']) || !$databaseConfig['database']) {
|
||||
@ -117,10 +191,6 @@ if(!isset($databaseConfig) || !isset($databaseConfig['database']) || !$databaseC
|
||||
die();
|
||||
}
|
||||
|
||||
if (isset($_GET['debug_profile'])) Profiler::mark('DB::connect');
|
||||
DB::connect($databaseConfig);
|
||||
if (isset($_GET['debug_profile'])) Profiler::unmark('DB::connect');
|
||||
|
||||
if (isset($_GET['debug_profile'])) Profiler::unmark('main.php init');
|
||||
|
||||
// Direct away - this is the "main" function, that hands control to the appropriate controller
|
||||
|
Loading…
Reference in New Issue
Block a user