From e97b0191ea8891a4c089c9e3221a9b37b76e6d89 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 20 Apr 2026 04:33:41 +0000 Subject: [PATCH] Security/ValidatedSanitizedInput: recognise casts through grouping parens `is_safe_casted()` currently only looks at the token immediately preceding the superglobal. The common idiom $id = (int) ( $_GET['id'] ?? 0 ); puts an open-parenthesis between the cast and the superglobal, so the helper reports the value as un-casted and the Sanitized/Unslashed sniff fires two false-positive errors. Walk outward through grouping parentheses when looking for the cast. Any non-paren, non-cast token in that outward walk stops the search, so function calls (preceded by a T_STRING) and other contexts are unaffected. Refs #2210. --- WordPress/Helpers/ContextHelper.php | 24 ++++++++++++++++--- .../ValidatedSanitizedInputUnitTest.1.inc | 22 +++++++++++++++++ .../ValidatedSanitizedInputUnitTest.php | 3 +++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/WordPress/Helpers/ContextHelper.php b/WordPress/Helpers/ContextHelper.php index 1da3286715..0e214cbc9f 100644 --- a/WordPress/Helpers/ContextHelper.php +++ b/WordPress/Helpers/ContextHelper.php @@ -355,9 +355,27 @@ public static function is_safe_casted( File $phpcsFile, $stackPtr ) { return false; } - $prev = $phpcsFile->findPrevious( Tokens::$emptyTokens, ( $stackPtr - 1 ), null, true ); - - return isset( self::$safe_casts[ $tokens[ $prev ]['code'] ] ); + // Walk outward through surrounding grouping parentheses. A leading + // cast sanitizes the enclosed value whether or not there are + // intermediate parens / coalesce operators — e.g. both + // `(int) $_GET['x']` and `(int) ( $_GET['x'] ?? 0 )` yield an int. + // Stop at the first non-empty token that isn't an open parenthesis; + // function-call parens (preceded by T_STRING) fall through to the + // not-a-cast branch correctly. + $check = $stackPtr; + do { + $prev = $phpcsFile->findPrevious( Tokens::$emptyTokens, ( $check - 1 ), null, true ); + if ( false === $prev ) { + return false; + } + if ( isset( self::$safe_casts[ $tokens[ $prev ]['code'] ] ) ) { + return true; + } + if ( \T_OPEN_PARENTHESIS !== $tokens[ $prev ]['code'] ) { + return false; + } + $check = $prev; + } while ( true ); } /** diff --git a/WordPress/Tests/Security/ValidatedSanitizedInputUnitTest.1.inc b/WordPress/Tests/Security/ValidatedSanitizedInputUnitTest.1.inc index cc4edc147a..cfa66138d6 100644 --- a/WordPress/Tests/Security/ValidatedSanitizedInputUnitTest.1.inc +++ b/WordPress/Tests/Security/ValidatedSanitizedInputUnitTest.1.inc @@ -500,3 +500,25 @@ function test_in_match_condition_is_regarded_as_comparison() { }; } } + +/* + * Type casts (int, float, bool) sanitize the enclosed value even when + * separated from the superglobal by grouping parentheses — e.g. the + * common `(int) ( $_GET['id'] ?? 0 )` idiom. Ref issue #2210. + */ +function test_cast_around_grouping_parens() { + $a = (int) ( $_POST['foo'] ?? 0 ); // OK. + $b = (int) ( $_GET['bar'] ?? 0 ); // OK. + $c = (bool) ( $_POST['flag'] ?? false ); // OK. + $d = (float) ( $_POST['rate'] ?? 0.0 ); // OK. + $e = (int) ( ( $_POST['n'] ?? 0 ) ); // OK — nested grouping parens. + + // (string) is not in the safe-cast list, so this is still unsanitised. + $g = (string) ( $_POST['s'] ?? '' ); // Bad x 2 - unslash + sanitize. + + // Array cast isn't treated as safe either. + $h = (array) ( $_POST['arr'] ?? array() ); // Bad x 2 - unslash + sanitize. + + // Non-cast leading token: no rescue from the paren-walk. + $i = abs( $_POST['foo'] ?? 0 ); // Bad x 2 - unslash + sanitize. +} diff --git a/WordPress/Tests/Security/ValidatedSanitizedInputUnitTest.php b/WordPress/Tests/Security/ValidatedSanitizedInputUnitTest.php index 065162c2a8..50b02a232a 100644 --- a/WordPress/Tests/Security/ValidatedSanitizedInputUnitTest.php +++ b/WordPress/Tests/Security/ValidatedSanitizedInputUnitTest.php @@ -114,6 +114,9 @@ public function getErrorList( $testFile = '' ) { 497 => 1, 498 => 1, 499 => 3, + 517 => 2, + 520 => 2, + 523 => 2, ); case 'ValidatedSanitizedInputUnitTest.2.inc':