wishthis/vendor/wp-coding-standards/wpcs/WordPress/Sniffs/Security/NonceVerificationSniff.php
2023-09-20 13:52:46 +02:00

422 lines
13 KiB
PHP

<?php
/**
* WordPress Coding Standard.
*
* @package WPCS\WordPressCodingStandards
* @link https://github.com/WordPress/WordPress-Coding-Standards
* @license https://opensource.org/licenses/MIT MIT
*/
namespace WordPressCS\WordPress\Sniffs\Security;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Conditions;
use PHPCSUtils\Utils\Context;
use PHPCSUtils\Utils\Lists;
use PHPCSUtils\Utils\MessageHelper;
use PHPCSUtils\Utils\Scopes;
use WordPressCS\WordPress\Helpers\ContextHelper;
use WordPressCS\WordPress\Helpers\RulesetPropertyHelper;
use WordPressCS\WordPress\Helpers\SanitizationHelperTrait;
use WordPressCS\WordPress\Helpers\UnslashingFunctionsHelper;
use WordPressCS\WordPress\Helpers\VariableHelper;
use WordPressCS\WordPress\Sniff;
/**
* Checks that nonce verification accompanies form processing.
*
* @link https://developer.wordpress.org/plugins/security/nonces/ Nonces on Plugin Developer Handbook
*
* @since 0.5.0
* @since 0.13.0 Class name changed: this class is now namespaced.
* @since 1.0.0 This sniff has been moved from the `CSRF` category to the `Security` category.
* @since 3.0.0 This sniff has received significant updates to its logic and structure.
*
* @uses \WordPressCS\WordPress\Helpers\SanitizationHelperTrait::$customSanitizingFunctions
* @uses \WordPressCS\WordPress\Helpers\SanitizationHelperTrait::$customUnslashingSanitizingFunctions
*/
class NonceVerificationSniff extends Sniff {
use SanitizationHelperTrait;
/**
* Superglobals to notify about when not accompanied by an nonce check.
*
* A value of `true` results in an error. A value of `false` in a warning.
*
* @since 0.12.0
*
* @var array
*/
protected $superglobals = array(
'$_POST' => true,
'$_FILES' => true,
'$_GET' => false,
'$_REQUEST' => false,
);
/**
* Custom list of functions which verify nonces.
*
* @since 0.5.0
*
* @var string[]
*/
public $customNonceVerificationFunctions = array();
/**
* List of the functions which verify nonces.
*
* @since 0.5.0
* @since 0.11.0 Changed from public static to protected non-static.
* @since 3.0.0 - Moved from the generic `Sniff` class to this class.
* - Visibility changed from `protected` to `private.
*
* @var array
*/
private $nonceVerificationFunctions = array(
'wp_verify_nonce' => true,
'check_admin_referer' => true,
'check_ajax_referer' => true,
);
/**
* Cache of previously added custom functions.
*
* Prevents having to do the same merges over and over again.
*
* @since 0.5.0
* @since 0.11.0 - Changed from public static to protected non-static.
* - Changed the format from simple bool to array.
* @since 3.0.0 - Property rename from `$addedCustomFunctions` to `$addedCustomNonceFunctions`.
* - Visibility changed from `protected` to `private.
* - Format changed from a multi-dimensional array to a single-dimensional array.
*
* @var array
*/
private $addedCustomNonceFunctions = array();
/**
* Information on the all scopes that were checked to find a nonce verification in a particular file.
*
* The array will be in the following format:
* ```
* array(
* 'file' => (string) The name of the file.
* 'cache' => (array) array(
* # => array( The key is the token pointer to the "start" position.
* 'end' => (int) The token pointer to the "end" position.
* 'nonce' => (int|bool) The token pointer where n nonce check
* was found, or false if none was found.
* )
* )
* )
* ```
*
* @since 3.0.0
*
* @var array<string, mixed>
*/
private $cached_results;
/**
* Returns an array of tokens this test wants to listen for.
*
* @return array
*/
public function register() {
$targets = array( \T_VARIABLE => \T_VARIABLE );
$targets += Collections::listOpenTokensBC(); // We need to skip over lists.
return $targets;
}
/**
* Processes this test, when one of its tokens is encountered.
*
* @param int $stackPtr The position of the current token in the stack.
*
* @return int|void Integer stack pointer to skip forward or void to continue
* normal file processing.
*/
public function process_token( $stackPtr ) {
// Skip over lists as whatever is in those will always be assignments.
if ( isset( Collections::listOpenTokensBC()[ $this->tokens[ $stackPtr ]['code'] ] ) ) {
$open_close = Lists::getOpenClose( $this->phpcsFile, $stackPtr );
$skip_to = $stackPtr;
if ( false !== $open_close ) {
$skip_to = $open_close['closer'];
}
return $skip_to;
}
if ( ! isset( $this->superglobals[ $this->tokens[ $stackPtr ]['content'] ] ) ) {
return;
}
if ( Scopes::isOOProperty( $this->phpcsFile, $stackPtr ) ) {
// Property with the same name as a superglobal. Not our target.
return;
}
// Determine the cache keys for this item.
$cache_keys = array(
'file' => $this->phpcsFile->getFilename(),
'start' => 0,
'end' => $stackPtr,
);
// If we're in a function, only look inside of it.
// This doesn't take arrow functions into account as those are "open".
$functionPtr = Conditions::getLastCondition( $this->phpcsFile, $stackPtr, array( \T_FUNCTION, \T_CLOSURE ) );
if ( false !== $functionPtr ) {
$cache_keys['start'] = $this->tokens[ $functionPtr ]['scope_opener'];
}
$this->mergeFunctionLists();
$needs_nonce = $this->needs_nonce_check( $stackPtr, $cache_keys );
if ( false === $needs_nonce ) {
return;
}
if ( $this->has_nonce_check( $stackPtr, $cache_keys, ( 'after' === $needs_nonce ) ) ) {
return;
}
// If we're still here, no nonce-verification function was found.
$error_code = 'Missing';
if ( false === $this->superglobals[ $this->tokens[ $stackPtr ]['content'] ] ) {
$error_code = 'Recommended';
}
MessageHelper::addMessage(
$this->phpcsFile,
'Processing form data without nonce verification.',
$stackPtr,
$this->superglobals[ $this->tokens[ $stackPtr ]['content'] ],
$error_code
);
}
/**
* Determine whether or not a nonce check is needed for the current superglobal.
*
* @since 3.0.0
*
* @param int $stackPtr The position of the current token in the stack of tokens.
* @param array $cache_keys The keys for the applicable cache (to potentially set).
*
* @return string|false String "before" or "after" if a nonce check is needed.
* FALSE when no nonce check is needed.
*/
protected function needs_nonce_check( $stackPtr, array $cache_keys ) {
$in_nonce_check = ContextHelper::is_in_function_call( $this->phpcsFile, $stackPtr, $this->nonceVerificationFunctions );
if ( false !== $in_nonce_check ) {
// This *is* the nonce check, so bow out, but do store to cache.
// @todo Change to use arg unpacking once PHP < 5.6 has been dropped.
$this->set_cache( $cache_keys['file'], $cache_keys['start'], $cache_keys['end'], $in_nonce_check );
return false;
}
if ( Context::inUnset( $this->phpcsFile, $stackPtr ) ) {
// Variable is only being unset, no nonce check needed.
return false;
}
if ( VariableHelper::is_assignment( $this->phpcsFile, $stackPtr, false ) ) {
// Overwriting the value of a superglobal.
return false;
}
$needs_nonce = 'before';
if ( ContextHelper::is_in_isset_or_empty( $this->phpcsFile, $stackPtr )
|| ContextHelper::is_in_type_test( $this->phpcsFile, $stackPtr )
|| VariableHelper::is_comparison( $this->phpcsFile, $stackPtr )
|| VariableHelper::is_assignment( $this->phpcsFile, $stackPtr, true )
|| ContextHelper::is_in_array_comparison( $this->phpcsFile, $stackPtr )
|| ContextHelper::is_in_function_call( $this->phpcsFile, $stackPtr, UnslashingFunctionsHelper::get_functions() ) !== false
|| $this->is_only_sanitized( $this->phpcsFile, $stackPtr )
) {
$needs_nonce = 'after';
}
return $needs_nonce;
}
/**
* Check if this token has an associated nonce check.
*
* @since 0.5.0
* @since 3.0.0 - Moved from the generic `Sniff` class to this class.
* - Visibility changed from `protected` to `private.
* - New `$cache_keys` parameter.
* - New `$allow_nonce_after` parameter.
*
* @param int $stackPtr The position of the current token in the stack of tokens.
* @param array $cache_keys The keys for the applicable cache.
* @param bool $allow_nonce_after Whether the nonce check _must_ be before the $stackPtr or
* is allowed _after_ the $stackPtr.
*
* @return bool
*/
private function has_nonce_check( $stackPtr, array $cache_keys, $allow_nonce_after = false ) {
$start = $cache_keys['start'];
$end = $cache_keys['end'];
// We allow for certain actions, such as an isset() check to come before the nonce check.
// If this superglobal is inside such a check, look for the nonce after it as well,
// all the way to the end of the scope.
if ( true === $allow_nonce_after ) {
$end = ( 0 === $start ) ? $this->phpcsFile->numTokens : $this->tokens[ $start ]['scope_closer'];
}
// Check against the cache.
$current_cache = $this->get_cache( $cache_keys['file'], $start );
if ( false !== $current_cache['nonce'] ) {
// If we have already found a nonce check in this scope, we just
// need to check whether it comes before this token. It is OK if the
// check is after the token though, if this was only an isset() check.
return ( true === $allow_nonce_after || $current_cache['nonce'] < $stackPtr );
} elseif ( $end <= $current_cache['end'] ) {
// If not, we can still go ahead and return false if we've already
// checked to the end of the search area.
return false;
}
$search_start = $start;
if ( $current_cache['end'] > $start ) {
// We haven't checked this far yet, but we can still save work by
// skipping over the part we've already checked.
$search_start = $this->cached_results['cache'][ $start ]['end'];
}
// Loop through the tokens looking for nonce verification functions.
for ( $i = $search_start; $i < $end; $i++ ) {
// Skip over nested closed scope constructs.
if ( isset( Collections::closedScopes()[ $this->tokens[ $i ]['code'] ] )
|| \T_FN === $this->tokens[ $i ]['code']
) {
if ( isset( $this->tokens[ $i ]['scope_closer'] ) ) {
$i = $this->tokens[ $i ]['scope_closer'];
}
continue;
}
// If this isn't a function name, skip it.
if ( \T_STRING !== $this->tokens[ $i ]['code'] ) {
continue;
}
// If this is one of the nonce verification functions, we can bail out.
if ( isset( $this->nonceVerificationFunctions[ $this->tokens[ $i ]['content'] ] ) ) {
/*
* Now, make sure it is a call to a global function.
*/
if ( ContextHelper::has_object_operator_before( $this->phpcsFile, $i ) === true ) {
continue;
}
if ( ContextHelper::is_token_namespaced( $this->phpcsFile, $i ) === true ) {
continue;
}
$this->set_cache( $cache_keys['file'], $start, $end, $i );
return true;
}
}
// We're still here, so no luck.
$this->set_cache( $cache_keys['file'], $start, $end, false );
return false;
}
/**
* Helper function to retrieve results from the cache.
*
* @since 3.0.0
*
* @param string $filename The name of the current file.
* @param int $start The stack pointer searches started from.
*
* @return array<string, mixed>
*/
private function get_cache( $filename, $start ) {
if ( is_array( $this->cached_results )
&& $filename === $this->cached_results['file']
&& isset( $this->cached_results['cache'][ $start ] )
) {
return $this->cached_results['cache'][ $start ];
}
return array(
'end' => 0,
'nonce' => false,
);
}
/**
* Helper function to store results to the cache.
*
* @since 3.0.0
*
* @param string $filename The name of the current file.
* @param int $start The stack pointer searches started from.
* @param int $end The stack pointer searched stopped at.
* @param int|bool $nonce Stack pointer to the nonce verification function call or false if none was found.
*
* @return void
*/
private function set_cache( $filename, $start, $end, $nonce ) {
if ( is_array( $this->cached_results ) === false
|| $filename !== $this->cached_results['file']
) {
$this->cached_results = array(
'file' => $filename,
'cache' => array(
$start => array(
'end' => $end,
'nonce' => $nonce,
),
),
);
return;
}
// Okay, so we know the current cache is for the current file. Check if we've seen this start pointer before.
if ( isset( $this->cached_results['cache'][ $start ] ) === false ) {
$this->cached_results['cache'][ $start ] = array(
'end' => $end,
'nonce' => $nonce,
);
return;
}
// Update existing entry.
if ( $end > $this->cached_results['cache'][ $start ]['end'] ) {
$this->cached_results['cache'][ $start ]['end'] = $end;
}
$this->cached_results['cache'][ $start ]['nonce'] = $nonce;
}
/**
* Merge custom functions provided via a custom ruleset with the defaults, if we haven't already.
*
* @since 0.11.0 Split out from the `process()` method.
*
* @return void
*/
protected function mergeFunctionLists() {
if ( $this->customNonceVerificationFunctions !== $this->addedCustomNonceFunctions ) {
$this->nonceVerificationFunctions = RulesetPropertyHelper::merge_custom_array(
$this->customNonceVerificationFunctions,
$this->nonceVerificationFunctions
);
$this->addedCustomNonceFunctions = $this->customNonceVerificationFunctions;
}
}
}