Skip to content

REST API: Stop rest_send_cors_headers() clobbering Access-Control-Allow-Methods, and make the list filterable. - #13151

Open
moonmeister wants to merge 4 commits into
WordPress:trunkfrom
moonmeister:fix/rest-cors-allow-methods-clobber
Open

REST API: Stop rest_send_cors_headers() clobbering Access-Control-Allow-Methods, and make the list filterable.#13151
moonmeister wants to merge 4 commits into
WordPress:trunkfrom
moonmeister:fix/rest-cors-allow-methods-clobber

Conversation

@moonmeister

Copy link
Copy Markdown

Trac ticket: https://core.trac.wordpress.org/ticket/46992 — background, prior art and the caveat are there.

Approach discussed in person with @adamsilverstein, who closed the ticket in 2019 and has since agreed on option C.

Changes

1. Don't clobber a value set on the response. rest_send_cors_headers() runs on rest_pre_serve_request, after the response's own headers have been sent, and header() replaces by default — so the hardcoded list discarded anything set via rest_post_dispatch.

$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'] ) ) {
	// ...send the default...
}

get_headers() rather than headers_list(): it reads what the response intends rather than what PHP already emitted, and needs no Xdebug under CLI.

2. Add rest_allowed_cors_methods. Same shape as the sibling CORS list filters rest_exposed_cors_headers and rest_allowed_cors_headers — array in, imploded out, $request in context. rest_pre_serve_request already passes $request, hence add_filter( ..., 10, 3 ).

$allow_methods = array( 'OPTIONS', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' );
$allow_methods = apply_filters( 'rest_allowed_cors_methods', $allow_methods, $request );
header( 'Access-Control-Allow-Methods: ' . implode( ', ', $allow_methods ) );

The filter runs only when the response has not set the header — a value on the response is the more specific instruction, and there is no default left to shape.

Both are needed: (1) alone forces a plugin to restate the whole list, so two plugins conflict instead of composing; (2) alone still gets discarded when a response sets its own value.

The default output is unchanged for every site.

Tests

rest_send_cors_headers() had no coverage at all. New file, 10 tests / 46 assertions — both changes plus baseline behavior (Origin present and absent, default output, Vary still appending, Access-Control-Allow-Origin and -Credentials not response-settable).

  • 6 of 10 fail against stock rest-api.php.
  • --group restapi: 3560 / 16285, no new failures against a 3550 / 16239 baseline. The one error in both runs (Test_oEmbed_Controller::test_proxy_with_classic_embed_provider) is pre-existing on trunk.

⚠️ The tests read the wire with xdebug_get_headers() — these headers bypass WP_REST_Server::send_header(), so Spy_REST_Server cannot see them (same approach as tests/phpunit/tests/oembed/headers.php). Without Xdebug they skip silently and the suite reports green.

moonmeister and others added 3 commits August 17, 2026 16:02
`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.
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 <noreply@anthropic.com>
@moonmeister
moonmeister marked this pull request as ready for review August 18, 2026 23:03
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props moonmeister.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant