diff --git a/src/wp-includes/class-wp-superglobals.php b/src/wp-includes/class-wp-superglobals.php new file mode 100644 index 0000000000000..aa31248c15b92 --- /dev/null +++ b/src/wp-includes/class-wp-superglobals.php @@ -0,0 +1,185 @@ +data = &$superglobal; + $this->name = $name; + } + + /** + * Retrieves an unslashed value from the superglobal. + * + * @since x.x.x + * + * @param string $key The key to retrieve. + * @param mixed $default_value Optional. Default value to return if the key does not exist. + * Default null. + * @return mixed The unslashed value, or `$default_value` if the key is not set. + */ + public function get( $key, $default_value = null ) { + if ( ! isset( $this->data[ $key ] ) ) { + return $default_value; + } + + return wp_unslash( $this->data[ $key ] ); + } + + /** + * Checks whether a key exists in the superglobal. + * + * @since x.x.x + * + * @param string $key The key to check. + * @return bool True if the key exists, false otherwise. + */ + public function has( $key ) { + return isset( $this->data[ $key ] ); + } + + /** + * Retrieves all values from the superglobal, unslashed. + * + * @since x.x.x + * + * @return array All unslashed values. + */ + public function all() { + return wp_unslash( $this->data ); + } + + /** + * Checks if a parameter is set. + * + * @since x.x.x + * + * @param string $offset Key to check. + * @return bool Whether the key is set. + */ + #[ReturnTypeWillChange] + public function offsetExists( $offset ) { + return isset( $this->data[ $offset ] ); + } + + /** + * Retrieves an unslashed value by key. + * + * @since x.x.x + * + * @param string $offset Key to retrieve. + * @return mixed|null Unslashed value if set, null otherwise. + */ + #[ReturnTypeWillChange] + public function offsetGet( $offset ) { + return $this->get( $offset ); + } + + /** + * Not implemented. Superglobal wrappers are read-only. + * + * This method is a no-op to maintain read-only behavior. + * + * @since x.x.x + * + * @param string $offset Key to set. + * @param mixed $value Value to set. + */ + #[ReturnTypeWillChange] + public function offsetSet( $offset, $value ) {} + + /** + * Not implemented. Superglobal wrappers are read-only. + * + * This method is a no-op to maintain read-only behavior. + * + * @since x.x.x + * + * @param string $offset Key to unset. + */ + #[ReturnTypeWillChange] + public function offsetUnset( $offset ) {} + + /** + * Returns the number of entries in the superglobal. + * + * @since x.x.x + * + * @return int Number of entries. + */ + #[ReturnTypeWillChange] + public function count() { + return count( $this->data ); + } + + /** + * Returns an iterator over all unslashed values. + * + * @since x.x.x + * + * @return ArrayIterator Iterator over unslashed key-value pairs. + */ + #[ReturnTypeWillChange] + public function getIterator() { + return new ArrayIterator( $this->all() ); + } +} diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 27c58b57dd671..3e22a208ca986 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -1279,10 +1279,21 @@ function wp_set_internal_encoding() { * Also forces `$_REQUEST` to be `$_GET + $_POST`. If `$_SERVER`, * `$_COOKIE`, or `$_ENV` are needed, use those superglobals directly. * + * Initializes the global WP_Superglobals instances that provide unslashed + * access to the superglobal values. + * * @since 3.0.0 * @access private + * + * @global WP_Superglobals $wp_get Unslashed access to $_GET. + * @global WP_Superglobals $wp_post Unslashed access to $_POST. + * @global WP_Superglobals $wp_request Unslashed access to $_REQUEST. + * @global WP_Superglobals $wp_cookie Unslashed access to $_COOKIE. + * @global WP_Superglobals $wp_server Unslashed access to $_SERVER. */ function wp_magic_quotes() { + global $wp_get, $wp_post, $wp_request, $wp_cookie, $wp_server; + // Escape with wpdb. $_GET = add_magic_quotes( $_GET ); $_POST = add_magic_quotes( $_POST ); @@ -1291,6 +1302,150 @@ function wp_magic_quotes() { // Force REQUEST to be GET + POST. $_REQUEST = array_merge( $_GET, $_POST ); + + /* + * Initialize global WP_Superglobals instances. + * + * These wrap the live superglobals by reference and transparently + * strip slashes on read, so callers never need to call wp_unslash() + * manually. Initialized after add_magic_quotes() so the wrappers + * always reference the slashed superglobals (and unslash on access). + */ + $wp_get = new WP_Superglobals( $_GET, '$_GET' ); + $wp_post = new WP_Superglobals( $_POST, '$_POST' ); + $wp_request = new WP_Superglobals( $_REQUEST, '$_REQUEST' ); + $wp_cookie = new WP_Superglobals( $_COOKIE, '$_COOKIE' ); + $wp_server = new WP_Superglobals( $_SERVER, '$_SERVER' ); +} + +/** + * Retrieves an unslashed value from the $_GET superglobal. + * + * Returns the value without the slashes added by wp_magic_quotes(), + * removing the need for manual wp_unslash() calls. + * + * This is analogous to PHP's filter_input( INPUT_GET, ... ) but returns + * the raw unslashed value rather than a filtered one. + * + * @since x.x.x + * + * @param string $key The key to retrieve from $_GET. + * @param mixed $default_value Optional. Default value to return if the key does not exist. + * Default null. + * @return mixed The unslashed value, or `$default_value` if the key is not set. + */ +function wp_input_get( $key, $default_value = null ) { + global $wp_get; + + if ( ! $wp_get instanceof WP_Superglobals ) { + return isset( $_GET[ $key ] ) ? wp_unslash( $_GET[ $key ] ) : $default_value; + } + + return $wp_get->get( $key, $default_value ); +} + +/** + * Retrieves an unslashed value from the $_POST superglobal. + * + * Returns the value without the slashes added by wp_magic_quotes(), + * removing the need for manual wp_unslash() calls. + * + * This is analogous to PHP's filter_input( INPUT_POST, ... ) but returns + * the raw unslashed value rather than a filtered one. + * + * @since x.x.x + * + * @param string $key The key to retrieve from $_POST. + * @param mixed $default_value Optional. Default value to return if the key does not exist. + * Default null. + * @return mixed The unslashed value, or `$default_value` if the key is not set. + */ +function wp_input_post( $key, $default_value = null ) { + global $wp_post; + + if ( ! $wp_post instanceof WP_Superglobals ) { + return isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : $default_value; + } + + return $wp_post->get( $key, $default_value ); +} + +/** + * Retrieves an unslashed value from the $_REQUEST superglobal. + * + * Returns the value without the slashes added by wp_magic_quotes(), + * removing the need for manual wp_unslash() calls. + * + * This is analogous to PHP's filter_input( INPUT_REQUEST, ... ) but returns + * the raw unslashed value rather than a filtered one. + * + * @since x.x.x + * + * @param string $key The key to retrieve from $_REQUEST. + * @param mixed $default_value Optional. Default value to return if the key does not exist. + * Default null. + * @return mixed The unslashed value, or `$default_value` if the key is not set. + */ +function wp_input_request( $key, $default_value = null ) { + global $wp_request; + + if ( ! $wp_request instanceof WP_Superglobals ) { + return isset( $_REQUEST[ $key ] ) ? wp_unslash( $_REQUEST[ $key ] ) : $default_value; + } + + return $wp_request->get( $key, $default_value ); +} + +/** + * Retrieves an unslashed value from the $_COOKIE superglobal. + * + * Returns the value without the slashes added by wp_magic_quotes(), + * removing the need for manual wp_unslash() calls. + * + * This is analogous to PHP's filter_input( INPUT_COOKIE, ... ) but returns + * the raw unslashed value rather than a filtered one. + * + * @since x.x.x + * + * @param string $key The key to retrieve from $_COOKIE. + * @param mixed $default_value Optional. Default value to return if the key does not exist. + * Default null. + * @return mixed The unslashed value, or `$default_value` if the key is not set. + */ +function wp_input_cookie( $key, $default_value = null ) { + global $wp_cookie; + + if ( ! $wp_cookie instanceof WP_Superglobals ) { + return isset( $_COOKIE[ $key ] ) ? wp_unslash( $_COOKIE[ $key ] ) : $default_value; + } + + return $wp_cookie->get( $key, $default_value ); +} + +/** + * Retrieves an unslashed value from the $_SERVER superglobal. + * + * Returns the value without the slashes added by wp_magic_quotes(), + * removing the need for manual wp_unslash() calls. + * + * This is analogous to PHP's filter_input( INPUT_SERVER, ... ) but returns + * the raw unslashed value rather than a filtered one. + * + * @since x.x.x + * + * @param string $key The key to retrieve from $_SERVER. + * @param mixed $default_value Optional. Default value to return if the key does not exist. + * Default null. + * @return mixed The unslashed value, or `$default_value` if the key is not set. + */ +function wp_input_server( $key, $default_value = null ) { + global $wp_server; + + if ( ! $wp_server instanceof WP_Superglobals ) { + return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : $default_value; + } + + return $wp_server->get( $key, $default_value ); } /** diff --git a/src/wp-settings.php b/src/wp-settings.php index b2736bddadc3c..28f75d226d599 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -118,6 +118,7 @@ require ABSPATH . WPINC . '/class-wp-meta-query.php'; require ABSPATH . WPINC . '/class-wp-matchesmapregex.php'; require ABSPATH . WPINC . '/class-wp.php'; +require ABSPATH . WPINC . '/class-wp-superglobals.php'; require ABSPATH . WPINC . '/class-wp-error.php'; require ABSPATH . WPINC . '/pomo/mo.php'; require ABSPATH . WPINC . '/l10n/class-wp-translation-controller.php'; diff --git a/tests/phpunit/tests/load/wpSuperglobals.php b/tests/phpunit/tests/load/wpSuperglobals.php new file mode 100644 index 0000000000000..47dbfce253447 --- /dev/null +++ b/tests/phpunit/tests/load/wpSuperglobals.php @@ -0,0 +1,298 @@ +original_superglobals = array( + '_GET' => $_GET, + '_POST' => $_POST, + '_REQUEST' => $_REQUEST, + '_COOKIE' => $_COOKIE, + '_SERVER' => $_SERVER, + ); + } + + /** + * Restores original superglobals and re-runs wp_magic_quotes(). + */ + public function tear_down() { + $_GET = $this->original_superglobals['_GET']; + $_POST = $this->original_superglobals['_POST']; + $_REQUEST = $this->original_superglobals['_REQUEST']; + $_COOKIE = $this->original_superglobals['_COOKIE']; + $_SERVER = $this->original_superglobals['_SERVER']; + wp_magic_quotes(); + parent::tear_down(); + } + + /** + * Tests that get() returns unslashed values and defaults for missing keys. + * + * @ticket 22325 + */ + public function test_get_returns_unslashed_value_and_default() { + $data = array( 'key' => addslashes( "it's a test" ) ); + $wrapper = new WP_Superglobals( $data, '$_TEST' ); + + $this->assertSame( "it's a test", $wrapper->get( 'key' ) ); + $this->assertNull( $wrapper->get( 'missing' ) ); + $this->assertSame( 'fallback', $wrapper->get( 'missing', 'fallback' ) ); + } + + /** + * Tests that get() unslashes nested array values. + * + * @ticket 22325 + */ + public function test_get_returns_unslashed_nested_array() { + $data = array( + 'nested' => array( + 'child' => addslashes( "it's nested" ), + ), + ); + $wrapper = new WP_Superglobals( $data, '$_TEST' ); + + $result = $wrapper->get( 'nested' ); + $this->assertSame( "it's nested", $result['child'] ); + } + + /** + * Tests has() for existing and missing keys. + * + * @ticket 22325 + */ + public function test_has() { + $data = array( 'present' => 'value' ); + $wrapper = new WP_Superglobals( $data, '$_TEST' ); + + $this->assertTrue( $wrapper->has( 'present' ) ); + $this->assertFalse( $wrapper->has( 'absent' ) ); + } + + /** + * Tests that all() returns all values unslashed. + * + * @ticket 22325 + */ + public function test_all_returns_all_unslashed_values() { + $data = array( + 'a' => addslashes( "it's a" ), + 'b' => addslashes( "it's b" ), + ); + $wrapper = new WP_Superglobals( $data, '$_TEST' ); + + $expected = array( + 'a' => "it's a", + 'b' => "it's b", + ); + $this->assertSame( $expected, $wrapper->all() ); + } + + /** + * Tests ArrayAccess: offsetExists, offsetGet, and read-only behavior. + * + * @ticket 22325 + */ + public function test_array_access() { + $data = array( 'key' => addslashes( "it's a value" ) ); + $wrapper = new WP_Superglobals( $data, '$_TEST' ); + + // offsetExists. + $this->assertTrue( isset( $wrapper['key'] ) ); + $this->assertFalse( isset( $wrapper['missing'] ) ); + + // offsetGet. + $this->assertSame( "it's a value", $wrapper['key'] ); + $this->assertNull( $wrapper['missing'] ); + + // offsetSet is a no-op. + $wrapper['key'] = 'modified'; + $this->assertSame( "it's a value", $wrapper['key'] ); + + // offsetUnset is a no-op. + unset( $wrapper['key'] ); + $this->assertTrue( isset( $wrapper['key'] ) ); + } + + /** + * Tests Countable and IteratorAggregate implementations. + * + * @ticket 22325 + */ + public function test_countable_and_iterable() { + $data = array( + 'a' => addslashes( "it's a" ), + 'b' => addslashes( "it's b" ), + ); + $wrapper = new WP_Superglobals( $data, '$_TEST' ); + + $this->assertCount( 2, $wrapper ); + + $result = array(); + foreach ( $wrapper as $key => $value ) { + $result[ $key ] = $value; + } + + $expected = array( + 'a' => "it's a", + 'b' => "it's b", + ); + $this->assertSame( $expected, $result ); + } + + /** + * Tests that the wrapper references live data, reflecting changes + * and new keys added to the underlying array. + * + * @ticket 22325 + */ + public function test_wrapper_reflects_live_superglobal_changes() { + $data = array( 'key' => 'original' ); + $wrapper = new WP_Superglobals( $data, '$_TEST' ); + + $this->assertSame( 'original', $wrapper->get( 'key' ) ); + + // Modify existing key. + $data['key'] = addslashes( "it's modified" ); + $this->assertSame( "it's modified", $wrapper->get( 'key' ) ); + + // Add new key. + $data['new_key'] = 'new_value'; + $this->assertTrue( $wrapper->has( 'new_key' ) ); + $this->assertSame( 'new_value', $wrapper->get( 'new_key' ) ); + } + + /** + * Tests global wrappers with real superglobals after wp_magic_quotes(). + * + * @ticket 22325 + */ + public function test_global_wrappers_after_wp_magic_quotes() { + global $wp_get, $wp_post, $wp_request, $wp_cookie, $wp_server; + + $this->assertInstanceOf( WP_Superglobals::class, $wp_get ); + $this->assertInstanceOf( WP_Superglobals::class, $wp_post ); + $this->assertInstanceOf( WP_Superglobals::class, $wp_request ); + $this->assertInstanceOf( WP_Superglobals::class, $wp_cookie ); + $this->assertInstanceOf( WP_Superglobals::class, $wp_server ); + + $_GET = array( 'from_get' => "get's value" ); + $_POST = array( 'from_post' => "post's value" ); + wp_magic_quotes(); + + // $_GET is slashed, wrapper is not. + $this->assertSame( addslashes( "get's value" ), $_GET['from_get'] ); + $this->assertSame( "get's value", $wp_get['from_get'] ); + $this->assertSame( "post's value", $wp_post['from_post'] ); + + // $_REQUEST merges GET + POST. + $this->assertSame( "get's value", $wp_request->get( 'from_get' ) ); + $this->assertSame( "post's value", $wp_request->get( 'from_post' ) ); + } + + /** + * Tests each wp_input_*() helper returns unslashed values. + * + * @dataProvider data_wp_input_helpers + * + * @ticket 22325 + * + * @param string $superglobal The superglobal name ('_GET', '_POST', '_COOKIE', '_SERVER'). + * @param string $function_name The helper function name. + */ + public function test_wp_input_helpers_return_unslashed( $superglobal, $function_name ) { + $GLOBALS[ $superglobal ] = array( 'test_key' => "it's a test" ); + wp_magic_quotes(); + + $this->assertSame( "it's a test", call_user_func( $function_name, 'test_key' ) ); + $this->assertNull( call_user_func( $function_name, 'missing' ) ); + $this->assertSame( 'default', call_user_func( $function_name, 'missing', 'default' ) ); + } + + /** + * Data provider for test_wp_input_helpers_return_unslashed(). + * + * @return array[] + */ + public static function data_wp_input_helpers() { + return array( + 'GET' => array( '_GET', 'wp_input_get' ), + 'POST' => array( '_POST', 'wp_input_post' ), + 'COOKIE' => array( '_COOKIE', 'wp_input_cookie' ), + 'SERVER' => array( '_SERVER', 'wp_input_server' ), + ); + } + + /** + * Tests that wp_input_request() merges GET and POST, with POST taking precedence. + * + * @ticket 22325 + */ + public function test_wp_input_request_merges_get_and_post() { + $_GET = array( + 'only_get' => "get's value", + 'shared' => "get's version", + ); + $_POST = array( + 'only_post' => "post's value", + 'shared' => "post's version", + ); + wp_magic_quotes(); + + $this->assertSame( "get's value", wp_input_request( 'only_get' ) ); + $this->assertSame( "post's value", wp_input_request( 'only_post' ) ); + $this->assertSame( "post's version", wp_input_request( 'shared' ) ); + $this->assertNull( wp_input_request( 'missing' ) ); + } + + /** + * Tests that helpers handle array values and special characters. + * + * @ticket 22325 + */ + public function test_wp_input_helpers_handle_complex_values() { + $_GET = array( + 'tags' => array( "it's tag1", "it's tag2" ), + 'empty' => '', + ); + $_POST = array( + 'quotes' => 'He said "hello"', + 'path' => 'C:\Users\test', + ); + wp_magic_quotes(); + + $this->assertSame( array( "it's tag1", "it's tag2" ), wp_input_get( 'tags' ) ); + $this->assertSame( '', wp_input_get( 'empty' ) ); + $this->assertSame( 'He said "hello"', wp_input_post( 'quotes' ) ); + $this->assertSame( 'C:\Users\test', wp_input_post( 'path' ) ); + } +}