From 3f08a51361d5d71198d56f883d755e24bcc90eb3 Mon Sep 17 00:00:00 2001 From: Alex Moon Date: Mon, 17 Aug 2026 16:02:51 -0700 Subject: [PATCH 01/35] fix: tests and conditional set of defaults --- src/wp-includes/rest-api.php | 26 +- .../tests/rest-api/rest-cors-headers.php | 228 ++++++++++++++++++ 2 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 tests/phpunit/tests/rest-api/rest-cors-headers.php diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index d54cee18c5b39..a29d0293f1951 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -249,7 +249,7 @@ function rest_api_default_filters() { } // Default serving. - add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' ); + add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers', 10, 2 ); add_filter( 'rest_post_dispatch', 'rest_send_allow_header', 10, 3 ); add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); @@ -798,11 +798,14 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { * Sends Cross-Origin Resource Sharing headers with API requests. * * @since 4.4.0 + * @since 7.2.0 Added the `$result` parameter. An `Access-Control-Allow-Methods` + * header already set on the response is no longer overwritten. * - * @param mixed $value Response data. + * @param mixed $value Response data. + * @param WP_HTTP_Response $result Optional. Result to send to the client. Default null. * @return mixed Response data. */ -function rest_send_cors_headers( $value ) { +function rest_send_cors_headers( $value, $result = null ) { $origin = get_http_origin(); if ( $origin ) { @@ -811,7 +814,22 @@ function rest_send_cors_headers( $value ) { $origin = sanitize_url( $origin ); } header( 'Access-Control-Allow-Origin: ' . $origin ); - header( 'Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE' ); + + /* + * This runs on 'rest_pre_serve_request', after the response's own headers + * have already been emitted by WP_REST_Server::send_headers(). Sending the + * default list unconditionally would replace a value set on the response -- + * for instance through the 'rest_post_dispatch' filter -- because header() + * replaces by default. Only send the default when the response has not + * provided one. Header names are case-insensitive. + */ + $response_headers = ( $result instanceof WP_HTTP_Response ) ? $result->get_headers() : array(); + $response_headers = array_change_key_case( $response_headers ); + + if ( ! isset( $response_headers['access-control-allow-methods'] ) ) { + header( 'Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE' ); + } + header( 'Access-Control-Allow-Credentials: true' ); header( 'Vary: Origin', false ); } elseif ( ! headers_sent() && 'GET' === $_SERVER['REQUEST_METHOD'] && ! is_user_logged_in() ) { diff --git a/tests/phpunit/tests/rest-api/rest-cors-headers.php b/tests/phpunit/tests/rest-api/rest-cors-headers.php new file mode 100644 index 0000000000000..bf0ad0f04c3ce --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-cors-headers.php @@ -0,0 +1,228 @@ +serve_request( '/wp/v2/types' ); + ob_end_clean(); + + return xdebug_get_headers(); + } + + /** + * An Access-Control-Allow-Methods header set on the response reaches the client. + * + * rest_send_cors_headers() runs on 'rest_pre_serve_request', after the response's + * own headers have been emitted, so sending the default list unconditionally + * replaced any value the response supplied. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_response_can_set_access_control_allow_methods() { + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); + return $response; + } + ); + + $headers = $this->serve_and_get_headers(); + + $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); + } + + /** + * The default list is suppressed rather than duplicated when the response sets one, + * and Vary continues to append. + * + * Sending both values would leave two Access-Control-Allow-Methods headers on the wire. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_response_value_suppresses_the_default_and_vary_still_appends() { + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); + $response->header( 'Vary', 'Accept-Encoding' ); + return $response; + } + ); + + $headers = $this->serve_and_get_headers(); + + $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); + $this->assertNotContains( self::DEFAULT_ALLOW_METHODS, $headers ); + $this->assertContains( 'Vary: Accept-Encoding', $headers ); + $this->assertContains( 'Vary: Origin', $headers ); + } + + /** + * The response header is matched without regard to case, as header names are + * case-insensitive. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_response_value_is_matched_case_insensitively() { + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->header( 'access-control-allow-methods', 'OPTIONS, GET' ); + return $response; + } + ); + + $headers = $this->serve_and_get_headers(); + + $this->assertNotContains( self::DEFAULT_ALLOW_METHODS, $headers ); + } + + /** + * A callback added to 'rest_pre_serve_request' after rest_send_cors_headers() + * can still send its own method list. + * + * This has always worked and continues to work. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_late_rest_pre_serve_request_callback_can_send_its_own_list() { + add_filter( + 'rest_pre_serve_request', + static function ( $served ) { + header( 'Access-Control-Allow-Methods: OPTIONS, GET' ); + return $served; + }, + 11 + ); + + $headers = $this->serve_and_get_headers(); + + $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); + } + + /** + * A response that sets no CORS header receives the same headers as before. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_default_cors_output_is_unchanged() { + $headers = $this->serve_and_get_headers(); + + $this->assertContains( 'Access-Control-Allow-Origin: http://example.org', $headers ); + $this->assertContains( self::DEFAULT_ALLOW_METHODS, $headers ); + $this->assertContains( 'Access-Control-Allow-Credentials: true', $headers ); + $this->assertContains( 'Vary: Origin', $headers ); + } + + /** + * No CORS headers are sent when the request has no Origin. + * + * Limited to the three headers rest_send_cors_headers() sends. WP_REST_Server + * sends Access-Control-Expose-Headers from send_headers() on every request, + * with or without an Origin. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_no_cors_headers_are_sent_without_an_origin() { + unset( $_SERVER['HTTP_ORIGIN'] ); + + $headers = $this->serve_and_get_headers(); + + foreach ( array( 'Access-Control-Allow-Origin', 'Access-Control-Allow-Methods', 'Access-Control-Allow-Credentials' ) as $name ) { + foreach ( $headers as $header ) { + $this->assertStringStartsNotWith( $name . ':', $header ); + } + } + } + + /** + * Access-Control-Allow-Origin and Access-Control-Allow-Credentials are not + * settable from the response. + * + * Those two headers determine who may read an authenticated response, so they + * remain under the control of rest_send_cors_headers() alone. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_origin_and_credentials_headers_are_not_response_settable() { + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->header( 'Access-Control-Allow-Origin', 'http://plugin.example' ); + $response->header( 'Access-Control-Allow-Credentials', 'false' ); + return $response; + } + ); + + $headers = $this->serve_and_get_headers(); + + $this->assertContains( 'Access-Control-Allow-Origin: http://example.org', $headers ); + $this->assertContains( 'Access-Control-Allow-Credentials: true', $headers ); + } +} From 694fccd9329aa594396b1dddeb72dbde7702c67e Mon Sep 17 00:00:00 2001 From: Alex Moon Date: Tue, 18 Aug 2026 13:55:35 -0700 Subject: [PATCH 02/35] REST API: Add the `rest_allowed_cors_methods` filter. `Access-Control-Allow-Methods` is the only CORS list header REST API responses send that has no extension point. Its two siblings gained one in 5.5.0 -- `rest_exposed_cors_headers` and `rest_allowed_cors_headers`, both array-filtered then imploded in `WP_REST_Server::send_headers()`, and both given the request in context in 6.3.0. This one was missed each time, because it is sent from `rest_send_cors_headers()` rather than from the server. Adds a filter with the same shape, so a site can compose the list rather than unhook `rest_send_cors_headers` and reimplement it. `$request` is available because `rest_pre_serve_request` already passes it; the filter accepts three arguments to reach it. The filter runs only when the response has not set the header itself, which is the preceding change on this branch. A value on the response is the more specific instruction, and there is no default left to shape once it is present. Adds three tests to the existing CORS header coverage. See #46992. --- src/wp-includes/rest-api.php | 32 +++++-- .../tests/rest-api/rest-cors-headers.php | 89 +++++++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index a29d0293f1951..6695296048237 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -249,7 +249,7 @@ function rest_api_default_filters() { } // Default serving. - add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers', 10, 2 ); + add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers', 10, 3 ); add_filter( 'rest_post_dispatch', 'rest_send_allow_header', 10, 3 ); add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); @@ -798,14 +798,15 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { * Sends Cross-Origin Resource Sharing headers with API requests. * * @since 4.4.0 - * @since 7.2.0 Added the `$result` parameter. An `Access-Control-Allow-Methods` + * @since 7.2.0 Added the `$result` and `$request` parameters. An `Access-Control-Allow-Methods` * header already set on the response is no longer overwritten. * - * @param mixed $value Response data. - * @param WP_HTTP_Response $result Optional. Result to send to the client. Default null. + * @param mixed $value Response data. + * @param WP_HTTP_Response $result Optional. Result to send to the client. Default null. + * @param WP_REST_Request|null $request Optional. The request in context. Default null. * @return mixed Response data. */ -function rest_send_cors_headers( $value, $result = null ) { +function rest_send_cors_headers( $value, $result = null, $request = null ) { $origin = get_http_origin(); if ( $origin ) { @@ -827,7 +828,26 @@ function rest_send_cors_headers( $value, $result = null ) { $response_headers = array_change_key_case( $response_headers ); if ( ! isset( $response_headers['access-control-allow-methods'] ) ) { - header( 'Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE' ); + $allow_methods = array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ); + + /** + * Filters the list of HTTP methods advertised to REST API CORS requests. + * + * The allowed methods are passed to the browser to specify which methods + * may be used in a cross-origin request to the REST API. The list is a + * fixed default and is not derived from the registered routes, so a site + * serving routes that accept other methods can advertise them here. + * + * A value set on the response itself takes precedence over this filter. + * + * @since 7.2.0 + * + * @param string[] $allow_methods The list of HTTP methods to allow. + * @param WP_REST_Request|null $request The request in context, if available. + */ + $allow_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request ); + + header( 'Access-Control-Allow-Methods: ' . implode( ', ', $allow_methods ) ); } header( 'Access-Control-Allow-Credentials: true' ); diff --git a/tests/phpunit/tests/rest-api/rest-cors-headers.php b/tests/phpunit/tests/rest-api/rest-cors-headers.php index bf0ad0f04c3ce..2232d65f2ec63 100644 --- a/tests/phpunit/tests/rest-api/rest-cors-headers.php +++ b/tests/phpunit/tests/rest-api/rest-cors-headers.php @@ -225,4 +225,93 @@ static function ( $response ) { $this->assertContains( 'Access-Control-Allow-Origin: http://example.org', $headers ); $this->assertContains( 'Access-Control-Allow-Credentials: true', $headers ); } + + /** + * The rest_allowed_cors_methods filter changes the advertised method list. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_allowed_cors_methods_filter_changes_the_list() { + add_filter( + 'rest_allowed_cors_methods', + static function ( $methods ) { + $methods[] = 'HEAD'; + return $methods; + } + ); + + $headers = $this->serve_and_get_headers(); + + $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE, HEAD', $headers ); + $this->assertNotContains( self::DEFAULT_ALLOW_METHODS, $headers ); + } + + /** + * The filter receives the default list as an array and the request in context. + * + * Matches the signature of the sibling CORS list filters, rest_exposed_cors_headers + * and rest_allowed_cors_headers. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_allowed_cors_methods_filter_receives_the_default_list_and_the_request() { + $received = null; + + add_filter( + 'rest_allowed_cors_methods', + static function ( $methods, $request ) use ( &$received ) { + $received = array( $methods, $request ); + return $methods; + }, + 10, + 2 + ); + + $this->serve_and_get_headers(); + + $this->assertNotNull( $received, 'The filter did not run.' ); + $this->assertSame( array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ), $received[0] ); + $this->assertInstanceOf( WP_REST_Request::class, $received[1] ); + $this->assertSame( '/wp/v2/types', $received[1]->get_route() ); + } + + /** + * A value set on the response wins over the filter, and the filter does not run. + * + * The response header is the more specific instruction: the filter shapes the + * default, and there is no default to shape once the response has supplied one. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_response_value_takes_precedence_over_the_filter() { + $filter_ran = false; + + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); + return $response; + } + ); + + add_filter( + 'rest_allowed_cors_methods', + static function ( $methods ) use ( &$filter_ran ) { + $filter_ran = true; + return array( 'OPTIONS', 'DELETE' ); + } + ); + + $headers = $this->serve_and_get_headers(); + + $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); + $this->assertNotContains( 'Access-Control-Allow-Methods: OPTIONS, DELETE', $headers ); + $this->assertFalse( $filter_ran, 'The filter ran even though the response set the header.' ); + } } From 8bb0c27e62ac4becec56ad5f35cf6d378a39296f Mon Sep 17 00:00:00 2001 From: Alex Moon Date: Tue, 18 Aug 2026 15:50:09 -0700 Subject: [PATCH 03/35] REST API: Trim the ordering comment in `rest_send_cors_headers()`. Keeps the two facts the code does not show -- that this runs after the response's own headers have been sent, and that `header()` replaces by default -- and drops the sentence restating the conditional below it. See #46992. Co-Authored-By: Claude Opus 5 --- src/wp-includes/rest-api.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 6695296048237..03a615a461bfc 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -817,12 +817,10 @@ function rest_send_cors_headers( $value, $result = null, $request = null ) { header( 'Access-Control-Allow-Origin: ' . $origin ); /* - * This runs on 'rest_pre_serve_request', after the response's own headers - * have already been emitted by WP_REST_Server::send_headers(). Sending the - * default list unconditionally would replace a value set on the response -- - * for instance through the 'rest_post_dispatch' filter -- because header() - * replaces by default. Only send the default when the response has not - * provided one. Header names are case-insensitive. + * This runs on 'rest_pre_serve_request', after the response's own headers have + * been sent, and header() replaces by default -- so sending the default list + * unconditionally would discard a value set on the response. Header names are + * case-insensitive. */ $response_headers = ( $result instanceof WP_HTTP_Response ) ? $result->get_headers() : array(); $response_headers = array_change_key_case( $response_headers ); From 91580d941adad8db0b9518920b5fcac630b57466 Mon Sep 17 00:00:00 2001 From: Alex Moon Date: Tue, 15 Sep 2026 15:49:08 -0700 Subject: [PATCH 04/35] REST API: Let `rest_allowed_cors_methods` override a response's method list. The filter previously ran only when the response had not set `Access-Control-Allow-Methods` itself, on the reasoning that a value on the response is the more specific instruction. That reverses here, per review: the filter now always runs, seeded with the response's value when there is one and with the default list otherwise. Seeding is a strict superset of the previous behavior. It can express all three outcomes -- filter wins by returning its own list, response wins by returning `$allow_methods` untouched or by registering nothing, and the two compose by appending. Skipping the filter made "filter overrides a response value" unreachable at any priority, and gave the callback no way to observe that it had been bypassed. Default output is unchanged either way. Parsing the header back into an array also normalizes the append case: `WP_HTTP_Response::header()` comma-joins when called with `$replace` false, so `explode()` and `trim()` recover the individual methods. Also types `$result` as `WP_HTTP_Response|null`, matching its default, and notes that `envelope_response()` relocates the response's headers into the body before this runs, so under `?_envelope=1` a response value is not seen here. Inverts the test that pinned the old precedence and adds two: one asserting the filter receives the response's value rather than the default, one covering the envelope case. See #46992. Co-Authored-By: Claude Opus 5 --- src/wp-includes/rest-api.php | 67 +++++++++------- .../tests/rest-api/rest-cors-headers.php | 78 +++++++++++++++++-- 2 files changed, 107 insertions(+), 38 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 03a615a461bfc..c9a1a273f9231 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -798,12 +798,16 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { * Sends Cross-Origin Resource Sharing headers with API requests. * * @since 4.4.0 - * @since 7.2.0 Added the `$result` and `$request` parameters. An `Access-Control-Allow-Methods` - * header already set on the response is no longer overwritten. - * - * @param mixed $value Response data. - * @param WP_HTTP_Response $result Optional. Result to send to the client. Default null. - * @param WP_REST_Request|null $request Optional. The request in context. Default null. + * @since 7.2.0 Added the `$result` and `$request` parameters, and the `rest_allowed_cors_methods` + * filter. An `Access-Control-Allow-Methods` header already set on the response is + * no longer discarded outright -- it seeds the filter instead of the default list, + * so it is passed through unless a callback changes it. Under `?_envelope=1`, a + * response header is relocated into the response body before this runs, so it is + * not seen here and the filtered default is sent instead. + * + * @param mixed $value Response data. + * @param WP_HTTP_Response|null $result Optional. Result to send to the client. Default null. + * @param WP_REST_Request|null $request Optional. The request in context. Default null. * @return mixed Response data. */ function rest_send_cors_headers( $value, $result = null, $request = null ) { @@ -819,35 +823,38 @@ function rest_send_cors_headers( $value, $result = null, $request = null ) { /* * This runs on 'rest_pre_serve_request', after the response's own headers have * been sent, and header() replaces by default -- so sending the default list - * unconditionally would discard a value set on the response. Header names are - * case-insensitive. + * unconditionally would discard a value set on the response. Seed the filter with + * that value instead of the default list, so the response is passed through + * unless `rest_allowed_cors_methods` changes it -- otherwise the filter could + * only add methods and could never narrow or replace a value the response set. + * Header names are case-insensitive. */ $response_headers = ( $result instanceof WP_HTTP_Response ) ? $result->get_headers() : array(); $response_headers = array_change_key_case( $response_headers ); - if ( ! isset( $response_headers['access-control-allow-methods'] ) ) { - $allow_methods = array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ); - - /** - * Filters the list of HTTP methods advertised to REST API CORS requests. - * - * The allowed methods are passed to the browser to specify which methods - * may be used in a cross-origin request to the REST API. The list is a - * fixed default and is not derived from the registered routes, so a site - * serving routes that accept other methods can advertise them here. - * - * A value set on the response itself takes precedence over this filter. - * - * @since 7.2.0 - * - * @param string[] $allow_methods The list of HTTP methods to allow. - * @param WP_REST_Request|null $request The request in context, if available. - */ - $allow_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request ); - - header( 'Access-Control-Allow-Methods: ' . implode( ', ', $allow_methods ) ); - } + $allow_methods = isset( $response_headers['access-control-allow-methods'] ) + ? array_map( 'trim', explode( ',', $response_headers['access-control-allow-methods'] ) ) + : array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ); + + /** + * Filters the list of HTTP methods advertised to REST API CORS requests. + * + * The allowed methods are passed to the browser to specify which methods + * may be used in a cross-origin request to the REST API. The list is a + * fixed default and is not derived from the registered routes, so a site + * serving routes that accept other methods can advertise them here. + * + * If the response already set its own `Access-Control-Allow-Methods` header, + * `$allow_methods` is that value, split on commas, instead of the default list. + * + * @since 7.2.0 + * + * @param string[] $allow_methods The list of HTTP methods to allow. + * @param WP_REST_Request|null $request The request in context, if available. + */ + $allow_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request ); + header( 'Access-Control-Allow-Methods: ' . implode( ', ', $allow_methods ) ); header( 'Access-Control-Allow-Credentials: true' ); header( 'Vary: Origin', false ); } elseif ( ! headers_sent() && 'GET' === $_SERVER['REQUEST_METHOD'] && ! is_user_logged_in() ) { diff --git a/tests/phpunit/tests/rest-api/rest-cors-headers.php b/tests/phpunit/tests/rest-api/rest-cors-headers.php index 2232d65f2ec63..904ff63314c86 100644 --- a/tests/phpunit/tests/rest-api/rest-cors-headers.php +++ b/tests/phpunit/tests/rest-api/rest-cors-headers.php @@ -39,7 +39,7 @@ public function set_up() { } public function tear_down() { - unset( $_SERVER['HTTP_ORIGIN'] ); + unset( $_SERVER['HTTP_ORIGIN'], $_GET['_envelope'] ); global $wp_rest_server; $wp_rest_server = null; @@ -280,16 +280,17 @@ static function ( $methods, $request ) use ( &$received ) { } /** - * A value set on the response wins over the filter, and the filter does not run. + * The filter runs even when the response set its own value, and can replace it. * - * The response header is the more specific instruction: the filter shapes the - * default, and there is no default to shape once the response has supplied one. + * If the filter only ran when there was no response value, it could add methods + * but never narrow or replace a value the response set -- a site could not use it + * to enforce a stricter list against a plugin response. * * @ticket 46992 * * @requires function xdebug_get_headers */ - public function test_response_value_takes_precedence_over_the_filter() { + public function test_filter_can_override_a_response_value() { $filter_ran = false; add_filter( @@ -310,8 +311,69 @@ static function ( $methods ) use ( &$filter_ran ) { $headers = $this->serve_and_get_headers(); - $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); - $this->assertNotContains( 'Access-Control-Allow-Methods: OPTIONS, DELETE', $headers ); - $this->assertFalse( $filter_ran, 'The filter ran even though the response set the header.' ); + $this->assertTrue( $filter_ran, 'The filter did not run even though the response set the header.' ); + $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, DELETE', $headers ); + $this->assertNotContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); + } + + /** + * The filter is seeded with the response's own value, split into an array, rather + * than with the default list, when the response has set one. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_filter_receives_the_response_value_when_the_response_set_one() { + $received = null; + + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); + return $response; + } + ); + + add_filter( + 'rest_allowed_cors_methods', + static function ( $methods ) use ( &$received ) { + $received = $methods; + return $methods; + } + ); + + $this->serve_and_get_headers(); + + $this->assertSame( array( 'OPTIONS', 'GET' ), $received ); + } + + /** + * Under `?_envelope=1`, a response header is not seen and the default is sent. + * + * envelope_response() runs on 'rest_post_dispatch', ahead of rest_send_cors_headers(), + * and relocates the response's own headers into the response body, building a fresh + * response with none of its own headers. rest_send_cors_headers() never sees the + * value the response set, so the filtered default goes out on the wire instead. + * + * @ticket 46992 + * + * @requires function xdebug_get_headers + */ + public function test_envelope_response_does_not_see_a_response_value() { + $_GET['_envelope'] = '1'; + + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); + return $response; + } + ); + + $headers = $this->serve_and_get_headers(); + + $this->assertContains( self::DEFAULT_ALLOW_METHODS, $headers ); + $this->assertNotContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); } } From d2c2f0e75fc4c45ac440e0f6b56c9fb883d4a0f1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 18 Oct 2026 21:43:45 -0700 Subject: [PATCH 05/35] Add missing ext-xdebug to suggest in composer.json --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index ca41407b4241f..bf89721ac8695 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "ext-sodium": "*", "ext-ssh2": "*", "ext-timezonedb": "*", + "ext-xdebug": "*", "ext-xml": "*", "ext-xmlreader": "*", "ext-zip": "*", From 86857b284e57adc87906a1302744ba5f8f0d4d90 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 18 Oct 2026 21:44:13 -0700 Subject: [PATCH 06/35] Fix PHPStan errors in tests --- .../tests/rest-api/rest-cors-headers.php | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-cors-headers.php b/tests/phpunit/tests/rest-api/rest-cors-headers.php index 904ff63314c86..9e9cd5035c70b 100644 --- a/tests/phpunit/tests/rest-api/rest-cors-headers.php +++ b/tests/phpunit/tests/rest-api/rest-cors-headers.php @@ -35,6 +35,8 @@ public function set_up() { */ global $wp_rest_server; $wp_rest_server = new WP_REST_Server(); + + /** This action is documented in wp-includes/rest-api.php */ do_action( 'rest_api_init', $wp_rest_server ); } @@ -50,14 +52,17 @@ public function tear_down() { /** * Serves a request through the full pipeline and returns the emitted headers. * - * @return string[] Headers as xdebug reports them, after replace semantics apply. + * @return list Headers as xdebug reports them, after replace semantics apply. */ protected function serve_and_get_headers() { ob_start(); rest_get_server()->serve_request( '/wp/v2/types' ); ob_end_clean(); - return xdebug_get_headers(); + /** @var list $headers */ + $headers = xdebug_get_headers(); + + return $headers; } /** @@ -74,7 +79,7 @@ protected function serve_and_get_headers() { public function test_response_can_set_access_control_allow_methods() { add_filter( 'rest_post_dispatch', - static function ( $response ) { + static function ( WP_HTTP_Response $response ) { $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); return $response; } @@ -98,7 +103,7 @@ static function ( $response ) { public function test_response_value_suppresses_the_default_and_vary_still_appends() { add_filter( 'rest_post_dispatch', - static function ( $response ) { + static function ( WP_HTTP_Response $response ) { $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); $response->header( 'Vary', 'Accept-Encoding' ); return $response; @@ -124,7 +129,7 @@ static function ( $response ) { public function test_response_value_is_matched_case_insensitively() { add_filter( 'rest_post_dispatch', - static function ( $response ) { + static function ( WP_HTTP_Response $response ) { $response->header( 'access-control-allow-methods', 'OPTIONS, GET' ); return $response; } @@ -213,7 +218,7 @@ public function test_no_cors_headers_are_sent_without_an_origin() { public function test_origin_and_credentials_headers_are_not_response_settable() { add_filter( 'rest_post_dispatch', - static function ( $response ) { + static function ( WP_HTTP_Response $response ) { $response->header( 'Access-Control-Allow-Origin', 'http://plugin.example' ); $response->header( 'Access-Control-Allow-Credentials', 'false' ); return $response; @@ -236,7 +241,7 @@ static function ( $response ) { public function test_allowed_cors_methods_filter_changes_the_list() { add_filter( 'rest_allowed_cors_methods', - static function ( $methods ) { + static function ( array $methods ) { $methods[] = 'HEAD'; return $methods; } @@ -295,7 +300,7 @@ public function test_filter_can_override_a_response_value() { add_filter( 'rest_post_dispatch', - static function ( $response ) { + static function ( WP_HTTP_Response $response ) { $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); return $response; } @@ -329,7 +334,7 @@ public function test_filter_receives_the_response_value_when_the_response_set_on add_filter( 'rest_post_dispatch', - static function ( $response ) { + static function ( WP_HTTP_Response $response ) { $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); return $response; } @@ -365,7 +370,7 @@ public function test_envelope_response_does_not_see_a_response_value() { add_filter( 'rest_post_dispatch', - static function ( $response ) { + static function ( WP_HTTP_Response $response ) { $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); return $response; } From 326b89ef0fd6576e86f1ee60eb62b0d6f2a505a3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 18 Oct 2026 21:45:27 -0700 Subject: [PATCH 07/35] Add native type hints for params --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index c8ae39ed351b2..a46f68e7c66b8 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -812,7 +812,7 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { * @param WP_REST_Request|null $request Optional. The request in context. Default null. * @return mixed Response data. */ -function rest_send_cors_headers( $value, $result = null, $request = null ) { +function rest_send_cors_headers( $value, ?WP_HTTP_Response $result = null, ?WP_REST_Request $request = null ) { $origin = get_http_origin(); if ( $origin ) { From f961c3eb0bf27da9f9c1106364e664cf7bfa7af2 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Mon, 19 Oct 2026 08:26:28 -0700 Subject: [PATCH 08/35] Apply batched suggestions from code review Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index a46f68e7c66b8..18c6a66bb955e 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -834,9 +834,10 @@ function rest_send_cors_headers( $value, ?WP_HTTP_Response $result = null, ?WP_R $response_headers = ( $result instanceof WP_HTTP_Response ) ? $result->get_headers() : array(); $response_headers = array_change_key_case( $response_headers ); - $allow_methods = isset( $response_headers['access-control-allow-methods'] ) - ? array_map( 'trim', explode( ',', $response_headers['access-control-allow-methods'] ) ) - : array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ); + $allow_methods = array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ); + if ( isset( $response_headers['access-control-allow-methods'] ) ) { + $allow_methods = array_values( array_intersect( $allow_methods, wp_parse_list( $response_headers['access-control-allow-methods'] ) ) ); + } /** * Filters the list of HTTP methods advertised to REST API CORS requests. @@ -856,7 +857,9 @@ function rest_send_cors_headers( $value, ?WP_HTTP_Response $result = null, ?WP_R */ $allow_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request ); - header( 'Access-Control-Allow-Methods: ' . implode( ', ', $allow_methods ) ); + if ( ! empty( $allow_methods ) { + header( 'Access-Control-Allow-Methods: ' . implode( ', ', $allow_methods ) ); + } header( 'Access-Control-Allow-Credentials: true' ); header( 'Vary: Origin', false ); } elseif ( ! headers_sent() && 'GET' === $_SERVER['REQUEST_METHOD'] && ! is_user_logged_in() ) { From f1de1db708e3504b9ed1da902128ac1d21bfd48a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 19 Oct 2026 09:03:55 -0700 Subject: [PATCH 09/35] Fix syntax error from prior suggestion --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 18c6a66bb955e..fbbea0e6d8d23 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -857,7 +857,7 @@ function rest_send_cors_headers( $value, ?WP_HTTP_Response $result = null, ?WP_R */ $allow_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request ); - if ( ! empty( $allow_methods ) { + if ( ! empty( $allow_methods ) ) { header( 'Access-Control-Allow-Methods: ' . implode( ', ', $allow_methods ) ); } header( 'Access-Control-Allow-Credentials: true' ); From f862087296e6c85b4456f6911addf837f901f4fb Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 19 Oct 2026 08:30:46 -0700 Subject: [PATCH 10/35] REST API: Fix the parse error in rest_send_cors_headers(). The batched review suggestion left the empty() guard without its closing parenthesis. Also update the filter docs to describe the narrowed seed list and the omitted header. --- src/wp-includes/rest-api.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index fbbea0e6d8d23..6dead9ee25169 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -848,7 +848,8 @@ function rest_send_cors_headers( $value, ?WP_HTTP_Response $result = null, ?WP_R * serving routes that accept other methods can advertise them here. * * If the response already set its own `Access-Control-Allow-Methods` header, - * `$allow_methods` is that value, split on commas, instead of the default list. + * `$allow_methods` is the default list narrowed to the methods it names. + * An empty list omits the header. * * @since 7.2.0 * From 9a017d9afce52e024035760906bbe4ecd0718d9c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 19 Oct 2026 08:35:29 -0700 Subject: [PATCH 11/35] Tests: Drop the Xdebug dependency from the REST CORS header tests. xdebug_get_headers() is often unavailable, and the tests needed a separate process each, which is slow. Read the final method list from a late rest_allowed_cors_methods callback instead, and ignore the header() warnings PHP raises once PHPUnit has sent output. The tests now run in process without Xdebug, so the ext-xdebug suggestion in composer.json goes too. Wire-level checks of headers this change does not touch (Vary, Allow-Origin, Allow-Credentials) are dropped, since they cannot be observed without Xdebug. --- composer.json | 1 - .../tests/rest-api/rest-cors-headers.php | 339 ++++++------------ 2 files changed, 103 insertions(+), 237 deletions(-) diff --git a/composer.json b/composer.json index bf89721ac8695..ca41407b4241f 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,6 @@ "ext-sodium": "*", "ext-ssh2": "*", "ext-timezonedb": "*", - "ext-xdebug": "*", "ext-xml": "*", "ext-xmlreader": "*", "ext-zip": "*", diff --git a/tests/phpunit/tests/rest-api/rest-cors-headers.php b/tests/phpunit/tests/rest-api/rest-cors-headers.php index 9e9cd5035c70b..7eca6a7b9db2d 100644 --- a/tests/phpunit/tests/rest-api/rest-cors-headers.php +++ b/tests/phpunit/tests/rest-api/rest-cors-headers.php @@ -1,17 +1,13 @@ sent_methods = null; + add_filter( 'rest_allowed_cors_methods', array( $this, 'record_sent_methods' ), PHP_INT_MAX ); } public function tear_down() { unset( $_SERVER['HTTP_ORIGIN'], $_GET['_envelope'] ); - - global $wp_rest_server; - $wp_rest_server = null; + $GLOBALS['wp_rest_server'] = null; parent::tear_down(); } /** - * Serves a request through the full pipeline and returns the emitted headers. + * Records the method list after every other callback has run. * - * @return list Headers as xdebug reports them, after replace semantics apply. + * @param string[] $methods The list of HTTP methods to allow. + * @return string[] The unchanged list. */ - protected function serve_and_get_headers() { - ob_start(); - rest_get_server()->serve_request( '/wp/v2/types' ); - ob_end_clean(); - - /** @var list $headers */ - $headers = xdebug_get_headers(); - - return $headers; + public function record_sent_methods( $methods ) { + $this->sent_methods = $methods; + return $methods; } /** - * An Access-Control-Allow-Methods header set on the response reaches the client. + * Calls a function, ignoring the warnings from header() once PHPUnit has sent output. * - * rest_send_cors_headers() runs on 'rest_pre_serve_request', after the response's - * own headers have been emitted, so sending the default list unconditionally - * replaced any value the response supplied. - * - * @ticket 46992 - * - * @requires function xdebug_get_headers + * @param callable $callback Function to call. + * @return mixed The callback's return value. */ - public function test_response_can_set_access_control_allow_methods() { - add_filter( - 'rest_post_dispatch', - static function ( WP_HTTP_Response $response ) { - $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); - return $response; - } + private function ignoring_header_warnings( $callback ) { + set_error_handler( + static function ( $errno, $errstr ) { + return str_starts_with( $errstr, 'Cannot modify header information' ); + }, + E_WARNING ); - $headers = $this->serve_and_get_headers(); - - $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); + try { + return $callback(); + } finally { + restore_error_handler(); + } } /** - * The default list is suppressed rather than duplicated when the response sets one, - * and Vary continues to append. - * - * Sending both values would leave two Access-Control-Allow-Methods headers on the wire. - * - * @ticket 46992 + * Sends the CORS headers for a response to `/wp/v2/types`. * - * @requires function xdebug_get_headers + * @param array $response_headers Headers to set on the response. */ - public function test_response_value_suppresses_the_default_and_vary_still_appends() { - add_filter( - 'rest_post_dispatch', - static function ( WP_HTTP_Response $response ) { - $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); - $response->header( 'Vary', 'Accept-Encoding' ); - return $response; + private function send_cors_headers( $response_headers = array() ) { + $response = new WP_REST_Response( array() ); + $response->set_headers( $response_headers ); + + $this->ignoring_header_warnings( + static function () use ( $response ) { + rest_send_cors_headers( false, $response, new WP_REST_Request( 'GET', '/wp/v2/types' ) ); } ); - - $headers = $this->serve_and_get_headers(); - - $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); - $this->assertNotContains( self::DEFAULT_ALLOW_METHODS, $headers ); - $this->assertContains( 'Vary: Accept-Encoding', $headers ); - $this->assertContains( 'Vary: Origin', $headers ); } /** - * The response header is matched without regard to case, as header names are - * case-insensitive. + * A response that sets no Access-Control-Allow-Methods header is sent the default list. * * @ticket 46992 - * - * @requires function xdebug_get_headers */ - public function test_response_value_is_matched_case_insensitively() { - add_filter( - 'rest_post_dispatch', - static function ( WP_HTTP_Response $response ) { - $response->header( 'access-control-allow-methods', 'OPTIONS, GET' ); - return $response; - } - ); - - $headers = $this->serve_and_get_headers(); + public function test_default_list_is_sent() { + $this->send_cors_headers(); - $this->assertNotContains( self::DEFAULT_ALLOW_METHODS, $headers ); + $this->assertSame( self::DEFAULT_ALLOW_METHODS, $this->sent_methods ); } /** - * A callback added to 'rest_pre_serve_request' after rest_send_cors_headers() - * can still send its own method list. - * - * This has always worked and continues to work. + * No method list is sent when the request has no Origin. * * @ticket 46992 - * - * @requires function xdebug_get_headers */ - public function test_late_rest_pre_serve_request_callback_can_send_its_own_list() { - add_filter( - 'rest_pre_serve_request', - static function ( $served ) { - header( 'Access-Control-Allow-Methods: OPTIONS, GET' ); - return $served; - }, - 11 - ); + public function test_no_list_is_sent_without_an_origin() { + unset( $_SERVER['HTTP_ORIGIN'] ); - $headers = $this->serve_and_get_headers(); + $this->send_cors_headers(); - $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); + $this->assertNull( $this->sent_methods ); } /** - * A response that sets no CORS header receives the same headers as before. + * An Access-Control-Allow-Methods header set on the response is passed through. * - * @ticket 46992 + * rest_send_cors_headers() runs on 'rest_pre_serve_request', after the response's + * own headers have been sent, so sending the default list unconditionally replaced + * any value the response supplied. * - * @requires function xdebug_get_headers + * @ticket 46992 */ - public function test_default_cors_output_is_unchanged() { - $headers = $this->serve_and_get_headers(); + public function test_response_value_is_passed_through() { + $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'OPTIONS, GET' ) ); - $this->assertContains( 'Access-Control-Allow-Origin: http://example.org', $headers ); - $this->assertContains( self::DEFAULT_ALLOW_METHODS, $headers ); - $this->assertContains( 'Access-Control-Allow-Credentials: true', $headers ); - $this->assertContains( 'Vary: Origin', $headers ); + $this->assertSame( array( 'OPTIONS', 'GET' ), $this->sent_methods ); } /** - * No CORS headers are sent when the request has no Origin. - * - * Limited to the three headers rest_send_cors_headers() sends. WP_REST_Server - * sends Access-Control-Expose-Headers from send_headers() on every request, - * with or without an Origin. + * The response header is matched without regard to case. * * @ticket 46992 - * - * @requires function xdebug_get_headers */ - public function test_no_cors_headers_are_sent_without_an_origin() { - unset( $_SERVER['HTTP_ORIGIN'] ); - - $headers = $this->serve_and_get_headers(); + public function test_response_value_is_matched_case_insensitively() { + $this->send_cors_headers( array( 'access-control-allow-methods' => 'OPTIONS, GET' ) ); - foreach ( array( 'Access-Control-Allow-Origin', 'Access-Control-Allow-Methods', 'Access-Control-Allow-Credentials' ) as $name ) { - foreach ( $headers as $header ) { - $this->assertStringStartsNotWith( $name . ':', $header ); - } - } + $this->assertSame( array( 'OPTIONS', 'GET' ), $this->sent_methods ); } /** - * Access-Control-Allow-Origin and Access-Control-Allow-Credentials are not - * settable from the response. - * - * Those two headers determine who may read an authenticated response, so they - * remain under the control of rest_send_cors_headers() alone. + * A response value is limited to methods in the default list. * * @ticket 46992 - * - * @requires function xdebug_get_headers */ - public function test_origin_and_credentials_headers_are_not_response_settable() { - add_filter( - 'rest_post_dispatch', - static function ( WP_HTTP_Response $response ) { - $response->header( 'Access-Control-Allow-Origin', 'http://plugin.example' ); - $response->header( 'Access-Control-Allow-Credentials', 'false' ); - return $response; - } - ); + public function test_response_value_is_limited_to_the_default_methods() { + $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'GET, TRACE, DELETE' ) ); - $headers = $this->serve_and_get_headers(); - - $this->assertContains( 'Access-Control-Allow-Origin: http://example.org', $headers ); - $this->assertContains( 'Access-Control-Allow-Credentials: true', $headers ); + $this->assertSame( array( 'GET', 'DELETE' ), $this->sent_methods ); } /** - * The rest_allowed_cors_methods filter changes the advertised method list. + * The rest_allowed_cors_methods filter changes the list. * * @ticket 46992 - * - * @requires function xdebug_get_headers */ - public function test_allowed_cors_methods_filter_changes_the_list() { + public function test_filter_changes_the_list() { add_filter( 'rest_allowed_cors_methods', - static function ( array $methods ) { + static function ( $methods ) { $methods[] = 'HEAD'; return $methods; } ); - $headers = $this->serve_and_get_headers(); - - $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE, HEAD', $headers ); - $this->assertNotContains( self::DEFAULT_ALLOW_METHODS, $headers ); - } - - /** - * The filter receives the default list as an array and the request in context. - * - * Matches the signature of the sibling CORS list filters, rest_exposed_cors_headers - * and rest_allowed_cors_headers. - * - * @ticket 46992 - * - * @requires function xdebug_get_headers - */ - public function test_allowed_cors_methods_filter_receives_the_default_list_and_the_request() { - $received = null; - - add_filter( - 'rest_allowed_cors_methods', - static function ( $methods, $request ) use ( &$received ) { - $received = array( $methods, $request ); - return $methods; - }, - 10, - 2 - ); - - $this->serve_and_get_headers(); + $this->send_cors_headers(); - $this->assertNotNull( $received, 'The filter did not run.' ); - $this->assertSame( array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ), $received[0] ); - $this->assertInstanceOf( WP_REST_Request::class, $received[1] ); - $this->assertSame( '/wp/v2/types', $received[1]->get_route() ); + $this->assertSame( array_merge( self::DEFAULT_ALLOW_METHODS, array( 'HEAD' ) ), $this->sent_methods ); } /** - * The filter runs even when the response set its own value, and can replace it. - * - * If the filter only ran when there was no response value, it could add methods - * but never narrow or replace a value the response set -- a site could not use it - * to enforce a stricter list against a plugin response. + * The filter is seeded with the response's value and can replace it. * * @ticket 46992 - * - * @requires function xdebug_get_headers */ public function test_filter_can_override_a_response_value() { - $filter_ran = false; - - add_filter( - 'rest_post_dispatch', - static function ( WP_HTTP_Response $response ) { - $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); - return $response; - } - ); + $received = null; add_filter( 'rest_allowed_cors_methods', - static function ( $methods ) use ( &$filter_ran ) { - $filter_ran = true; + static function ( $methods ) use ( &$received ) { + $received = $methods; return array( 'OPTIONS', 'DELETE' ); } ); - $headers = $this->serve_and_get_headers(); + $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'OPTIONS, GET' ) ); - $this->assertTrue( $filter_ran, 'The filter did not run even though the response set the header.' ); - $this->assertContains( 'Access-Control-Allow-Methods: OPTIONS, DELETE', $headers ); - $this->assertNotContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); + $this->assertSame( array( 'OPTIONS', 'GET' ), $received, 'The filter was not seeded with the response value.' ); + $this->assertSame( array( 'OPTIONS', 'DELETE' ), $this->sent_methods ); } /** - * The filter is seeded with the response's own value, split into an array, rather - * than with the default list, when the response has set one. + * Serving a request passes the request in context to the filter. * * @ticket 46992 - * - * @requires function xdebug_get_headers */ - public function test_filter_receives_the_response_value_when_the_response_set_one() { + public function test_filter_receives_the_request_when_serving() { $received = null; - add_filter( - 'rest_post_dispatch', - static function ( WP_HTTP_Response $response ) { - $response->header( 'Access-Control-Allow-Methods', 'OPTIONS, GET' ); - return $response; - } - ); - add_filter( 'rest_allowed_cors_methods', - static function ( $methods ) use ( &$received ) { - $received = $methods; + static function ( $methods, $request ) use ( &$received ) { + $received = $request; return $methods; - } + }, + 10, + 2 ); - $this->serve_and_get_headers(); + $this->ignoring_header_warnings( + static function () { + rest_get_server()->serve_request( '/wp/v2/types' ); + } + ); - $this->assertSame( array( 'OPTIONS', 'GET' ), $received ); + $this->assertInstanceOf( WP_REST_Request::class, $received ); + $this->assertSame( '/wp/v2/types', $received->get_route() ); } /** * Under `?_envelope=1`, a response header is not seen and the default is sent. * - * envelope_response() runs on 'rest_post_dispatch', ahead of rest_send_cors_headers(), - * and relocates the response's own headers into the response body, building a fresh - * response with none of its own headers. rest_send_cors_headers() never sees the - * value the response set, so the filtered default goes out on the wire instead. + * envelope_response() moves the response's headers into the body on + * 'rest_post_dispatch', ahead of rest_send_cors_headers(). * * @ticket 46992 - * - * @requires function xdebug_get_headers */ public function test_envelope_response_does_not_see_a_response_value() { $_GET['_envelope'] = '1'; @@ -376,9 +240,12 @@ static function ( WP_HTTP_Response $response ) { } ); - $headers = $this->serve_and_get_headers(); + $this->ignoring_header_warnings( + static function () { + rest_get_server()->serve_request( '/wp/v2/types' ); + } + ); - $this->assertContains( self::DEFAULT_ALLOW_METHODS, $headers ); - $this->assertNotContains( 'Access-Control-Allow-Methods: OPTIONS, GET', $headers ); + $this->assertSame( self::DEFAULT_ALLOW_METHODS, $this->sent_methods ); } } From 62058a08dd95a82908ffb492ca26c462b953161f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 19 Oct 2026 14:56:29 -0700 Subject: [PATCH 12/35] REST API: Harden the arguments to rest_send_cors_headers(). Native type hints on a hooked callback turn an unexpected value passed through rest_pre_serve_request into a fatal TypeError, so rely on the existing instanceof checks instead. Match methods in a response's Access-Control-Allow-Methods value case-insensitively, as the Fetch standard normalizes them, so 'get' is not silently dropped. Also trim the inline comment and the @since note, which no longer described the narrowed list. --- src/wp-includes/rest-api.php | 22 +++-------- .../tests/rest-api/rest-cors-headers.php | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 6dead9ee25169..3ddea42429cce 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -800,19 +800,14 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { * Sends Cross-Origin Resource Sharing headers with API requests. * * @since 4.4.0 - * @since 7.2.0 Added the `$result` and `$request` parameters, and the `rest_allowed_cors_methods` - * filter. An `Access-Control-Allow-Methods` header already set on the response is - * no longer discarded outright -- it seeds the filter instead of the default list, - * so it is passed through unless a callback changes it. Under `?_envelope=1`, a - * response header is relocated into the response body before this runs, so it is - * not seen here and the filtered default is sent instead. + * @since 7.2.0 Added the `$result` and `$request` parameters and the `rest_allowed_cors_methods` filter. * * @param mixed $value Response data. * @param WP_HTTP_Response|null $result Optional. Result to send to the client. Default null. * @param WP_REST_Request|null $request Optional. The request in context. Default null. * @return mixed Response data. */ -function rest_send_cors_headers( $value, ?WP_HTTP_Response $result = null, ?WP_REST_Request $request = null ) { +function rest_send_cors_headers( $value, $result = null, $request = null ) { $origin = get_http_origin(); if ( $origin ) { @@ -823,20 +818,15 @@ function rest_send_cors_headers( $value, ?WP_HTTP_Response $result = null, ?WP_R header( 'Access-Control-Allow-Origin: ' . $origin ); /* - * This runs on 'rest_pre_serve_request', after the response's own headers have - * been sent, and header() replaces by default -- so sending the default list - * unconditionally would discard a value set on the response. Seed the filter with - * that value instead of the default list, so the response is passed through - * unless `rest_allowed_cors_methods` changes it -- otherwise the filter could - * only add methods and could never narrow or replace a value the response set. - * Header names are case-insensitive. + * The response's own headers have already been sent, and header() replaces them, + * so seed the list from any value the response set rather than discarding it. */ $response_headers = ( $result instanceof WP_HTTP_Response ) ? $result->get_headers() : array(); $response_headers = array_change_key_case( $response_headers ); $allow_methods = array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ); if ( isset( $response_headers['access-control-allow-methods'] ) ) { - $allow_methods = array_values( array_intersect( $allow_methods, wp_parse_list( $response_headers['access-control-allow-methods'] ) ) ); + $allow_methods = array_values( array_intersect( $allow_methods, array_map( 'strtoupper', wp_parse_list( $response_headers['access-control-allow-methods'] ) ) ) ); } /** @@ -856,7 +846,7 @@ function rest_send_cors_headers( $value, ?WP_HTTP_Response $result = null, ?WP_R * @param string[] $allow_methods The list of HTTP methods to allow. * @param WP_REST_Request|null $request The request in context, if available. */ - $allow_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request ); + $allow_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request instanceof WP_REST_Request ? $request : null ); if ( ! empty( $allow_methods ) ) { header( 'Access-Control-Allow-Methods: ' . implode( ', ', $allow_methods ) ); diff --git a/tests/phpunit/tests/rest-api/rest-cors-headers.php b/tests/phpunit/tests/rest-api/rest-cors-headers.php index 7eca6a7b9db2d..f88d885830b42 100644 --- a/tests/phpunit/tests/rest-api/rest-cors-headers.php +++ b/tests/phpunit/tests/rest-api/rest-cors-headers.php @@ -152,6 +152,45 @@ public function test_response_value_is_limited_to_the_default_methods() { $this->assertSame( array( 'GET', 'DELETE' ), $this->sent_methods ); } + /** + * Methods in a response value are matched without regard to case. + * + * @ticket 46992 + */ + public function test_response_value_methods_are_matched_case_insensitively() { + $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'options, Get' ) ); + + $this->assertSame( array( 'OPTIONS', 'GET' ), $this->sent_methods ); + } + + /** + * Unexpected values passed through 'rest_pre_serve_request' are ignored. + * + * @ticket 46992 + */ + public function test_unexpected_result_and_request_are_ignored() { + $received = false; + + add_filter( + 'rest_allowed_cors_methods', + static function ( $methods, $request ) use ( &$received ) { + $received = $request; + return $methods; + }, + 10, + 2 + ); + + $this->ignoring_header_warnings( + static function () { + rest_send_cors_headers( false, 'not a response', 'not a request' ); + } + ); + + $this->assertNull( $received ); + $this->assertSame( self::DEFAULT_ALLOW_METHODS, $this->sent_methods ); + } + /** * The rest_allowed_cors_methods filter changes the list. * From 674e883f8c1091b8689737487d77dbea6e934ad7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 19 Oct 2026 15:09:52 -0700 Subject: [PATCH 13/35] REST API: Remove the CORS methods header when the filtered list is empty. The response's own headers are sent before this runs, so skipping the write left a response-supplied list on the wire. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011PkCnymaZCDVy1WUqzxYbN --- src/wp-includes/rest-api.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 3ddea42429cce..88d1055968744 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -850,6 +850,9 @@ function rest_send_cors_headers( $value, $result = null, $request = null ) { if ( ! empty( $allow_methods ) ) { header( 'Access-Control-Allow-Methods: ' . implode( ', ', $allow_methods ) ); + } else { + // Remove a value the response already sent. + header_remove( 'Access-Control-Allow-Methods' ); } header( 'Access-Control-Allow-Credentials: true' ); header( 'Vary: Origin', false ); From 3756a7fd89705f7667da0151fb9d66885c6ee33c Mon Sep 17 00:00:00 2001 From: Alex Moon Date: Mon, 19 Oct 2026 16:43:45 -0700 Subject: [PATCH 14/35] REST API: Validate CORS methods from the response by syntax, not the default list. Intersecting a response's `Access-Control-Allow-Methods` value with the default list meant a response could only remove methods, never add one. A route handling `PROPFIND`, or `QUERY`, had that method silently dropped, though advertising such methods is part of what this ticket is for. Keep only entries that are valid method tokens per RFC 9110, section 9.1, so junk is still rejected without discarding real methods. Methods are case-sensitive, so they now pass through as given rather than being uppercased, which was only needed for the comparison against the default list. See #46992. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/rest-api.php | 5 ++-- .../tests/rest-api/rest-cors-headers.php | 23 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 88d1055968744..ab0c61d9a341b 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -826,7 +826,8 @@ function rest_send_cors_headers( $value, $result = null, $request = null ) { $allow_methods = array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ); if ( isset( $response_headers['access-control-allow-methods'] ) ) { - $allow_methods = array_values( array_intersect( $allow_methods, array_map( 'strtoupper', wp_parse_list( $response_headers['access-control-allow-methods'] ) ) ) ); + // Keep only syntactically valid method tokens (RFC 9110, section 9.1). + $allow_methods = array_values( preg_grep( '/^[!#$%&\'*+.^_`|~0-9A-Za-z-]+$/', wp_parse_list( $response_headers['access-control-allow-methods'] ) ) ); } /** @@ -838,7 +839,7 @@ function rest_send_cors_headers( $value, $result = null, $request = null ) { * serving routes that accept other methods can advertise them here. * * If the response already set its own `Access-Control-Allow-Methods` header, - * `$allow_methods` is the default list narrowed to the methods it names. + * `$allow_methods` is the valid methods it names instead of the default list. * An empty list omits the header. * * @since 7.2.0 diff --git a/tests/phpunit/tests/rest-api/rest-cors-headers.php b/tests/phpunit/tests/rest-api/rest-cors-headers.php index f88d885830b42..80d132b72b730 100644 --- a/tests/phpunit/tests/rest-api/rest-cors-headers.php +++ b/tests/phpunit/tests/rest-api/rest-cors-headers.php @@ -142,25 +142,36 @@ public function test_response_value_is_matched_case_insensitively() { } /** - * A response value is limited to methods in the default list. + * A response value may name methods outside the default list. * * @ticket 46992 */ - public function test_response_value_is_limited_to_the_default_methods() { - $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'GET, TRACE, DELETE' ) ); + public function test_response_value_can_add_methods_beyond_the_default() { + $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'OPTIONS, GET, PROPFIND' ) ); + + $this->assertSame( array( 'OPTIONS', 'GET', 'PROPFIND' ), $this->sent_methods ); + } + + /** + * Entries in a response value that are not valid method tokens are dropped. + * + * @ticket 46992 + */ + public function test_response_value_drops_invalid_method_tokens() { + $this->send_cors_headers( array( 'Access-Control-Allow-Methods' => 'GET, P@TCH,