diff --git a/CHANGELOG.txt b/CHANGELOG.txt index f9b010c58..dcef8ea97 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,14 @@ +Drupal 7.80, 2021-04-20 +----------------------- +- Fixed security issues: + - SA-CORE-2021-002 + +Drupal 7.79, 2021-04-07 +----------------------- +- Initial support for PHP 8 +- Support for SameSite cookie attribute +- Avoid write for unchanged fields (opt-in) + Drupal 7.78, 2021-01-19 ----------------------- - Fixed security issues: diff --git a/includes/bootstrap.inc b/includes/bootstrap.inc index 2b6d7ff48..f9202da9f 100644 --- a/includes/bootstrap.inc +++ b/includes/bootstrap.inc @@ -8,7 +8,7 @@ /** * The current system version. */ -define('VERSION', '7.78'); +define('VERSION', '7.80'); /** * Core API compatibility. @@ -2596,13 +2596,10 @@ function drupal_get_hash_salt() { * The filename that the error was raised in. * @param $line * The line number the error was raised at. - * @param $context - * An array that points to the active symbol table at the point the error - * occurred. */ -function _drupal_error_handler($error_level, $message, $filename, $line, $context) { +function _drupal_error_handler($error_level, $message, $filename, $line) { require_once DRUPAL_ROOT . '/includes/errors.inc'; - _drupal_error_handler_real($error_level, $message, $filename, $line, $context); + _drupal_error_handler_real($error_level, $message, $filename, $line); } /** @@ -3879,3 +3876,85 @@ function drupal_clear_opcode_cache($filepath) { @apc_delete_file($filepath); } } + +/** + * Drupal's wrapper around PHP's setcookie() function. + * + * This allows the cookie's $value and $options to be altered. + * + * @param $name + * The name of the cookie. + * @param $value + * The value of the cookie. + * @param $options + * An associative array which may have any of the keys expires, path, domain, + * secure, httponly, samesite. + * + * @see setcookie() + * @ingroup php_wrappers + */ +function drupal_setcookie($name, $value, $options) { + $options = _drupal_cookie_params($options); + if (\PHP_VERSION_ID >= 70300) { + setcookie($name, $value, $options); + } + else { + setcookie($name, $value, $options['expires'], $options['path'], $options['domain'], $options['secure'], $options['httponly']); + } +} + +/** + * Process the params for cookies. This emulates support for the SameSite + * attribute in earlier versions of PHP, and allows the value of that attribute + * to be overridden. + * + * @param $options + * An associative array which may have any of the keys expires, path, domain, + * secure, httponly, samesite. + * + * @return + * An associative array which may have any of the keys expires, path, domain, + * secure, httponly, and samesite. + */ +function _drupal_cookie_params($options) { + $options['samesite'] = _drupal_samesite_cookie($options); + if (\PHP_VERSION_ID < 70300) { + // Emulate SameSite support in older PHP versions. + if (!empty($options['samesite'])) { + // Ensure the SameSite attribute is only added once. + if (!preg_match('/SameSite=/i', $options['path'])) { + $options['path'] .= '; SameSite=' . $options['samesite']; + } + } + } + return $options; +} + +/** + * Determine the value for the samesite cookie attribute, in the following order + * of precedence: + * + * 1) A value explicitly passed to drupal_setcookie() + * 2) A value set in $conf['samesite_cookie_value'] + * 3) The setting from php ini + * 4) The default of None, or FALSE (no attribute) if the cookie is not Secure + * + * @param $options + * An associative array as passed to drupal_setcookie(). + * @return + * The value for the samesite cookie attribute. + */ +function _drupal_samesite_cookie($options) { + if (isset($options['samesite'])) { + return $options['samesite']; + } + $override = variable_get('samesite_cookie_value', NULL); + if ($override !== NULL) { + return $override; + } + $ini_options = session_get_cookie_params(); + if (isset($ini_options['samesite'])) { + return $ini_options['samesite']; + } + return empty($options['secure']) ? FALSE : 'None'; +} diff --git a/includes/common.inc b/includes/common.inc index 7b7955855..690c00478 100644 --- a/includes/common.inc +++ b/includes/common.inc @@ -1559,7 +1559,7 @@ function _filter_xss_split($m, $store = FALSE) { return '<'; } - if (!preg_match('%^<\s*(/\s*)?([a-zA-Z0-9\-]+)([^>]*)>?|()$%', $string, $matches)) { + if (!preg_match('%^<\s*(/\s*)?([a-zA-Z0-9\-]+)\s*([^>]*)>?|()$%', $string, $matches)) { // Seriously malformed. return ''; } @@ -1618,7 +1618,13 @@ function _filter_xss_attributes($attr) { // Attribute name, href for instance. if (preg_match('/^([-a-zA-Z]+)/', $attr, $match)) { $attrname = strtolower($match[1]); - $skip = ($attrname == 'style' || substr($attrname, 0, 2) == 'on'); + $skip = ( + $attrname == 'style' || + substr($attrname, 0, 2) == 'on' || + substr($attrname, 0, 1) == '-' || + // Ignore long attributes to avoid unnecessary processing overhead. + strlen($attrname) > 96 + ); $working = $mode = 1; $attr = preg_replace('/^[-a-zA-Z]+/', '', $attr); } @@ -2329,6 +2335,7 @@ function url($path = NULL, array $options = array()) { } elseif (!empty($path) && !$options['alias']) { $language = isset($options['language']) && isset($options['language']->language) ? $options['language']->language : ''; + require_once DRUPAL_ROOT . '/' . variable_get('path_inc', 'includes/path.inc'); $alias = drupal_get_path_alias($original_path, $language); if ($alias != $original_path) { // Strip leading slashes from internal path aliases to prevent them @@ -5166,6 +5173,8 @@ function drupal_build_js_cache($files) { $contents .= file_get_contents($path) . ";\n"; } } + // Remove JS source and source mapping urls or these may cause 404 errors. + $contents = preg_replace('/\/\/(#|@)\s(sourceURL|sourceMappingURL)=\s*(\S*?)\s*$/m', '', $contents); // Prefix filename to prevent blocking by firewalls which reject files // starting with "ad*". $filename = 'js_' . drupal_hash_base64($contents) . '.js'; diff --git a/includes/database/database.inc b/includes/database/database.inc index d4d2d8f02..61ac44f78 100644 --- a/includes/database/database.inc +++ b/includes/database/database.inc @@ -184,7 +184,7 @@ * * @see http://php.net/manual/book.pdo.php */ -abstract class DatabaseConnection extends PDO { +abstract class DatabaseConnection { /** * The database target this connection is for. @@ -261,6 +261,13 @@ abstract class DatabaseConnection extends PDO { */ protected $temporaryNameIndex = 0; + /** + * The actual PDO connection. + * + * @var \PDO + */ + protected $connection; + /** * The connection information for this connection object. * @@ -325,14 +332,27 @@ abstract class DatabaseConnection extends PDO { $driver_options[PDO::ATTR_ERRMODE] = PDO::ERRMODE_EXCEPTION; // Call PDO::__construct and PDO::setAttribute. - parent::__construct($dsn, $username, $password, $driver_options); + $this->connection = new PDO($dsn, $username, $password, $driver_options); // Set a Statement class, unless the driver opted out. if (!empty($this->statementClass)) { - $this->setAttribute(PDO::ATTR_STATEMENT_CLASS, array($this->statementClass, array($this))); + $this->connection->setAttribute(PDO::ATTR_STATEMENT_CLASS, array($this->statementClass, array($this))); } } + /** + * Proxy possible direct calls to the \PDO methods. + * + * Since PHP8.0 the signature of the the \PDO::query() method has changed, + * and this class can't extending \PDO any more. + * + * However, for the BC, proxy any calls to the \PDO methods to the actual + * PDO connection object. + */ + public function __call($name, $arguments) { + return call_user_func_array(array($this->connection, $name), $arguments); + } + /** * Destroys this Connection object. * @@ -346,7 +366,7 @@ abstract class DatabaseConnection extends PDO { // The Statement class attribute only accepts a new value that presents a // proper callable, so we reset it to PDOStatement. if (!empty($this->statementClass)) { - $this->setAttribute(PDO::ATTR_STATEMENT_CLASS, array('PDOStatement', array())); + $this->connection->setAttribute(PDO::ATTR_STATEMENT_CLASS, array('PDOStatement', array())); } $this->schema = NULL; } @@ -521,7 +541,7 @@ abstract class DatabaseConnection extends PDO { $query = $this->prefixTables($query); // Call PDO::prepare. - return parent::prepare($query); + return $this->connection->prepare($query); } /** @@ -733,7 +753,7 @@ abstract class DatabaseConnection extends PDO { case Database::RETURN_AFFECTED: return $stmt->rowCount(); case Database::RETURN_INSERT_ID: - return $this->lastInsertId(); + return $this->connection->lastInsertId(); case Database::RETURN_NULL: return; default: @@ -1116,7 +1136,7 @@ abstract class DatabaseConnection extends PDO { $rolled_back_other_active_savepoints = TRUE; } } - parent::rollBack(); + $this->connection->rollBack(); if ($rolled_back_other_active_savepoints) { throw new DatabaseTransactionOutOfOrderException(); } @@ -1144,7 +1164,7 @@ abstract class DatabaseConnection extends PDO { $this->query('SAVEPOINT ' . $name); } else { - parent::beginTransaction(); + $this->connection->beginTransaction(); } $this->transactionLayers[$name] = $name; } @@ -1195,7 +1215,7 @@ abstract class DatabaseConnection extends PDO { // If there are no more layers left then we should commit. unset($this->transactionLayers[$name]); if (empty($this->transactionLayers)) { - if (!parent::commit()) { + if (!$this->connection->commit()) { throw new DatabaseTransactionCommitFailedException(); } } @@ -1279,7 +1299,7 @@ abstract class DatabaseConnection extends PDO { * Returns the version of the database server. */ public function version() { - return $this->getAttribute(PDO::ATTR_SERVER_VERSION); + return $this->connection->getAttribute(PDO::ATTR_SERVER_VERSION); } /** @@ -1724,12 +1744,16 @@ abstract class Database { * * @param $key * The connection key. + * @param $close + * Whether to close the connection. * @return * TRUE in case of success, FALSE otherwise. */ - final public static function removeConnection($key) { + final public static function removeConnection($key, $close = TRUE) { if (isset(self::$databaseInfo[$key])) { - self::closeConnection(NULL, $key); + if ($close) { + self::closeConnection(NULL, $key); + } unset(self::$databaseInfo[$key]); return TRUE; } diff --git a/includes/database/mysql/database.inc b/includes/database/mysql/database.inc index 00df3c13e..b83611198 100644 --- a/includes/database/mysql/database.inc +++ b/includes/database/mysql/database.inc @@ -345,10 +345,10 @@ class DatabaseConnection_mysql extends DatabaseConnection { // certain one has been set; otherwise, MySQL defaults to 'utf8_general_ci' // for UTF-8. if (!empty($connection_options['collation'])) { - $this->exec('SET NAMES ' . $charset . ' COLLATE ' . $connection_options['collation']); + $this->connection->exec('SET NAMES ' . $charset . ' COLLATE ' . $connection_options['collation']); } else { - $this->exec('SET NAMES ' . $charset); + $this->connection->exec('SET NAMES ' . $charset); } // Set MySQL init_commands if not already defined. Default Drupal's MySQL @@ -366,7 +366,7 @@ class DatabaseConnection_mysql extends DatabaseConnection { $sql_mode = 'REAL_AS_FLOAT,PIPES_AS_CONCAT,ANSI_QUOTES,IGNORE_SPACE,STRICT_TRANS_TABLES,STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO'; // NO_AUTO_CREATE_USER was removed in MySQL 8.0.11 // https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-11.html#mysqld-8-0-11-deprecation-removal - if (version_compare($this->getAttribute(PDO::ATTR_SERVER_VERSION), '8.0.11', '<')) { + if (version_compare($this->connection->getAttribute(PDO::ATTR_SERVER_VERSION), '8.0.11', '<')) { $sql_mode .= ',NO_AUTO_CREATE_USER'; } $connection_options['init_commands'] += array( @@ -375,7 +375,7 @@ class DatabaseConnection_mysql extends DatabaseConnection { // Execute initial commands. foreach ($connection_options['init_commands'] as $sql) { - $this->exec($sql); + $this->connection->exec($sql); } } @@ -536,7 +536,7 @@ class DatabaseConnection_mysql extends DatabaseConnection { // If there are no more layers left then we should commit. unset($this->transactionLayers[$name]); if (empty($this->transactionLayers)) { - if (!PDO::commit()) { + if (!$this->doCommit()) { throw new DatabaseTransactionCommitFailedException(); } } @@ -559,7 +559,7 @@ class DatabaseConnection_mysql extends DatabaseConnection { $this->transactionLayers = array(); // We also have to explain to PDO that the transaction stack has // been cleaned-up. - PDO::commit(); + $this->doCommit(); } else { throw $e; @@ -569,6 +569,53 @@ class DatabaseConnection_mysql extends DatabaseConnection { } } + /** + * Do the actual commit, including a workaround for PHP 8 behaviour changes. + * + * @return bool + * Success or otherwise of the commit. + */ + protected function doCommit() { + if ($this->connection->inTransaction()) { + return $this->connection->commit(); + } + else { + // In PHP 8.0 a PDOException is thrown when a commit is attempted with no + // transaction active. In previous PHP versions this failed silently. + return TRUE; + } + } + + /** + * {@inheritdoc} + */ + public function rollback($savepoint_name = 'drupal_transaction') { + // MySQL will automatically commit transactions when tables are altered or + // created (DDL transactions are not supported). Prevent triggering an + // exception to ensure that the error that has caused the rollback is + // properly reported. + if (!$this->connection->inTransaction()) { + // Before PHP 8 $this->connection->inTransaction() will return TRUE and + // $this->connection->rollback() does not throw an exception; the + // following code is unreachable. + + // If \DatabaseConnection::rollback() would throw an + // exception then continue to throw an exception. + if (!$this->inTransaction()) { + throw new DatabaseTransactionNoActiveException(); + } + // A previous rollback to an earlier savepoint may mean that the savepoint + // in question has already been accidentally committed. + if (!isset($this->transactionLayers[$savepoint_name])) { + throw new DatabaseTransactionNoActiveException(); + } + + trigger_error('Rollback attempted when there is no active transaction. This can cause data integrity issues.', E_USER_WARNING); + return; + } + return parent::rollback($savepoint_name); + } + public function utf8mb4IsConfigurable() { return TRUE; } @@ -579,7 +626,7 @@ class DatabaseConnection_mysql extends DatabaseConnection { public function utf8mb4IsSupported() { // Ensure that the MySQL driver supports utf8mb4 encoding. - $version = $this->getAttribute(PDO::ATTR_CLIENT_VERSION); + $version = $this->connection->getAttribute(PDO::ATTR_CLIENT_VERSION); if (strpos($version, 'mysqlnd') !== FALSE) { // The mysqlnd driver supports utf8mb4 starting at version 5.0.9. $version = preg_replace('/^\D+([\d.]+).*/', '$1', $version); diff --git a/includes/database/pgsql/database.inc b/includes/database/pgsql/database.inc index fb3d0ab51..96ffc1d3e 100644 --- a/includes/database/pgsql/database.inc +++ b/includes/database/pgsql/database.inc @@ -66,11 +66,11 @@ class DatabaseConnection_pgsql extends DatabaseConnection { parent::__construct($dsn, $connection_options['username'], $connection_options['password'], $connection_options['pdo']); // Force PostgreSQL to use the UTF-8 character set by default. - $this->exec("SET NAMES 'UTF8'"); + $this->connection->exec("SET NAMES 'UTF8'"); // Execute PostgreSQL init_commands. if (isset($connection_options['init_commands'])) { - $this->exec(implode('; ', $connection_options['init_commands'])); + $this->connection->exec(implode('; ', $connection_options['init_commands'])); } } @@ -117,7 +117,7 @@ class DatabaseConnection_pgsql extends DatabaseConnection { case Database::RETURN_AFFECTED: return $stmt->rowCount(); case Database::RETURN_INSERT_ID: - return $this->lastInsertId($options['sequence_name']); + return $this->connection->lastInsertId($options['sequence_name']); case Database::RETURN_NULL: return; default: diff --git a/includes/database/select.inc b/includes/database/select.inc index 84098bdf7..674c6b53a 100644 --- a/includes/database/select.inc +++ b/includes/database/select.inc @@ -964,7 +964,7 @@ class SelectQuery extends Query implements SelectQueryInterface { */ protected $forUpdate = FALSE; - public function __construct($table, $alias = NULL, DatabaseConnection $connection, $options = array()) { + public function __construct($table, $alias, DatabaseConnection $connection, $options = array()) { $options['return'] = Database::RETURN_STATEMENT; parent::__construct($connection, $options); $this->where = new DatabaseCondition('AND'); diff --git a/includes/database/sqlite/database.inc b/includes/database/sqlite/database.inc index c50f08ec5..2cf83ccb5 100644 --- a/includes/database/sqlite/database.inc +++ b/includes/database/sqlite/database.inc @@ -121,7 +121,7 @@ class DatabaseConnection_sqlite extends DatabaseConnection { // Execute sqlite init_commands. if (isset($connection_options['init_commands'])) { - $this->exec(implode('; ', $connection_options['init_commands'])); + $this->connection->exec(implode('; ', $connection_options['init_commands'])); } } @@ -259,7 +259,7 @@ class DatabaseConnection_sqlite extends DatabaseConnection { * expose this function to the world. */ public function PDOPrepare($query, array $options = array()) { - return parent::prepare($query, $options); + return $this->connection->prepare($query, $options); } public function queryRange($query, $from, $count, array $args = array(), array $options = array()) { @@ -350,7 +350,7 @@ class DatabaseConnection_sqlite extends DatabaseConnection { } } if ($this->supportsTransactions()) { - PDO::rollBack(); + $this->connection->rollBack(); } } @@ -365,7 +365,7 @@ class DatabaseConnection_sqlite extends DatabaseConnection { throw new DatabaseTransactionNameNonUniqueException($name . " is already in use."); } if (!$this->inTransaction()) { - PDO::beginTransaction(); + $this->connection->beginTransaction(); } $this->transactionLayers[$name] = $name; } @@ -390,9 +390,9 @@ class DatabaseConnection_sqlite extends DatabaseConnection { // If there was any rollback() we should roll back whole transaction. if ($this->willRollback) { $this->willRollback = FALSE; - PDO::rollBack(); + $this->connection->rollBack(); } - elseif (!PDO::commit()) { + elseif (!$this->connection->commit()) { throw new DatabaseTransactionCommitFailedException(); } } diff --git a/includes/errors.inc b/includes/errors.inc index 3548d1fd8..4401ebe87 100644 --- a/includes/errors.inc +++ b/includes/errors.inc @@ -48,11 +48,8 @@ function drupal_error_levels() { * The filename that the error was raised in. * @param $line * The line number the error was raised at. - * @param $context - * An array that points to the active symbol table at the point the error - * occurred. */ -function _drupal_error_handler_real($error_level, $message, $filename, $line, $context) { +function _drupal_error_handler_real($error_level, $message, $filename, $line) { if ($error_level & error_reporting()) { $types = drupal_error_levels(); list($severity_msg, $severity_level) = $types[$error_level]; diff --git a/includes/menu.inc b/includes/menu.inc index 22e6dba97..0f38d6f50 100644 --- a/includes/menu.inc +++ b/includes/menu.inc @@ -317,7 +317,7 @@ define('MENU_PREFERRED_LINK', '1cf698d64d1aa4b83907cf6ed55db3a7f8e92c91'); * actually exists. This list of 'masks' is built in menu_rebuild(). * * @param $parts - * An array of path parts; for the above example, + * An array of path parts; for the above example, * array('node', '12345', 'edit'). * * @return @@ -2595,7 +2595,7 @@ function menu_get_active_breadcrumb() { // Don't show a link to the current page in the breadcrumb trail. $end = end($active_trail); - if ($item['href'] == $end['href']) { + if (is_array($end) && $item['href'] == $end['href']) { array_pop($active_trail); } diff --git a/includes/session.inc b/includes/session.inc index 11c77c4c1..a4ce54b7d 100644 --- a/includes/session.inc +++ b/includes/session.inc @@ -284,6 +284,20 @@ function drupal_session_start() { // Save current session data before starting it, as PHP will destroy it. $session_data = isset($_SESSION) ? $_SESSION : NULL; + // Apply any overrides to the session cookie params. + $params = $original_params = session_get_cookie_params(); + // PHP settings for samesite will be handled by _drupal_cookie_params(). + unset($params['samesite']); + $params = _drupal_cookie_params($params); + if ($params !== $original_params) { + if (\PHP_VERSION_ID >= 70300) { + session_set_cookie_params($params); + } + else { + session_set_cookie_params($params['lifetime'], $params['path'], $params['domain'], $params['secure'], $params['httponly']); + } + } + session_start(); drupal_session_started(TRUE); @@ -323,7 +337,14 @@ function drupal_session_commit() { $insecure_session_name = substr(session_name(), 1); $params = session_get_cookie_params(); $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0; - setcookie($insecure_session_name, $_COOKIE[$insecure_session_name], $expire, $params['path'], $params['domain'], FALSE, $params['httponly']); + $options = array( + 'expires' => $expire, + 'path' => $params['path'], + 'domain' => $params['domain'], + 'secure' => FALSE, + 'httponly' => $params['httponly'], + ); + drupal_setcookie($insecure_session_name, $_COOKIE[$insecure_session_name], $options); } } // Write the session data. @@ -365,7 +386,14 @@ function drupal_session_regenerate() { // $params['lifetime'] seconds from the current request. If it is not set, // it will expire when the browser is closed. $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0; - setcookie($insecure_session_name, $session_id, $expire, $params['path'], $params['domain'], FALSE, $params['httponly']); + $options = array( + 'expires' => $expire, + 'path' => $params['path'], + 'domain' => $params['domain'], + 'secure' => FALSE, + 'httponly' => $params['httponly'], + ); + drupal_setcookie($insecure_session_name, $session_id, $options); $_COOKIE[$insecure_session_name] = $session_id; } @@ -380,7 +408,14 @@ function drupal_session_regenerate() { if (isset($old_session_id)) { $params = session_get_cookie_params(); $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0; - setcookie(session_name(), session_id(), $expire, $params['path'], $params['domain'], $params['secure'], $params['httponly']); + $options = array( + 'expires' => $expire, + 'path' => $params['path'], + 'domain' => $params['domain'], + 'secure' => $params['secure'], + 'httponly' => $params['httponly'], + ); + drupal_setcookie(session_name(), session_id(), $options); $fields = array('sid' => session_id()); if ($is_https) { $fields['ssid'] = session_id(); @@ -488,7 +523,14 @@ function _drupal_session_delete_cookie($name, $secure = NULL) { if ($secure !== NULL) { $params['secure'] = $secure; } - setcookie($name, '', REQUEST_TIME - 3600, $params['path'], $params['domain'], $params['secure'], $params['httponly']); + $options = array( + 'expires' => REQUEST_TIME - 3600, + 'path' => $params['path'], + 'domain' => $params['domain'], + 'secure' => $params['secure'], + 'httponly' => $params['httponly'], + ); + drupal_setcookie($name, '', $options); unset($_COOKIE[$name]); } } diff --git a/misc/ajax.js b/misc/ajax.js index 79a4e9eb6..a809f5738 100644 --- a/misc/ajax.js +++ b/misc/ajax.js @@ -408,7 +408,7 @@ Drupal.ajax.prototype.beforeSend = function (xmlhttprequest, options) { // Insert progressbar or throbber. if (this.progress.type == 'bar') { - var progressBar = new Drupal.progressBar('ajax-progress-' + this.element.id, eval(this.progress.update_callback), this.progress.method, eval(this.progress.error_callback)); + var progressBar = new Drupal.progressBar('ajax-progress-' + this.element.id, $.noop, this.progress.method, $.noop); if (this.progress.message) { progressBar.setProgress(-1, this.progress.message); } diff --git a/modules/book/book.test b/modules/book/book.test index 81f4524ac..448dc23a9 100644 --- a/modules/book/book.test +++ b/modules/book/book.test @@ -101,7 +101,7 @@ class BookTestCase extends DrupalWebTestCase { // Check that book pages display along with the correct outlines and // previous/next links. - $this->checkBookNode($book, array($nodes[0], $nodes[3], $nodes[4]), FALSE, FALSE, $nodes[0], array()); + $this->checkBookNode($book, array($nodes[0], $nodes[3], $nodes[4]), FALSE, FALSE, $nodes[0]); $this->checkBookNode($nodes[0], array($nodes[1], $nodes[2]), $book, $book, $nodes[1], array($book)); $this->checkBookNode($nodes[1], NULL, $nodes[0], $nodes[0], $nodes[2], array($book, $nodes[0])); $this->checkBookNode($nodes[2], NULL, $nodes[1], $nodes[0], $nodes[3], array($book, $nodes[0])); @@ -124,7 +124,7 @@ class BookTestCase extends DrupalWebTestCase { // First we must set $this->book to the second book, so that the // correct regex will be generated for testing the outline. $this->book = $other_book; - $this->checkBookNode($other_book, array($node), FALSE, FALSE, $node, array()); + $this->checkBookNode($other_book, array($node), FALSE, FALSE, $node); $this->checkBookNode($node, NULL, $other_book, $other_book, FALSE, array($other_book)); } @@ -144,9 +144,9 @@ class BookTestCase extends DrupalWebTestCase { * @param $next * (optional) Next link node. Defaults to FALSE. * @param $breadcrumb - * The nodes that should be displayed in the breadcrumb. + * (optional) The nodes that should be displayed in the breadcrumb. */ - function checkBookNode($node, $nodes, $previous = FALSE, $up = FALSE, $next = FALSE, array $breadcrumb) { + function checkBookNode($node, $nodes = NULL, $previous = FALSE, $up = FALSE, $next = FALSE, array $breadcrumb = array()) { // $number does not use drupal_static as it should not be reset // since it uniquely identifies each call to checkBookNode(). static $number = 0; diff --git a/modules/field/modules/field_sql_storage/field_sql_storage.module b/modules/field/modules/field_sql_storage/field_sql_storage.module index 842893ad7..deb08d0da 100644 --- a/modules/field/modules/field_sql_storage/field_sql_storage.module +++ b/modules/field/modules/field_sql_storage/field_sql_storage.module @@ -434,6 +434,81 @@ function field_sql_storage_field_storage_load($entity_type, $entities, $age, $fi } } +/** + * Callback for array_filter(). + */ +function _field_sql_storage_write_compare_filter_callback($value) { + return NULL !== $value && '' !== $value; +} + +/** + * Cleanup field values for later values comparison. + * + * @param array $field + * Field info as returned by field_info_field_by_id(). + * + * @param array $array + * Field values to cleanup. + * + * @return array + * Filtered values. + */ +function _field_sql_storage_write_compare_filter($field, $array) { + foreach ($array as $language => $items) { + if (empty($items)) { + unset($array[$language]); + } + else { + foreach ($items as $delta => $item) { + // This should not happen but some modules provide invalid data to the + // field API. + if (!is_array($item)) { + continue; + } + // Let's start by pruning empty values and non storable values. + $array[$language][$delta] = array_filter(array_intersect_key($item, $field['columns']), '_field_sql_storage_write_compare_filter_callback'); + // Ordering is important because for widget elements and loaded columns + // from database order might differ and give false positives on field + // value change, especially with complex fields such as image fields. + ksort($array[$language][$delta]); + } + } + } + return $array; +} + +/** + * Compare a single field value for both entities and tell us if it changed. + * + * @param array $field + * Loaded field structure. + * @param object $entity1 + * First entity to compare. + * @param object $entity2 + * Second entity to compare. + * + * @return bool + * True if field value changed, false otherwise. + */ +function _field_sql_storage_write_compare($field, $entity1, $entity2) { + $field_name = $field['field_name']; + if (empty($entity1->$field_name) && empty($entity2->$field_name)) { + // Both are empty we can safely assume that it did not change. + return FALSE; + } + if (!isset($entity1->$field_name) || !isset($entity2->$field_name)) { + // One of them is missing but not the other the value changed. + return TRUE; + } + // We need to proceed to deep array comparison, but we cannot do it naively: + // in most cases the field values come from the edit form, and some Form API + // widget values that are not field columns may be present. We need to clean + // up both original and new field values before comparison. + $items1 = _field_sql_storage_write_compare_filter($field, (array) $entity1->$field_name); + $items2 = _field_sql_storage_write_compare_filter($field, (array) $entity2->$field_name); + return $items1 != $items2; +} + /** * Implements hook_field_storage_write(). */ @@ -443,8 +518,29 @@ function field_sql_storage_field_storage_write($entity_type, $entity, $op, $fiel $vid = $id; } + // Check if the given entity is a new revision or not. In case of a new + // revision creation, we cannot skip any field. + if (!empty($vid) && !empty($entity->original)) { + list(, $original_vid) = entity_extract_ids($entity_type, $entity->original); + if (NULL === $original_vid) { + $original_vid = $id; + } + $is_new_revision = $original_vid != $vid; + } + else { + $is_new_revision = FALSE; + } + + // Allow this optimization to be optional. + $skip_unchanged_fields = variable_get('field_sql_storage_skip_writing_unchanged_fields', FALSE); + foreach ($fields as $field_id) { $field = field_info_field_by_id($field_id); + + if ($skip_unchanged_fields && !$is_new_revision && !empty($entity->original) && !_field_sql_storage_write_compare($field, $entity, $entity->original)) { + continue; + } + $field_name = $field['field_name']; $table_name = _field_sql_storage_tablename($field); $revision_name = _field_sql_storage_revision_tablename($field); diff --git a/modules/field/modules/field_sql_storage/field_sql_storage.test b/modules/field/modules/field_sql_storage/field_sql_storage.test index b2eb50652..e46677be9 100644 --- a/modules/field/modules/field_sql_storage/field_sql_storage.test +++ b/modules/field/modules/field_sql_storage/field_sql_storage.test @@ -281,6 +281,69 @@ class FieldSqlStorageTestCase extends DrupalWebTestCase { $this->assertEqual($count, 1, 'NULL field translation is wiped.'); } + /** + * Tests the expected return values of _field_sql_storage_write_compare(). + */ + public function testFieldCompareDataModification() { + $langcode = LANGUAGE_NONE; + $field_info = field_info_field($this->field_name); + + // Make sure we have 2 sample field values that are unique. + $value1 = 0; + $value2 = 0; + while ($value1 == $value2) { + $value1 = mt_rand(); + $value2 = (string) mt_rand(); + } + + // Create the 2 entities to compare. + $entity = field_test_create_stub_entity(); + $entity->{$this->field_name}[$langcode][]['value'] = $value1; + $entity1 = clone $entity; + $entity2 = clone $entity; + + // Make sure that it correctly compares identical entities. + $this->assert(!_field_sql_storage_write_compare($field_info, $entity1, $entity2), 'The entities are identical.'); + + // Compare to an empty object. + $this->assert(_field_sql_storage_write_compare($field_info, $entity1, new stdClass()), 'The entity is not the same as an empty object.'); + + // Change one of the values. + $entity2->{$this->field_name}[$langcode][0]['value'] = $value2; + $this->assert(_field_sql_storage_write_compare($field_info, $entity1, $entity2), 'The values are not the same.'); + + // Reset $entity2. + $entity2 = clone $entity; + + // Duplicate the value on one of the entities. + $entity1->{$this->field_name}[$langcode][]['value'] = $value1; + $this->assert(_field_sql_storage_write_compare($field_info, $entity1, $entity2), 'The fields do not have the same number of values.'); + + // Add a second value to both entities. + $entity2->{$this->field_name}[$langcode][]['value'] = $value2; + $this->assert(_field_sql_storage_write_compare($field_info, $entity1, $entity2), 'The values are not the same.'); + + // Replace the array containing the value with the actual value. + $entity2->{$this->field_name}[$langcode] = $entity2->{$this->field_name}[$langcode][0]; + $this->assert(_field_sql_storage_write_compare($field_info, $entity1, $entity2), 'The array to hold field values is replaced by the value.'); + + // Null one value. + $entity2->{$this->field_name}[$langcode] = NULL; + $this->assert(_field_sql_storage_write_compare($field_info, $entity1, $entity2), 'One field is NULL and the other is not.'); + + // Null both values. + $entity1->{$this->field_name}[$langcode] = NULL; + $this->assert(!_field_sql_storage_write_compare($field_info, $entity1, $entity2), 'Both fields are NULL.'); + + // Unset one of the fields. + unset($entity2->{$this->field_name}); + $this->assert(_field_sql_storage_write_compare($field_info, $entity1, $entity2), 'One field structure is unset.'); + + // Unset both of the fields. + unset($entity1->{$this->field_name}); + $this->assert(!_field_sql_storage_write_compare($field_info, $entity1, $entity2), 'Both field structures are unset.'); + } + /** * Test trying to update a field with data. */ diff --git a/modules/field/tests/field_test.storage.inc b/modules/field/tests/field_test.storage.inc index 03eae4a6f..7a6201d31 100644 --- a/modules/field/tests/field_test.storage.inc +++ b/modules/field/tests/field_test.storage.inc @@ -240,111 +240,6 @@ function field_test_field_storage_delete_revision($entity_type, $entity, $fields _field_test_storage_data($data); } -/** - * Implements hook_field_storage_query(). - */ -function field_test_field_storage_query($field_id, $conditions, $count, &$cursor = NULL, $age) { - $data = _field_test_storage_data(); - - $load_current = $age == FIELD_LOAD_CURRENT; - - $field = field_info_field_by_id($field_id); - $field_columns = array_keys($field['columns']); - - $field_data = $data[$field['id']]; - $sub_table = $load_current ? 'current' : 'revisions'; - // We need to sort records by entity type and entity id. - usort($field_data[$sub_table], '_field_test_field_storage_query_sort_helper'); - - // Initialize results array. - $return = array(); - $entity_count = 0; - $rows_count = 0; - $rows_total = count($field_data[$sub_table]); - $skip = $cursor; - $skipped = 0; - - foreach ($field_data[$sub_table] as $row) { - if ($count != FIELD_QUERY_NO_LIMIT && $entity_count >= $count) { - break; - } - - if ($row->field_id == $field['id']) { - $match = TRUE; - $condition_deleted = FALSE; - // Add conditions. - foreach ($conditions as $condition) { - @list($column, $value, $operator) = $condition; - if (empty($operator)) { - $operator = is_array($value) ? 'IN' : '='; - } - switch ($operator) { - case '=': - $match = $match && $row->{$column} == $value; - break; - case '<>': - case '<': - case '<=': - case '>': - case '>=': - eval('$match = $match && ' . $row->{$column} . ' ' . $operator . ' '. $value); - break; - case 'IN': - $match = $match && in_array($row->{$column}, $value); - break; - case 'NOT IN': - $match = $match && !in_array($row->{$column}, $value); - break; - case 'BETWEEN': - $match = $match && $row->{$column} >= $value[0] && $row->{$column} <= $value[1]; - break; - case 'STARTS_WITH': - case 'ENDS_WITH': - case 'CONTAINS': - // Not supported. - $match = FALSE; - break; - } - // Track condition on 'deleted'. - if ($column == 'deleted') { - $condition_deleted = TRUE; - } - } - - // Exclude deleted data unless we have a condition on it. - if (!$condition_deleted && $row->deleted) { - $match = FALSE; - } - - if ($match) { - if (!isset($skip) || $skipped >= $skip) { - $cursor++; - // If querying all revisions and the entity type has revisions, we need - // to key the results by revision_ids. - $entity_type = entity_get_info($row->type); - $id = ($load_current || empty($entity_type['entity keys']['revision'])) ? $row->entity_id : $row->revision_id; - - if (!isset($return[$row->type][$id])) { - $return[$row->type][$id] = entity_create_stub_entity($row->type, array($row->entity_id, $row->revision_id, $row->bundle)); - $entity_count++; - } - } - else { - $skipped++; - } - } - } - $rows_count++; - - // The query is complete if we walked the whole array. - if ($count != FIELD_QUERY_NO_LIMIT && $rows_count >= $rows_total) { - $cursor = FIELD_QUERY_COMPLETE; - } - } - - return $return; -} - /** * Sort helper for field_test_field_storage_query(). * diff --git a/modules/file/file.field.inc b/modules/file/file.field.inc index fc1a1df20..ddb4f841f 100644 --- a/modules/file/file.field.inc +++ b/modules/file/file.field.inc @@ -593,7 +593,7 @@ function file_field_widget_uri($field, $instance, $data = array()) { /** * The #value_callback for the file_generic field element. */ -function file_field_widget_value($element, $input = FALSE, $form_state) { +function file_field_widget_value($element, $input = FALSE, $form_state = array()) { if ($input) { // Checkboxes lose their value when empty. // If the display field is present make sure its unchecked value is saved. @@ -955,17 +955,14 @@ function theme_file_upload_help($variables) { if (isset($upload_validators['file_validate_image_resolution'])) { $max = $upload_validators['file_validate_image_resolution'][0]; $min = $upload_validators['file_validate_image_resolution'][1]; - if ($min && $max && $min == $max) { - $descriptions[] = t('Images must be exactly !size pixels.', array('!size' => '' . $max . '')); - } - elseif ($min && $max) { - $descriptions[] = t('Images must be between !min and !max pixels.', array('!min' => '' . $min . '', '!max' => '' . $max . '')); + if ($min && $max) { + $descriptions[] = t('Images must be at least !min pixels. Images larger than !max pixels will be resized.', array('!min' => '' . $min . '', '!max' => '' . $max . '')); } elseif ($min) { - $descriptions[] = t('Images must be larger than !min pixels.', array('!min' => '' . $min . '')); + $descriptions[] = t('Images must be at least !min pixels.', array('!min' => '' . $min . '')); } elseif ($max) { - $descriptions[] = t('Images must be smaller than !max pixels.', array('!max' => '' . $max . '')); + $descriptions[] = t('Images larger than !max pixels will be resized.', array('!max' => '' . $max . '')); } } diff --git a/modules/image/image.test b/modules/image/image.test index 0c26ffa84..22edcaa06 100644 --- a/modules/image/image.test +++ b/modules/image/image.test @@ -1022,7 +1022,7 @@ class ImageFieldDisplayTestCase extends ImageFieldTestCase { $this->drupalGet('node/add/article'); $this->assertText(t('Files must be less than 50 KB.'), 'Image widget max file size is displayed on article form.'); $this->assertText(t('Allowed file types: ' . $test_image_extension . '.'), 'Image widget allowed file types displayed on article form.'); - $this->assertText(t('Images must be between 10x10 and 100x100 pixels.'), 'Image widget allowed resolution displayed on article form.'); + $this->assertText(t('Images must be at least 10x10 pixels. Images larger than 100x100 pixels will be resized.'), 'Image widget allowed resolution displayed on article form.'); // We have to create the article first and then edit it because the alt // and title fields do not display until the image has been attached. diff --git a/modules/openid/openid.inc b/modules/openid/openid.inc index a1da1d0b5..4ca747164 100644 --- a/modules/openid/openid.inc +++ b/modules/openid/openid.inc @@ -142,7 +142,8 @@ function _openid_xrds_parse($raw_xml) { // For PHP version >= 5.2.11, we can use this function to protect against // malicious doctype declarations and other unexpected entity loading. // However, we will not rely on it, and reject any XML with a DOCTYPE. - $disable_entity_loader = function_exists('libxml_disable_entity_loader'); + // libxml_disable_entity_loader() is deprecated in PHP >= 8.0. + $disable_entity_loader = function_exists('libxml_disable_entity_loader') && PHP_VERSION_ID < 80000; if ($disable_entity_loader) { $load_entities = libxml_disable_entity_loader(TRUE); } diff --git a/modules/openid/openid.module b/modules/openid/openid.module index a52dbc3de..f2c1b8d54 100644 --- a/modules/openid/openid.module +++ b/modules/openid/openid.module @@ -743,7 +743,7 @@ function openid_association_request($public) { return $request; } -function openid_authentication_request($claimed_id, $identity, $return_to = '', $assoc_handle = '', $service) { +function openid_authentication_request($claimed_id, $identity, $return_to, $assoc_handle, $service) { global $base_url; module_load_include('inc', 'openid'); diff --git a/modules/simpletest/drupal_web_test_case.php b/modules/simpletest/drupal_web_test_case.php index c426ba53a..f212b0eb5 100644 --- a/modules/simpletest/drupal_web_test_case.php +++ b/modules/simpletest/drupal_web_test_case.php @@ -1690,8 +1690,14 @@ protected function tearDown() { $this->fail('Failed to drop all prefixed tables.'); } + // In PHP 8 some tests encounter problems when shutdown code tries to + // access the database connection after it's been explicitly closed, for + // example the destructor of DrupalCacheArray. We avoid this by not fully + // destroying the test database connection. + $close = \PHP_VERSION_ID < 80000; + // Get back to the original connection. - Database::removeConnection('default'); + Database::removeConnection('default', $close); Database::renameConnection('simpletest_original_default', 'default'); // Restore original shutdown callbacks array to prevent original @@ -3084,7 +3090,7 @@ protected function assertNoText($text, $message = '', $group = 'Other') { * @return * TRUE on pass, FALSE on fail. */ - protected function assertTextHelper($text, $message = '', $group, $not_exists) { + protected function assertTextHelper($text, $message, $group, $not_exists) { if ($this->plainTextContent === FALSE) { $this->plainTextContent = filter_xss($this->drupalGetContent(), array()); } @@ -3150,7 +3156,7 @@ protected function assertNoUniqueText($text, $message = '', $group = 'Other') { * @return * TRUE on pass, FALSE on fail. */ - protected function assertUniqueTextHelper($text, $message = '', $group, $be_unique) { + protected function assertUniqueTextHelper($text, $message, $group, $be_unique) { if ($this->plainTextContent === FALSE) { $this->plainTextContent = filter_xss($this->drupalGetContent(), array()); } @@ -3256,7 +3262,7 @@ protected function assertNoTitle($title, $message = '', $group = 'Other') { * @param $callback * The name of the theme function to invoke; e.g. 'links' for theme_links(). * @param $variables - * (optional) An array of variables to pass to the theme function. + * An array of variables to pass to the theme function. * @param $expected * The expected themed output string. * @param $message @@ -3272,7 +3278,7 @@ protected function assertNoTitle($title, $message = '', $group = 'Other') { * @return * TRUE on pass, FALSE on fail. */ - protected function assertThemeOutput($callback, array $variables = array(), $expected, $message = '', $group = 'Other') { + protected function assertThemeOutput($callback, array $variables, $expected, $message = '', $group = 'Other') { $output = theme($callback, $variables); $this->verbose('Variables:' . '
' . check_plain(var_export($variables, TRUE)) . '' . '
' . check_plain(var_export($output, TRUE)) . '' diff --git a/modules/simpletest/simpletest.test b/modules/simpletest/simpletest.test index 5d1c718c1..80b841d58 100644 --- a/modules/simpletest/simpletest.test +++ b/modules/simpletest/simpletest.test @@ -164,13 +164,16 @@ class SimpleTestFunctionalTest extends DrupalWebTestCase { $this->pass(t('Test ID is @id.', array('@id' => $this->testId))); // Generates a warning. - $i = 1 / 0; + $a = ''; + foreach ($a as $b) { + + } // Call an assert function specific to that class. $this->assertNothing(); - // Generates a warning inside a PHP function. - array_key_exists(NULL, NULL); + // Generates 3 warnings inside a PHP function. + simplexml_load_string('
' . t('This login can be used only once.') . '
'); $form['actions'] = array('#type' => 'actions'); $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Log in')); - $form['#action'] = url("user/reset/$uid/$timestamp/$hashed_pass/login"); + $form['#action'] = url("user/reset/$uid/$timestamp/$session_reset_hash/login"); + // Prevent the browser from storing this page so that the token will + // not be visible in the form action if the back button is used to + // revisit this page. + drupal_add_http_header('Cache-Control', 'no-store'); return $form; } } diff --git a/modules/user/user.test b/modules/user/user.test index 4c16b531c..ec2f90d6f 100644 --- a/modules/user/user.test +++ b/modules/user/user.test @@ -480,6 +480,10 @@ class UserLoginTestCase extends DrupalWebTestCase { class UserPasswordResetTestCase extends DrupalWebTestCase { protected $profile = 'standard'; + function setUp() { + parent::setUp('user_form_test'); + } + public static function getInfo() { return array( 'name' => 'Reset password', @@ -491,20 +495,38 @@ class UserPasswordResetTestCase extends DrupalWebTestCase { /** * Retrieves password reset email and extracts the login link. */ - public function getResetURL() { + public function getResetURL($bypass_form = FALSE) { // Assume the most recent email. $_emails = $this->drupalGetMails(); $email = end($_emails); $urls = array(); preg_match('#.+user/reset/.+#', $email['body'], $urls); - return $urls[0]; + return $urls[0] . ($bypass_form ? '/login' : ''); + } + + /** + * Generates login link. + */ + public function generateResetURL($account, $bypass_form = FALSE) { + return user_pass_reset_url($account) . ($bypass_form ? '/login' : ''); + } + + /** + * Turns a password reset URL into a 'confirm' URL. + */ + public function getConfirmURL($reset_url) { + // Last part is always the hash; replace with "confirm". + $parts = explode('/', $reset_url); + array_pop($parts); + array_push($parts, 'confirm'); + return implode('/', $parts); } /** * Tests password reset functionality. */ - function testUserPasswordReset() { + function testUserPasswordReset($use_direct_login_link = FALSE) { // Create a user. $account = $this->drupalCreateUser(); $this->drupalLogin($account); @@ -540,11 +562,19 @@ class UserPasswordResetTestCase extends DrupalWebTestCase { ); field_create_instance($instance); - $resetURL = $this->getResetURL(); + variable_del("user_test_pass_reset_form_submit_{$account->uid}"); + $resetURL = $this->getResetURL($use_direct_login_link); $this->drupalGet($resetURL); // Check successful login. - $this->drupalPost(NULL, NULL, t('Log in')); + if (!$use_direct_login_link) { + $this->assertUrl($this->getConfirmURL($resetURL), array(), 'The user is redirected to the reset password confirm form.'); + $this->drupalPost(NULL, NULL, t('Log in')); + // The form was fully processed before redirecting. + $form_submit_handled = variable_get("user_test_pass_reset_form_submit_{$account->uid}", FALSE); + $this->assertTrue($form_submit_handled, 'A custom submit handler executed.'); + } + $this->assertText('You have just used your one-time login link. It is no longer necessary to use this link to log in. Please change your password.'); // Make sure the Ajax request from uploading a file does not invalidate the // reset token. @@ -559,6 +589,16 @@ class UserPasswordResetTestCase extends DrupalWebTestCase { $edit = array('pass[pass1]' => $password, 'pass[pass2]' => $password); $this->drupalPost(NULL, $edit, t('Save')); $this->assertText(t('The changes have been saved.'), 'Forgotten password changed.'); + + // Ensure blocked and deleted accounts can't access the direct login link. + $this->drupalLogout(); + $reset_url = $this->generateResetURL($account, $use_direct_login_link); + user_save($account, array('status' => 0)); + $this->drupalGet($reset_url); + $this->assertResponse(403); + user_delete($account->uid); + $this->drupalGet($reset_url); + $this->assertResponse(403); } /** @@ -642,21 +682,51 @@ class UserPasswordResetTestCase extends DrupalWebTestCase { /** * Test user password reset while logged in. */ - function testUserPasswordResetLoggedIn() { + function testUserPasswordResetLoggedIn($use_direct_login_link = FALSE) { + $another_account = $this->drupalCreateUser(); $account = $this->drupalCreateUser(); $this->drupalLogin($account); // Make sure the test account has a valid password. user_save($account, array('pass' => user_password())); + // Try to use the login link while logged in as a different user. // Generate one time login link. - $reset_url = user_pass_reset_url($account); + $reset_url = $this->generateResetURL($another_account, $use_direct_login_link); $this->drupalGet($reset_url); + $this->assertRaw(t( + 'Another user (%other_user) is already logged into the site on this computer, but you tried to use a one-time link for user %resetting_user. Please logout and try using the link again.', + array('%other_user' => $account->name, '%resetting_user' => $another_account->name, '!logout' => url('user/logout')) + )); - $this->assertText('Reset password'); - $this->drupalPost(NULL, NULL, t('Log in')); + // Test the link for a deleted user while logged in. + user_delete($another_account->uid); + $this->drupalGet($reset_url); + $this->assertText('The one-time login link you clicked is invalid.'); + // Generate a one time login link for the logged-in user. + $fapi_action = $use_direct_login_link ? 'build' : 'submit'; + variable_del("user_test_pass_reset_form_{$fapi_action}_{$account->uid}"); + $reset_url = $this->generateResetURL($account, $use_direct_login_link); + $this->drupalGet($reset_url); + if ($use_direct_login_link) { + // The form is never fully built; user is logged out (session destroyed) + // and redirected to the same URL, then logged in again and redirected + // during form build. + $form_built = variable_get("user_test_pass_reset_form_build_{$account->uid}", FALSE); + $this->assertTrue(!$form_built, 'The password reset form was never fully built.'); + } + else { + $this->assertUrl($this->getConfirmURL($reset_url), array(), 'The user is redirected to the reset password confirm form.'); + $this->assertText('Reset password'); + $this->drupalPost(NULL, NULL, t('Log in')); + // The form was fully processed before redirecting. + $form_submit_handled = variable_get("user_test_pass_reset_form_submit_{$account->uid}", FALSE); + $this->assertTrue($form_submit_handled, 'A custom submit handler executed.'); + } $this->assertText('You have just used your one-time login link. It is no longer necessary to use this link to log in. Please change your password.'); + // The user can change the forgotten password on the page they are + // redirected to. $pass = user_password(); $edit = array( 'pass[pass1]' => $pass, @@ -667,6 +737,14 @@ class UserPasswordResetTestCase extends DrupalWebTestCase { $this->assertText('The changes have been saved.'); } + /** + * Test direct login link that bypasses the password reset form. + */ + function testUserDirectLogin() { + $this->testUserPasswordReset(TRUE); + $this->testUserPasswordResetLoggedIn(TRUE); + } + /** * Attempts login using an expired password reset link. */ @@ -770,7 +848,7 @@ class UserPasswordResetTestCase extends DrupalWebTestCase { $reset_url = url("user/reset/$user1->uid/$timestamp/$reset_url_token", array('absolute' => TRUE)); $this->drupalGet($reset_url); $this->assertText($user1->name, 'The valid password reset page shows the user name.'); - $this->assertUrl($reset_url, array(), 'The user remains on the password reset login page.'); + $this->assertUrl($this->getConfirmURL($reset_url), array(), 'The user is redirected to the reset password confirm form.'); $this->assertNoText('You have tried to use a one-time login link that has either been used or is no longer valid. Please request a new one using the form below.'); } diff --git a/sites/default/default.settings.php b/sites/default/default.settings.php index bf367b208..3e88c3834 100644 --- a/sites/default/default.settings.php +++ b/sites/default/default.settings.php @@ -702,6 +702,15 @@ */ # $conf['variable_initialize_wait_for_lock'] = FALSE; +/** + * Opt in to field_sql_storage_field_storage_write() optimization. + * + * To reduce unnecessary writes field_sql_storage_field_storage_write() can skip + * fields where values have apparently not changed. To opt in to this + * optimization, set this variable to TRUE. + */ +$conf['field_sql_storage_skip_writing_unchanged_fields'] = TRUE; + /** * Use site name as display-name in outgoing mail. * @@ -716,3 +725,23 @@ * @see drupal_mail() */ $conf['mail_display_name_site_name'] = TRUE; + +/** + * SameSite cookie attribute. + * + * This variable can be used to set a value for the SameSite cookie attribute. + * + * Versions of PHP before 7.3 have no native support for the SameSite attribute + * so it is emulated. + * + * The session.cookie-samesite setting in PHP 7.3 and later will be overridden + * by this variable for Drupal session cookies, and any other cookies managed + * with drupal_setcookie(). + * + * Setting this variable to FALSE disables the SameSite attribute on cookies. + * + * @see drupal_setcookie() + * @see drupal_session_start() + * @see https://www.php.net/manual/en/session.configuration.php#ini.session.cookie-samesite + */ +#$conf['samesite_cookie_value'] = 'None';