From 2e09960443d6e3c6578a21b2312998ca031183f4 Mon Sep 17 00:00:00 2001 From: Sainath Poojary Date: Tue, 4 Aug 2026 19:32:32 +0530 Subject: [PATCH 1/2] HTTP API: Add wp_remote_put(), wp_remote_delete(), wp_remote_patch() and safe variants --- src/wp-includes/http.php | 165 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/http.php b/src/wp-includes/http.php index 19aec80581a44..86e1953ef5b0d 100644 --- a/src/wp-includes/http.php +++ b/src/wp-includes/http.php @@ -142,14 +142,107 @@ function wp_safe_remote_head( $url, $args = array() ) { return $http->head( $url, $args ); } +/** + * Retrieves the raw response from a safe HTTP request using the PUT method. + * + * This function is ideal when the HTTP request is being made to an arbitrary + * URL. The URL, and every URL it redirects to, are validated with wp_http_validate_url() + * to avoid Server Side Request Forgery attacks (SSRF). + * + * The only supported protocols are `http` and `https`. + * + * @since 7.1.0 + * + * @see wp_remote_request() For more information on the response array format. + * @see WP_Http::request() For default arguments information. + * @see wp_http_validate_url() For more information about how the URL is validated. + * + * @link https://owasp.org/www-community/attacks/Server_Side_Request_Forgery + * + * @param string $url URL to send the request to. + * @param array $args Optional. Request arguments. Default empty array. + * See WP_Http::request() for information on accepted arguments. + * @return array|WP_Error The response or WP_Error on failure. + * See WP_Http::request() for information on return value. + */ +function wp_safe_remote_put( $url, $args = array() ) { + $args['reject_unsafe_urls'] = true; + $defaults = array( 'method' => 'PUT' ); + $parsed_args = wp_parse_args( $args, $defaults ); + return wp_remote_request( $url, $parsed_args ); +} + +/** + * Retrieves the raw response from a safe HTTP request using the DELETE method. + * + * This function is ideal when the HTTP request is being made to an arbitrary + * URL. The URL, and every URL it redirects to, are validated with wp_http_validate_url() + * to avoid Server Side Request Forgery attacks (SSRF). + * + * The only supported protocols are `http` and `https`. + * + * @since 7.1.0 + * + * @see wp_remote_request() For more information on the response array format. + * @see WP_Http::request() For default arguments information. + * @see wp_http_validate_url() For more information about how the URL is validated. + * + * @link https://owasp.org/www-community/attacks/Server_Side_Request_Forgery + * + * @param string $url URL to send the request to. + * @param array $args Optional. Request arguments. Default empty array. + * See WP_Http::request() for information on accepted arguments. + * @return array|WP_Error The response or WP_Error on failure. + * See WP_Http::request() for information on return value. + */ +function wp_safe_remote_delete( $url, $args = array() ) { + $args['reject_unsafe_urls'] = true; + $defaults = array( 'method' => 'DELETE' ); + $parsed_args = wp_parse_args( $args, $defaults ); + return wp_remote_request( $url, $parsed_args ); +} + +/** + * Retrieves the raw response from a safe HTTP request using the PATCH method. + * + * This function is ideal when the HTTP request is being made to an arbitrary + * URL. The URL, and every URL it redirects to, are validated with wp_http_validate_url() + * to avoid Server Side Request Forgery attacks (SSRF). + * + * The only supported protocols are `http` and `https`. + * + * @since 7.1.0 + * + * @see wp_remote_request() For more information on the response array format. + * @see WP_Http::request() For default arguments information. + * @see wp_http_validate_url() For more information about how the URL is validated. + * + * @link https://owasp.org/www-community/attacks/Server_Side_Request_Forgery + * + * @param string $url URL to send the request to. + * @param array $args Optional. Request arguments. Default empty array. + * See WP_Http::request() for information on accepted arguments. + * @return array|WP_Error The response or WP_Error on failure. + * See WP_Http::request() for information on return value. + */ +function wp_safe_remote_patch( $url, $args = array() ) { + $args['reject_unsafe_urls'] = true; + $defaults = array( 'method' => 'PATCH' ); + $parsed_args = wp_parse_args( $args, $defaults ); + return wp_remote_request( $url, $parsed_args ); +} + /** * Performs an HTTP request and returns its response. * * There are other API functions available which abstract away the HTTP method: * - * - Default 'GET' for wp_remote_get() - * - Default 'POST' for wp_remote_post() - * - Default 'HEAD' for wp_remote_head() + * - Default 'GET' for wp_remote_get() + * - Default 'POST' for wp_remote_post() + * - Default 'HEAD' for wp_remote_head() + * - Default 'PUT' for wp_remote_put() + * - Default 'DELETE' for wp_remote_delete() + * - Default 'PATCH' for wp_remote_patch() * * Important: If the URL is user-controlled, use `wp_safe_remote_request()` instead. * @@ -231,6 +324,72 @@ function wp_remote_head( $url, $args = array() ) { return $http->head( $url, $args ); } +/** + * Performs an HTTP request using the PUT method and returns its response. + * + * Important: If the URL is user-controlled, use `wp_safe_remote_put()` instead. + * + * @since 7.1.0 + * + * @see wp_remote_request() For more information on the response array format. + * @see WP_Http::request() For default arguments information. + * + * @param string $url URL to send the request to. + * @param array $args Optional. Request arguments. Default empty array. + * See WP_Http::request() for information on accepted arguments. + * @return array|WP_Error The response or WP_Error on failure. + * See WP_Http::request() for information on return value. + */ +function wp_remote_put( $url, $args = array() ) { + $defaults = array( 'method' => 'PUT' ); + $parsed_args = wp_parse_args( $args, $defaults ); + return wp_remote_request( $url, $parsed_args ); +} + +/** + * Performs an HTTP request using the DELETE method and returns its response. + * + * Important: If the URL is user-controlled, use `wp_safe_remote_delete()` instead. + * + * @since 7.1.0 + * + * @see wp_remote_request() For more information on the response array format. + * @see WP_Http::request() For default arguments information. + * + * @param string $url URL to send the request to. + * @param array $args Optional. Request arguments. Default empty array. + * See WP_Http::request() for information on accepted arguments. + * @return array|WP_Error The response or WP_Error on failure. + * See WP_Http::request() for information on return value. + */ +function wp_remote_delete( $url, $args = array() ) { + $defaults = array( 'method' => 'DELETE' ); + $parsed_args = wp_parse_args( $args, $defaults ); + return wp_remote_request( $url, $parsed_args ); +} + +/** + * Performs an HTTP request using the PATCH method and returns its response. + * + * Important: If the URL is user-controlled, use `wp_safe_remote_patch()` instead. + * + * @since 7.1.0 + * + * @see wp_remote_request() For more information on the response array format. + * @see WP_Http::request() For default arguments information. + * + * @param string $url URL to send the request to. + * @param array $args Optional. Request arguments. Default empty array. + * See WP_Http::request() for information on accepted arguments. + * @return array|WP_Error The response or WP_Error on failure. + * See WP_Http::request() for information on return value. + */ +function wp_remote_patch( $url, $args = array() ) { + $defaults = array( 'method' => 'PATCH' ); + $parsed_args = wp_parse_args( $args, $defaults ); + return wp_remote_request( $url, $parsed_args ); +} + /** * Retrieves only the headers from the raw response. * From 23c197dc6dd0347d5aa35d0434e1baa997f556cd Mon Sep 17 00:00:00 2001 From: Sainath Poojary Date: Wed, 5 Aug 2026 14:33:35 +0530 Subject: [PATCH 2/2] test: add unit tests for wp_remote_{put,delete,patch} and wp_safe_remote_{put,delete,patch} --- tests/phpunit/tests/http/wpRemoteMethods.php | 321 +++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 tests/phpunit/tests/http/wpRemoteMethods.php diff --git a/tests/phpunit/tests/http/wpRemoteMethods.php b/tests/phpunit/tests/http/wpRemoteMethods.php new file mode 100644 index 0000000000000..ec5584c68dee7 --- /dev/null +++ b/tests/phpunit/tests/http/wpRemoteMethods.php @@ -0,0 +1,321 @@ +http_args = $args; + return new WP_Error( 'test_short_circuit', 'Request short-circuited for testing.' ); + } + + /** + * Helper to run a wp_remote_* function with the request intercepted. + * + * @param string $function_name The function to call, e.g. 'wp_remote_put'. + * @param string $url URL to pass. + * @param array $args Additional args to pass. + */ + private function call_remote_function( $function_name, $url = 'http://example.com/', $args = array() ) { + $this->http_args = array(); + + add_filter( 'pre_http_request', array( $this, 'http_catcher' ), 10, 2 ); + call_user_func( $function_name, $url, $args ); + remove_filter( 'pre_http_request', array( $this, 'http_catcher' ), 10, 2 ); + } + + /** + * Data provider yielding unsafe wp_remote_* function names and their expected HTTP methods. + * + * @return array[] + */ + public function data_remote_methods() { + return array( + 'PUT' => array( 'wp_remote_put', 'PUT' ), + 'DELETE' => array( 'wp_remote_delete', 'DELETE' ), + 'PATCH' => array( 'wp_remote_patch', 'PATCH' ), + ); + } + + /** + * Data provider yielding safe wp_safe_remote_* function names and their expected HTTP methods. + * + * @return array[] + */ + public function data_safe_remote_methods() { + return array( + 'PUT' => array( 'wp_safe_remote_put', 'PUT' ), + 'DELETE' => array( 'wp_safe_remote_delete', 'DELETE' ), + 'PATCH' => array( 'wp_safe_remote_patch', 'PATCH' ), + ); + } + + /** + * Data provider yielding only unsafe function names (no expected method needed). + * + * @return array[] + */ + public function data_remote_function_names() { + return array( + 'wp_remote_put' => array( 'wp_remote_put' ), + 'wp_remote_delete' => array( 'wp_remote_delete' ), + 'wp_remote_patch' => array( 'wp_remote_patch' ), + ); + } + + /** + * Data provider yielding only safe function names (no expected method needed). + * + * @return array[] + */ + public function data_safe_remote_function_names() { + return array( + 'wp_safe_remote_put' => array( 'wp_safe_remote_put' ), + 'wp_safe_remote_delete' => array( 'wp_safe_remote_delete' ), + 'wp_safe_remote_patch' => array( 'wp_safe_remote_patch' ), + ); + } + + /** + * Tests that each wp_remote_* wrapper sets the correct HTTP method. + * + * @ticket 40142 + * + * @dataProvider data_remote_methods + * + * @covers ::wp_remote_put + * @covers ::wp_remote_delete + * @covers ::wp_remote_patch + * + * @param string $function_name The function name to call. + * @param string $expected_method The expected HTTP method string. + */ + public function test_remote_methods_use_correct_http_method( $function_name, $expected_method ) { + $this->call_remote_function( $function_name ); + + $this->assertNotEmpty( + $this->http_args, + "$function_name() did not trigger a request." + ); + + $this->assertSame( + $expected_method, + $this->http_args['method'], + "$function_name() did not set the expected HTTP method '$expected_method'." + ); + } + + /** + * Tests that each wp_safe_remote_* wrapper sets the correct HTTP method. + * + * @ticket 40142 + * + * @dataProvider data_safe_remote_methods + * + * @covers ::wp_safe_remote_put + * @covers ::wp_safe_remote_delete + * @covers ::wp_safe_remote_patch + * + * @param string $function_name The function name to call. + * @param string $expected_method The expected HTTP method string. + */ + public function test_safe_remote_methods_use_correct_http_method( $function_name, $expected_method ) { + $this->call_remote_function( $function_name ); + + $this->assertNotEmpty( + $this->http_args, + "$function_name() did not trigger a request." + ); + + $this->assertSame( + $expected_method, + $this->http_args['method'], + "$function_name() did not set the expected HTTP method '$expected_method'." + ); + } + + /** + * Tests that wp_safe_remote_* wrappers set the reject_unsafe_urls flag. + * + * @ticket 40142 + * + * @dataProvider data_safe_remote_function_names + * + * @covers ::wp_safe_remote_put + * @covers ::wp_safe_remote_delete + * @covers ::wp_safe_remote_patch + * + * @param string $function_name The function name to call. + */ + public function test_safe_remote_methods_reject_unsafe_urls( $function_name ) { + $this->call_remote_function( $function_name ); + + $this->assertNotEmpty( + $this->http_args, + "$function_name() did not trigger a request." + ); + + $this->assertTrue( + $this->http_args['reject_unsafe_urls'], + "$function_name() did not set 'reject_unsafe_urls' to true." + ); + } + + /** + * Tests that the unsafe wp_remote_* wrappers do NOT set reject_unsafe_urls. + * + * @ticket 40142 + * + * @dataProvider data_remote_function_names + * + * @covers ::wp_remote_put + * @covers ::wp_remote_delete + * @covers ::wp_remote_patch + * + * @param string $function_name The function name to call. + */ + public function test_remote_methods_do_not_reject_unsafe_urls( $function_name ) { + $this->call_remote_function( $function_name ); + + $this->assertNotEmpty( + $this->http_args, + "$function_name() did not trigger a request." + ); + + $this->assertEmpty( + $this->http_args['reject_unsafe_urls'] ?? '', + "$function_name() should NOT set 'reject_unsafe_urls', but it did." + ); + } + + /** + * Tests that caller-supplied args are passed through and merged correctly + * for the unsafe wp_remote_* wrappers. + * + * @ticket 40142 + * + * @dataProvider data_remote_methods + * + * @covers ::wp_remote_put + * @covers ::wp_remote_delete + * @covers ::wp_remote_patch + * + * @param string $function_name The function name to call. + * @param string $expected_method The expected HTTP method string. + */ + public function test_remote_methods_pass_through_args( $function_name, $expected_method ) { + $this->call_remote_function( + $function_name, + 'http://example.com/', + array( + 'timeout' => 42, + 'headers' => array( 'X-Custom-Header' => 'test-value' ), + ) + ); + + $this->assertSame( + $expected_method, + $this->http_args['method'], + "$function_name() did not set the expected HTTP method '$expected_method'." + ); + + $this->assertSame( + 42, + $this->http_args['timeout'], + "$function_name() did not pass through the 'timeout' argument." + ); + } + + /** + * Tests that caller-supplied args are passed through and merged correctly + * for the safe wp_safe_remote_* wrappers. + * + * @ticket 40142 + * + * @dataProvider data_safe_remote_methods + * + * @covers ::wp_safe_remote_put + * @covers ::wp_safe_remote_delete + * @covers ::wp_safe_remote_patch + * + * @param string $function_name The function name to call. + * @param string $expected_method The expected HTTP method string. + */ + public function test_safe_remote_methods_pass_through_args( $function_name, $expected_method ) { + $this->call_remote_function( + $function_name, + 'http://example.com/', + array( + 'timeout' => 99, + 'headers' => array( 'X-Safe-Header' => 'safe-value' ), + ) + ); + + $this->assertSame( + $expected_method, + $this->http_args['method'], + "$function_name() did not set the expected HTTP method '$expected_method'." + ); + + $this->assertSame( + 99, + $this->http_args['timeout'], + "$function_name() did not pass through the 'timeout' argument." + ); + + $this->assertTrue( + $this->http_args['reject_unsafe_urls'], + "$function_name() did not set 'reject_unsafe_urls' to true." + ); + } + + /** + * Tests that a caller-supplied 'method' argument overrides the function's default HTTP method. + * + * wp_parse_args() gives caller-supplied values priority over defaults, so this is + * intentional and consistent with the behaviour of wp_remote_get(), wp_remote_post(), etc. + * + * @ticket 40142 + * + * @dataProvider data_remote_function_names + * + * @covers ::wp_remote_put + * @covers ::wp_remote_delete + * @covers ::wp_remote_patch + * + * @param string $function_name The function name to call. + */ + public function test_remote_methods_caller_can_override_method( $function_name ) { + $this->call_remote_function( + $function_name, + 'http://example.com/', + array( 'method' => 'GET' ) + ); + + $this->assertSame( + 'GET', + $this->http_args['method'], + "$function_name() should allow callers to override the HTTP method via \$args, but it did not." + ); + } +}