Make WordPress Core

Changeset 64730


Ignore:
Timestamp:
10/26/2026 03:56:24 PM (41 hours ago)
Author:
adamsilverstein
Message:

REST API: Introduce the rest_allowed_cors_methods filter.

Add a filter to rest_send_cors_headers() so the Access-Control-Allow-Methods header sent with CORS responses can be adjusted. The list of methods is a fixed default and is not derived from the registered routes, so a site serving routes that accept other methods can now advertise them. The filter receives the method list as an array and the request in context, matching the shape of the sibling rest_exposed_cors_headers and rest_allowed_cors_headers filters, so a plugin can append to or narrow the list rather than restate it. An empty list omits the header. The default output is unchanged.

The filter is seeded from an Access-Control-Allow-Methods header already set on the response, limited to the methods in the default list, falling back to the default list itself. A value set through rest_post_dispatch was previously discarded: rest_send_cors_headers() runs on rest_pre_serve_request, after the response's own headers have been emitted, and header() replaces by default.

Follow-up to [56096].

Props moonmeister, sudar, TimothyBlynJacobs, aaemnnosttv, westonruter.
Fixes #46992.

Location:
trunk
Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/rest-api.php

    r64689 r64730  
    250250
    251251        // Default serving.
    252         add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
     252        add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers', 10, 4 );
    253253        add_filter( 'rest_post_dispatch', 'rest_send_allow_header', 10, 3 );
    254254        add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 );
    … …  
    809809 *
    810810 * @since 4.4.0
    811  *
    812  * @param mixed $value Response data.
     811 * @since 7.2.0 Added the `$result`, `$request`, and `$server` parameters and the `rest_allowed_cors_methods` filter.
     812 *
     813 * @param mixed                 $value   Response data.
     814 * @param WP_HTTP_Response|null $result  Optional. Result to send to the client. Default null.
     815 * @param WP_REST_Request|null  $request Optional. The request in context. Default null.
     816 * @param WP_REST_Server|null   $server  Optional. The server instance. Default null.
    813817 * @return mixed Response data.
    814818 */
    815 function rest_send_cors_headers( $value ) {
     819function rest_send_cors_headers( $value, $result = null, $request = null, $server = null ) {
    816820        $origin = get_http_origin();
    817821
    … …  
    821825                        $origin = sanitize_url( $origin );
    822826                }
    823                 header( 'Access-Control-Allow-Origin: ' . $origin );
    824                 header( 'Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE' );
    825                 header( 'Access-Control-Allow-Credentials: true' );
    826                 header( 'Vary: Origin', false );
     827
     828                /*
     829                 * Send through the server when serving a REST request. A direct call without one
     830                 * sends the headers itself, so that it does not set up the REST server.
     831                 */
     832                if ( ! $server instanceof WP_REST_Server ) {
     833                        $server = null;
     834                }
     835                $send_header = static function ( string $key, string $header_value ) use ( $server ): void {
     836                        if ( $server ) {
     837                                $server->send_header( $key, $header_value );
     838                        } elseif ( ! headers_sent() ) {
     839                                header( "{$key}: {$header_value}" );
     840                        }
     841                };
     842
     843                $send_header( 'Access-Control-Allow-Origin', $origin );
     844
     845                /*
     846                 * The response's own headers have already been sent, and header() replaces them,
     847                 * so seed the list from any value the response set rather than discarding it.
     848                 */
     849                $response_headers = ( $result instanceof WP_HTTP_Response ) ? $result->get_headers() : array();
     850                $response_headers = array_change_key_case( $response_headers );
     851
     852                /**
     853                 * Parses a list of methods, keeping only syntactically valid method tokens (RFC 9110, section 9.1).
     854                 *
     855                 * @phpstan-var Closure(mixed): list<non-empty-string> $parse_methods
     856                 */
     857                $parse_methods = static function ( $methods ): array {
     858                        if ( is_array( $methods ) ) {
     859                                // Join the entries so that each one is split like a string value, as in array( 'GET, POST' ).
     860                                $methods = implode( ',', array_filter( $methods, 'is_string' ) );
     861                        } elseif ( ! is_string( $methods ) ) {
     862                                return array();
     863                        }
     864                        $methods = preg_grep( '/^[!#$%&\'*+.^_`|~0-9A-Za-z-]+$/D', array_unique( wp_parse_list( $methods ) ) );
     865                        return is_array( $methods ) ? array_values( $methods ) : array();
     866                };
     867
     868                // Fall back to the default list when the response names no valid methods.
     869                $allow_methods = $parse_methods( $response_headers['access-control-allow-methods'] ?? null );
     870                if ( empty( $allow_methods ) ) {
     871                        $allow_methods = array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' );
     872                }
     873
     874                /**
     875                 * Filters the list of HTTP methods advertised to REST API CORS requests.
     876                 *
     877                 * The allowed methods are passed to the browser to specify which methods
     878                 * may be used in a cross-origin request to the REST API. The list is a
     879                 * fixed default and is not derived from the registered routes, so a site
     880                 * serving routes that accept other methods can advertise them here.
     881                 *
     882                 * If the response already set its own `Access-Control-Allow-Methods` header
     883                 * naming valid methods, `$allow_methods` is those methods instead of the default list.
     884                 * Entries that are not valid method tokens are dropped, and an empty list
     885                 * omits the header. A value that is neither an array nor a string is ignored.
     886                 *
     887                 * @since 7.2.0
     888                 *
     889                 * @param string[]             $allow_methods The list of HTTP methods to allow.
     890                 * @param WP_REST_Request|null $request       The request in context, if available.
     891                 */
     892                $filtered_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request instanceof WP_REST_Request ? $request : null );
     893
     894                // Keep the unfiltered list for a callback that does not return one, rather than omitting the header.
     895                if ( is_array( $filtered_methods ) || is_string( $filtered_methods ) ) {
     896                        $allow_methods = $parse_methods( $filtered_methods );
     897                }
     898
     899                if ( ! empty( $allow_methods ) ) {
     900                        $send_header( 'Access-Control-Allow-Methods', implode( ', ', $allow_methods ) );
     901                } elseif ( $server ) {
     902                        // Remove a value the response already sent.
     903                        $server->remove_header( 'Access-Control-Allow-Methods' );
     904                } elseif ( ! headers_sent() ) {
     905                        header_remove( 'Access-Control-Allow-Methods' );
     906                }
     907
     908                $send_header( 'Access-Control-Allow-Credentials', 'true' );
     909
     910                // WP_REST_Server::send_header() replaces a header, and Vary must be appended to.
     911                if ( ! headers_sent() ) {
     912                        header( 'Vary: Origin', false );
     913                }
    827914        } elseif ( ! headers_sent() && 'GET' === $_SERVER['REQUEST_METHOD'] && ! is_user_logged_in() ) {
    828915                header( 'Vary: Origin', false );
  • trunk/tests/phpunit/includes/spy-rest-server.php

    r60185 r64730  
    33class Spy_REST_Server extends WP_REST_Server {
    44
     5        /** @var array<string, string> */
    56        public $sent_headers        = array();
    67        public $sent_body           = '';
Note: See TracChangeset for help on using the changeset viewer.