wishthis/vendor/wp-coding-standards/wpcs/WordPress/Sniffs/Security/EscapeOutputSniff.php

904 lines
31 KiB
PHP
Raw Normal View History

2023-09-19 17:26:46 +00:00
<?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 PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\BackCompat\BCFile;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Arrays;
use PHPCSUtils\Utils\Conditions;
use PHPCSUtils\Utils\Operators;
use PHPCSUtils\Utils\PassedParameters;
use PHPCSUtils\Utils\TextStrings;
use WordPressCS\WordPress\AbstractFunctionRestrictionsSniff;
use WordPressCS\WordPress\Helpers\ArrayWalkingFunctionsHelper;
use WordPressCS\WordPress\Helpers\ConstantsHelper;
use WordPressCS\WordPress\Helpers\ContextHelper;
use WordPressCS\WordPress\Helpers\EscapingFunctionsTrait;
use WordPressCS\WordPress\Helpers\FormattingFunctionsHelper;
use WordPressCS\WordPress\Helpers\PrintingFunctionsTrait;
use WordPressCS\WordPress\Helpers\VariableHelper;
/**
* Verifies that all outputted strings are escaped.
*
* @link https://developer.wordpress.org/apis/security/data-validation/ WordPress Developer Docs on Data Validation.
*
* @since 2013-06-11
* @since 0.4.0 This class now extends the WordPressCS native `Sniff` class.
* @since 0.5.0 The various function list properties which used to be contained in this class
* have been moved to the WordPressCS native `Sniff` parent class.
* @since 0.12.0 This sniff will now also check for output escaping when using shorthand
* echo tags `<?=`.
* @since 0.13.0 Class name changed: this class is now namespaced.
* @since 1.0.0 This sniff has been moved from the `XSS` category to the `Security` category.
* @since 3.0.0 This class now extends the WordPressCS native
* `AbstractFunctionRestrictionsSniff` class.
* The parent `exclude` property is disabled.
*
* @uses \WordPressCS\WordPress\Helpers\EscapingFunctionsTrait::$customEscapingFunctions
* @uses \WordPressCS\WordPress\Helpers\EscapingFunctionsTrait::$customAutoEscapedFunctions
* @uses \WordPressCS\WordPress\Helpers\PrintingFunctionsTrait::$customPrintingFunctions
*/
class EscapeOutputSniff extends AbstractFunctionRestrictionsSniff {
use EscapingFunctionsTrait;
use PrintingFunctionsTrait;
/**
* Printing functions that incorporate unsafe values.
*
* @since 0.4.0
* @since 0.11.0 Changed from public static to protected non-static.
* @since 3.0.0 The format of the array values has changed from plain string to array.
*
* @var array<string, array>
*/
protected $unsafePrintingFunctions = array(
'_e' => array(
'alternative' => 'esc_html_e() or esc_attr_e()',
'params' => array(
1 => 'text',
),
),
'_ex' => array(
'alternative' => 'echo esc_html_x() or echo esc_attr_x()',
'params' => array(
1 => 'text',
),
),
);
/**
* List of names of the native PHP constants which can be considered safe.
*
* @since 1.0.0
*
* @var array<string, bool>
*/
private $safe_php_constants = array(
'PHP_EOL' => true, // String.
'PHP_VERSION' => true, // Integer.
'PHP_MAJOR_VERSION' => true, // Integer.
'PHP_MINOR_VERSION' => true, // Integer.
'PHP_RELEASE_VERSION' => true, // Integer.
'PHP_VERSION_ID' => true, // Integer.
'PHP_EXTRA_VERSION' => true, // String.
'PHP_DEBUG' => true, // Integer.
);
/**
* List of tokens which can be considered as safe when directly part of the output.
*
* This list is enhanced with additional tokens in the `register()` method.
*
* @since 0.12.0
*
* @var array<string|int, string|int>
*/
private $safe_components = array(
\T_LNUMBER => \T_LNUMBER,
\T_DNUMBER => \T_DNUMBER,
\T_TRUE => \T_TRUE,
\T_FALSE => \T_FALSE,
\T_NULL => \T_NULL,
\T_CONSTANT_ENCAPSED_STRING => \T_CONSTANT_ENCAPSED_STRING,
\T_START_NOWDOC => \T_START_NOWDOC,
\T_NOWDOC => \T_NOWDOC,
\T_END_NOWDOC => \T_END_NOWDOC,
\T_BOOLEAN_NOT => \T_BOOLEAN_NOT,
);
/**
* List of keyword tokens this sniff listens for, which can also be used as an inline expression.
*
* @since 3.0.0
*
* @var array<string|int, string|int>
*/
private $target_keywords = array(
\T_EXIT => \T_EXIT,
\T_PRINT => \T_PRINT,
\T_THROW => \T_THROW,
);
/**
* Returns an array of tokens this test wants to listen for.
*
* @return string|int[]
*/
public function register() {
// Enrich the list of "safe components" tokens.
$this->safe_components += Tokens::$comparisonTokens;
$this->safe_components += Tokens::$operators;
$this->safe_components += Tokens::$booleanOperators;
$this->safe_components += Collections::incrementDecrementOperators();
// Set up the tokens the sniff should listen too.
$targets = array_merge( parent::register(), $this->target_keywords );
$targets[] = \T_ECHO;
$targets[] = \T_OPEN_TAG_WITH_ECHO;
return $targets;
}
/**
* Groups of functions this sniff is looking for.
*
* @since 3.0.0
*
* @return array
*/
public function getGroups() {
// Make sure all array keys are lowercase (could contain user provided function names).
$printing_functions = array_change_key_case( $this->get_printing_functions(), \CASE_LOWER );
// Remove the unsafe printing functions to prevent duplicate notices.
$printing_functions = array_diff_key( $printing_functions, $this->unsafePrintingFunctions );
return array(
'unsafe_printing_functions' => array(
'functions' => array_keys( $this->unsafePrintingFunctions ),
),
'printing_functions' => array(
'functions' => array_keys( $printing_functions ),
),
);
}
/**
* Processes this test, when one of its tokens is encountered.
*
* @since 3.0.0 This method has been split up.
*
* @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 ) {
$start = ( $stackPtr + 1 );
$end = $start;
switch ( $this->tokens[ $stackPtr ]['code'] ) {
case \T_STRING:
// Prevent exclusion of any of the function groups.
$this->exclude = array();
// In the tests, custom printing functions may be added/removed on the fly.
if ( defined( 'PHP_CODESNIFFER_IN_TESTS' ) ) {
$this->setup_groups( 'functions' );
}
// Let the abstract parent class handle the initial function call check.
return parent::process_token( $stackPtr );
case \T_EXIT:
$next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $stackPtr + 1 ), null, true );
if ( false === $next_non_empty
|| \T_OPEN_PARENTHESIS !== $this->tokens[ $next_non_empty ]['code']
|| isset( $this->tokens[ $next_non_empty ]['parenthesis_closer'] ) === false
) {
// Live coding/parse error or an exit/die which doesn't pass a status code. Ignore.
return;
}
// $end is not examined, so make sure the parentheses are balanced.
$start = $next_non_empty;
$end = ( $this->tokens[ $next_non_empty ]['parenthesis_closer'] + 1 );
break;
case \T_THROW:
// Find the open parentheses, while stepping over the exception creation tokens.
$ignore = Tokens::$emptyTokens;
$ignore += Collections::namespacedNameTokens();
$ignore += Collections::functionCallTokens();
$ignore += Collections::objectOperators();
$next_relevant = $this->phpcsFile->findNext( $ignore, ( $stackPtr + 1 ), null, true );
if ( false === $next_relevant ) {
return;
}
if ( \T_NEW === $this->tokens[ $next_relevant ]['code'] ) {
$next_relevant = $this->phpcsFile->findNext( $ignore, ( $next_relevant + 1 ), null, true );
if ( false === $next_relevant ) {
return;
}
}
if ( \T_OPEN_PARENTHESIS !== $this->tokens[ $next_relevant ]['code']
|| isset( $this->tokens[ $next_relevant ]['parenthesis_closer'] ) === false
) {
// Live codind/parse error or a pre-created exception. Nothing to do for us.
return;
}
$end = $this->tokens[ $next_relevant ]['parenthesis_closer'];
// Check if the throw is within a `try-catch`.
// Doing this here (instead of earlier) to allow skipping to the end of the statement.
$search_for = Collections::closedScopes();
$search_for[ \T_TRY ] = \T_TRY;
$last_condition = Conditions::getLastCondition( $this->phpcsFile, $stackPtr, $search_for );
if ( false !== $last_condition && \T_TRY === $this->tokens[ $last_condition ]['code'] ) {
// This exception will (probably) be caught, so ignore it.
return $end;
}
$call_token = $this->phpcsFile->findPrevious( Tokens::$emptyTokens, ( $next_relevant - 1 ), null, true );
$params = PassedParameters::getParameters( $this->phpcsFile, $call_token );
if ( empty( $params ) ) {
// No parameters passed, nothing to do.
return $end;
}
// Examine each parameter individually.
foreach ( $params as $param ) {
$this->check_code_is_escaped( $param['start'], ( $param['end'] + 1 ), 'ExceptionNotEscaped' );
}
return $end;
case \T_PRINT:
$end = BCFile::findEndOfStatement( $this->phpcsFile, $stackPtr );
if ( \T_COMMA !== $this->tokens[ $end ]['code']
&& \T_SEMICOLON !== $this->tokens[ $end ]['code']
&& \T_COLON !== $this->tokens[ $end ]['code']
&& \T_DOUBLE_ARROW !== $this->tokens[ $end ]['code']
&& isset( $this->tokens[ ( $end + 1 ) ] )
) {
/*
* FindEndOfStatement includes a comma/(semi-)colon/double arrow if that's the end of
* the statement, but for everything else, it returns the last non-empty token _before_
* the end, which would mean the last non-empty token in the statement would not
* be examined. Let's fix that.
*/
++$end;
}
// Note: no need to check for close tag as close tag will have the token before the tag as the $end.
if ( $end >= ( $this->phpcsFile->numTokens - 1 ) ) {
$last_non_empty = $this->phpcsFile->findPrevious( Tokens::$emptyTokens, $end, null, true );
if ( \T_SEMICOLON !== $this->tokens[ $last_non_empty ]['code'] ) {
// Live coding/parse error at end of file. Ignore.
return;
}
}
// Special case for a print statement *within* a ternary, where we need to find the "inline else" as the end token.
$prev_non_empty = $this->phpcsFile->findPrevious( Tokens::$emptyTokens, ( $stackPtr - 1 ), null, true );
if ( \T_INLINE_THEN === $this->tokens[ $prev_non_empty ]['code'] ) {
$target_nesting_level = 0;
if ( empty( $this->tokens[ $stackPtr ]['nested_parenthesis'] ) === false ) {
$target_nesting_level = \count( $this->tokens[ $stackPtr ]['nested_parenthesis'] );
}
$inline_else = false;
for ( $i = ( $stackPtr + 1 ); $i < $end; $i++ ) {
if ( \T_INLINE_ELSE !== $this->tokens[ $i ]['code'] ) {
continue;
}
if ( empty( $this->tokens[ $i ]['nested_parenthesis'] )
&& 0 === $target_nesting_level
) {
$inline_else = $i;
break;
}
if ( empty( $this->tokens[ $i ]['nested_parenthesis'] ) === false
&& \count( $this->tokens[ $i ]['nested_parenthesis'] ) === $target_nesting_level
) {
$inline_else = $i;
break;
}
}
if ( false === $inline_else ) {
// Live coding/parse error. Bow out.
return;
}
$end = $inline_else;
}
break;
// Echo, open tag with echo.
default:
$end = $this->phpcsFile->findNext( array( \T_SEMICOLON, \T_CLOSE_TAG ), $stackPtr );
if ( false === $end ) {
// Live coding/parse error. Bow out.
return;
}
break;
}
return $this->check_code_is_escaped( $start, $end );
}
/**
* Process a matched function call token.
*
* @since 3.0.0 Split off from the process_token() method.
*
* @param int $stackPtr The position of the current token in the stack.
* @param string $group_name The name of the group which was matched.
* @param string $matched_content The token content (function name) which was matched
* in lowercase.
*
* @return int|void Integer stack pointer to skip forward or void to continue
* normal file processing.
*/
public function process_matched_token( $stackPtr, $group_name, $matched_content ) {
// Make sure we only deal with actual function calls, not function import use statements.
$next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $stackPtr + 1 ), null, true );
if ( false === $next_non_empty
|| \T_OPEN_PARENTHESIS !== $this->tokens[ $next_non_empty ]['code']
|| isset( $this->tokens[ $next_non_empty ]['parenthesis_closer'] ) === false
) {
// Live coding, parse error or not a function _call_.
return;
}
$end = $this->tokens[ $next_non_empty ]['parenthesis_closer'];
if ( 'unsafe_printing_functions' === $group_name ) {
$error = $this->phpcsFile->addError(
"All output should be run through an escaping function (like %s), found '%s'.",
$stackPtr,
'UnsafePrintingFunction',
array( $this->unsafePrintingFunctions[ $matched_content ]['alternative'], $matched_content )
);
// If the error was reported, don't bother checking the function's arguments.
if ( $error || empty( $this->unsafePrintingFunctions[ $matched_content ]['params'] ) ) {
return $end;
}
// If the function was not reported for being unsafe, examine the relevant parameters.
$params = PassedParameters::getParameters( $this->phpcsFile, $stackPtr );
foreach ( $this->unsafePrintingFunctions[ $matched_content ]['params'] as $position => $name ) {
$param = PassedParameters::getParameterFromStack( $params, $position, $name );
if ( false === $param ) {
// Parameter doesn't exist. Nothing to do.
continue;
}
$this->check_code_is_escaped( $param['start'], ( $param['end'] + 1 ) );
}
return $end;
}
$params = PassedParameters::getParameters( $this->phpcsFile, $stackPtr );
/*
* These functions only need to have their first argument - `$message` - escaped.
* Note: user_error() is an alias for trigger_error(), so the param names are the same.
*/
if ( 'trigger_error' === $matched_content || 'user_error' === $matched_content ) {
$message_param = PassedParameters::getParameterFromStack( $params, 1, 'message' );
if ( false === $message_param ) {
// Message parameter doesn't exist. Nothing to do.
return $end;
}
return $this->check_code_is_escaped( $message_param['start'], ( $message_param['end'] + 1 ) );
}
/*
* If the first param to `_deprecated_file()` - `$file` - follows the typical `basename( __FILE__ )`
* pattern, it doesn't need to be escaped.
*/
if ( '_deprecated_file' === $matched_content ) {
$file_param = PassedParameters::getParameterFromStack( $params, 1, 'file' );
if ( false !== $file_param ) {
// Check for a particular code pattern which can safely be ignored.
if ( preg_match( '`^[\\\\]?basename\s*\(\s*__FILE__\s*\)$`', $file_param['clean'] ) === 1 ) {
unset( $params[1], $params['file'] ); // Remove the param, whether passed positionally or named.
}
}
unset( $file_param );
}
// Examine each parameter individually.
foreach ( $params as $param ) {
$this->check_code_is_escaped( $param['start'], ( $param['end'] + 1 ) );
}
return $end;
}
/**
* Check whether each relevant part of an arbitrary group of token is output escaped.
*
* @since 3.0.0 Split off from the process_token() method.
*
* @param int $start The position to start checking from.
* @param int $end The position to stop the check at.
* @param string $code Code to use for the PHPCS error.
*
* @return int Integer stack pointer to skip forward.
*/
protected function check_code_is_escaped( $start, $end, $code = 'OutputNotEscaped' ) {
/*
* Check for a ternary operator.
* We only need to do this here if this statement is lacking parenthesis.
* Otherwise it will be handled in the below loop.
*/
$ternary = false;
$next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $start + 1 ), null, true );
$last_non_empty = $this->phpcsFile->findPrevious( Tokens::$emptyTokens, ( $end - 1 ), null, true );
if ( \T_OPEN_PARENTHESIS !== $this->tokens[ $next_non_empty ]['code']
|| \T_CLOSE_PARENTHESIS !== $this->tokens[ $last_non_empty ]['code']
|| ( \T_OPEN_PARENTHESIS === $this->tokens[ $next_non_empty ]['code']
&& \T_CLOSE_PARENTHESIS === $this->tokens[ $last_non_empty ]['code']
&& isset( $this->tokens[ $next_non_empty ]['parenthesis_closer'] )
&& $this->tokens[ $next_non_empty ]['parenthesis_closer'] !== $last_non_empty
)
) {
// If there is a (long) ternary skip over the part before the ?.
$ternary = $this->find_long_ternary( $start, $end );
if ( false !== $ternary ) {
$start = ( $ternary + 1 );
}
}
$in_cast = false;
$watch = true;
// Looping through echo'd components.
for ( $i = $start; $i < $end; $i++ ) {
// Ignore whitespaces and comments.
if ( isset( Tokens::$emptyTokens[ $this->tokens[ $i ]['code'] ] ) ) {
continue;
}
// Skip over irrelevant tokens.
if ( isset( Tokens::$magicConstants[ $this->tokens[ $i ]['code'] ] ) // Magic constants for debug functions.
|| \T_NS_SEPARATOR === $this->tokens[ $i ]['code']
|| \T_DOUBLE_ARROW === $this->tokens[ $i ]['code']
|| \T_CLOSE_PARENTHESIS === $this->tokens[ $i ]['code']
) {
continue;
}
if ( \T_OPEN_PARENTHESIS === $this->tokens[ $i ]['code'] ) {
if ( ! isset( $this->tokens[ $i ]['parenthesis_closer'] ) ) {
// Live coding or parse error.
break;
}
if ( $in_cast ) {
// Skip to the end of a function call if it has been casted to a safe value.
$i = $this->tokens[ $i ]['parenthesis_closer'];
$in_cast = false;
} else {
// Skip over the condition part of a (long) ternary (i.e., to after the ?).
$ternary = $this->find_long_ternary( ( $i + 1 ), $this->tokens[ $i ]['parenthesis_closer'] );
if ( false !== $ternary ) {
$i = $ternary;
}
}
continue;
}
/*
* If a keyword is encountered in an inline expression and the keyword is one
* this sniff listens to, recurse into the sniff, handle the expression
* based on the keyword and skip over the code examined.
*/
if ( isset( $this->target_keywords[ $this->tokens[ $i ]['code'] ] ) ) {
$return_value = $this->process_token( $i );
if ( isset( $return_value ) ) {
$i = $return_value;
}
continue;
}
// Handle PHP 8.0+ match expressions.
if ( \T_MATCH === $this->tokens[ $i ]['code'] ) {
$match_valid = $this->walk_match_expression( $i, $code );
if ( false === $match_valid ) {
// Live coding or parse error. Shouldn't be possible as PHP[CS] will tokenize the keyword as `T_STRING` in that case.
break; // @codeCoverageIgnore
}
$i = $match_valid;
continue;
}
// Examine the items in an array individually for array parameters.
if ( isset( Collections::arrayOpenTokensBC()[ $this->tokens[ $i ]['code'] ] ) ) {
$array_open_close = Arrays::getOpenClose( $this->phpcsFile, $i );
if ( false === $array_open_close ) {
// Short list or misidentified short array token.
continue;
}
$array_items = PassedParameters::getParameters( $this->phpcsFile, $i, 0, true );
if ( ! empty( $array_items ) ) {
foreach ( $array_items as $array_item ) {
$this->check_code_is_escaped( $array_item['start'], ( $array_item['end'] + 1 ), $code );
}
}
$i = $array_open_close['closer'];
continue;
}
// Ignore safe PHP native constants.
if ( \T_STRING === $this->tokens[ $i ]['code']
&& isset( $this->safe_php_constants[ $this->tokens[ $i ]['content'] ] )
&& ConstantsHelper::is_use_of_global_constant( $this->phpcsFile, $i )
) {
continue;
}
// Wake up on concatenation characters, another part to check.
if ( \T_STRING_CONCAT === $this->tokens[ $i ]['code'] ) {
$watch = true;
continue;
}
// Wake up after a ternary else (:).
if ( false !== $ternary && \T_INLINE_ELSE === $this->tokens[ $i ]['code'] ) {
$watch = true;
continue;
}
// Wake up for commas.
if ( \T_COMMA === $this->tokens[ $i ]['code'] ) {
$in_cast = false;
$watch = true;
continue;
}
if ( false === $watch ) {
continue;
}
// Allow T_CONSTANT_ENCAPSED_STRING eg: echo 'Some String';
// Also T_LNUMBER, e.g.: echo 45; exit -1; and booleans.
if ( isset( $this->safe_components[ $this->tokens[ $i ]['code'] ] ) ) {
continue;
}
// Check for use of *::class.
if ( \T_STRING === $this->tokens[ $i ]['code']
|| \T_VARIABLE === $this->tokens[ $i ]['code']
|| isset( Collections::ooHierarchyKeywords()[ $this->tokens[ $i ]['code'] ] )
) {
$double_colon = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), $end, true );
if ( false !== $double_colon
&& \T_DOUBLE_COLON === $this->tokens[ $double_colon ]['code']
) {
$class_keyword = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $double_colon + 1 ), $end, true );
if ( false !== $class_keyword
&& \T_STRING === $this->tokens[ $class_keyword ]['code']
&& 'class' === strtolower( $this->tokens[ $class_keyword ]['content'] )
) {
$i = $class_keyword;
continue;
}
}
}
$watch = false;
// Allow int/double/bool casted variables.
if ( isset( ContextHelper::get_safe_cast_tokens()[ $this->tokens[ $i ]['code'] ] ) ) {
/*
* If the next thing is a match expression, skip over it as whatever is
* being returned will be safe casted.
* Do not set `$in_cast` to `true`.
*/
$next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), $end, true );
if ( false !== $next_non_empty
&& \T_MATCH === $this->tokens[ $next_non_empty ]['code']
&& isset( $this->tokens[ $next_non_empty ]['scope_closer'] )
) {
$i = $this->tokens[ $next_non_empty ]['scope_closer'];
continue;
}
$in_cast = true;
continue;
}
// Handle heredocs separately as they only need escaping when interpolation is used.
if ( \T_START_HEREDOC === $this->tokens[ $i ]['code'] ) {
$current = ( $i + 1 );
while ( isset( $this->tokens[ $current ] ) && \T_HEREDOC === $this->tokens[ $current ]['code'] ) {
$embeds = TextStrings::getEmbeds( $this->tokens[ $current ]['content'] );
if ( ! empty( $embeds ) ) {
$this->phpcsFile->addError(
'All output should be run through an escaping function (see the Security sections in the WordPress Developer Handbooks), found interpolation in unescaped heredoc.',
$current,
'HeredocOutputNotEscaped'
);
}
++$current;
}
$i = $current;
continue;
}
// Now check that next token is a function call.
if ( \T_STRING === $this->tokens[ $i ]['code'] ) {
$ptr = $i;
$functionName = $this->tokens[ $i ]['content'];
$function_opener = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), null, true );
$is_formatting_function = FormattingFunctionsHelper::is_formatting_function( $functionName );
if ( false !== $function_opener
&& \T_OPEN_PARENTHESIS === $this->tokens[ $function_opener ]['code']
) {
if ( ArrayWalkingFunctionsHelper::is_array_walking_function( $functionName ) ) {
// Get the callback parameter.
$callback = ArrayWalkingFunctionsHelper::get_callback_parameter( $this->phpcsFile, $ptr );
if ( ! empty( $callback ) ) {
/*
* If this is a function callback (not a method callback array) and we're able
* to resolve the function name, do so.
*/
$mapped_function = $this->phpcsFile->findNext(
Tokens::$emptyTokens,
$callback['start'],
( $callback['end'] + 1 ),
true
);
if ( false !== $mapped_function
&& \T_CONSTANT_ENCAPSED_STRING === $this->tokens[ $mapped_function ]['code']
) {
$functionName = TextStrings::stripQuotes( $this->tokens[ $mapped_function ]['content'] );
$ptr = $mapped_function;
}
}
}
// If this is a formatting function, we examine the parameters individually.
if ( $is_formatting_function ) {
$formatting_params = PassedParameters::getParameters( $this->phpcsFile, $i );
if ( ! empty( $formatting_params ) ) {
foreach ( $formatting_params as $format_param ) {
$this->check_code_is_escaped( $format_param['start'], ( $format_param['end'] + 1 ), $code );
}
}
$watch = true;
}
// Skip pointer to after the function.
if ( isset( $this->tokens[ $function_opener ]['parenthesis_closer'] ) ) {
$i = $this->tokens[ $function_opener ]['parenthesis_closer'];
} else {
// Live coding or parse error.
break;
}
}
// If this is a safe function, we don't flag it.
if ( $is_formatting_function
|| $this->is_escaping_function( $functionName )
|| $this->is_auto_escaped_function( $functionName )
) {
// Special case get_search_query() which is unsafe if $escaped = false.
if ( 'get_search_query' === strtolower( $functionName ) ) {
$escaped_param = PassedParameters::getParameter( $this->phpcsFile, $ptr, 1, 'escaped' );
if ( false !== $escaped_param && 'true' !== $escaped_param['clean'] ) {
$this->phpcsFile->addError(
'Output from get_search_query() is unsafe due to $escaped parameter being set to "false".',
$ptr,
'UnsafeSearchQuery'
);
}
}
continue;
}
$content = $functionName;
} else {
$content = $this->tokens[ $i ]['content'];
$ptr = $i;
}
// Make the error message a little more informative for array access variables.
if ( \T_VARIABLE === $this->tokens[ $ptr ]['code'] ) {
$array_keys = VariableHelper::get_array_access_keys( $this->phpcsFile, $ptr );
if ( ! empty( $array_keys ) ) {
$content .= '[' . implode( '][', $array_keys ) . ']';
}
}
$this->phpcsFile->addError(
"All output should be run through an escaping function (see the Security sections in the WordPress Developer Handbooks), found '%s'.",
$ptr,
$code,
array( $content )
);
}
return $end;
}
/**
* Check whether there is a ternary token at the right nesting level in an arbitrary set of tokens.
*
* @since 3.0.0 Split off from the process_token() method.
*
* @param int $start The position to start checking from.
* @param int $end The position to stop the check at.
*
* @return int|false Stack pointer to the ternary or FALSE if no ternary was found or
* if this is a short ternary.
*/
private function find_long_ternary( $start, $end ) {
for ( $i = $start; $i < $end; $i++ ) {
// Ignore anything within square brackets.
if ( isset( $this->tokens[ $i ]['bracket_opener'], $this->tokens[ $i ]['bracket_closer'] )
&& $i === $this->tokens[ $i ]['bracket_opener']
) {
$i = $this->tokens[ $i ]['bracket_closer'];
continue;
}
// Skip past nested arrays, function calls and arbitrary groupings.
if ( \T_OPEN_PARENTHESIS === $this->tokens[ $i ]['code']
&& isset( $this->tokens[ $i ]['parenthesis_closer'] )
) {
$i = $this->tokens[ $i ]['parenthesis_closer'];
continue;
}
// Skip past closures, anonymous classes and anything else scope related.
if ( isset( $this->tokens[ $i ]['scope_condition'], $this->tokens[ $i ]['scope_closer'] )
&& $this->tokens[ $i ]['scope_condition'] === $i
) {
$i = $this->tokens[ $i ]['scope_closer'];
continue;
}
if ( \T_INLINE_THEN !== $this->tokens[ $i ]['code'] ) {
continue;
}
/*
* Okay, we found a ternary and it should be at the correct nesting level.
* If this is a short ternary, it shouldn't be ignored though.
*/
if ( Operators::isShortTernary( $this->phpcsFile, $i ) === true ) {
return false;
}
return $i;
}
return false;
}
/**
* Examine a match expression and only check for escaping in the "returned" parts of the match expression.
*
* {@internal PHPCSUtils will likely contain a utility for parsing match expressions in the future.
* Ref: https://github.com/PHPCSStandards/PHPCSUtils/issues/497}
*
* @since 3.0.0
*
* @param int $stackPtr Pointer to a T_MATCH token.
* @param string $code Code to use for the PHPCS error.
*
* @return int|false Stack pointer to skip to or FALSE if the match expression contained a parse error.
*/
private function walk_match_expression( $stackPtr, $code ) {
if ( ! isset( $this->tokens[ $stackPtr ]['scope_opener'], $this->tokens[ $stackPtr ]['scope_closer'] ) ) {
// Parse error/live coding. Shouldn't be possible as PHP[CS] will tokenize the keyword as `T_STRING` in that case.
return false; // @codeCoverageIgnore
}
$current = $this->tokens[ $stackPtr ]['scope_opener'];
$end = $this->tokens[ $stackPtr ]['scope_closer'];
do {
$current = $this->phpcsFile->findNext( \T_MATCH_ARROW, ( $current + 1 ), $end );
if ( false === $current ) {
// We must have reached the last match item (or there is a parse error).
break;
}
$item_start = ( $current + 1 );
$item_end = false;
// Find the first comma at the same level.
for ( $i = $item_start; $i <= $end; $i++ ) {
// Ignore anything within square brackets.
if ( isset( $this->tokens[ $i ]['bracket_opener'], $this->tokens[ $i ]['bracket_closer'] )
&& $i === $this->tokens[ $i ]['bracket_opener']
) {
$i = $this->tokens[ $i ]['bracket_closer'];
continue;
}
// Skip past nested arrays, function calls and arbitrary groupings.
if ( \T_OPEN_PARENTHESIS === $this->tokens[ $i ]['code']
&& isset( $this->tokens[ $i ]['parenthesis_closer'] )
) {
$i = $this->tokens[ $i ]['parenthesis_closer'];
continue;
}
// Skip past closures, anonymous classes and anything else scope related.
if ( isset( $this->tokens[ $i ]['scope_condition'], $this->tokens[ $i ]['scope_closer'] )
&& $this->tokens[ $i ]['scope_condition'] === $i
) {
$i = $this->tokens[ $i ]['scope_closer'];
continue;
}
if ( \T_COMMA !== $this->tokens[ $i ]['code']
&& $i !== $end
) {
continue;
}
$item_end = $i;
break;
}
if ( false === $item_end ) {
// Parse error/live coding. Shouldn't be possible.
return false; // @codeCoverageIgnore
}
// Now check that the value returned by this match "leaf" is correctly escaped.
$this->check_code_is_escaped( $item_start, $item_end, $code );
// Independently of whether or not the check was succesfull or ran into (parse error) problems,
// always skip to the identified end of the item.
$current = $item_end;
} while ( $current < $end );
return $end;
}
}