Repository navigation
REST API: Add the rest_allowed_cors_methods filter and stop discarding a response's method list. - #13151
REST API: Add the rest_allowed_cors_methods filter and stop discarding a response's method list.#13151moonmeister wants to merge 21 commits into
Conversation
`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>
|
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 Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe 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
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
|
Thanks for the continued effort here. Overall this looks good to me @moonmeister! Claude went over the PR against the ticket discussion, here is most of what came back:
What do you think - is there a reason to keep the response winning outright that I'm not seeing? |
…d 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 <noreply@anthropic.com>
|
Thanks for the review @adamsilverstein! No, there's no reason I can find to keep it — taking your suggestion as written. The filter now always runs, seeded with the response's value when available. The argument I'd hang it on is that seeding is a strict superset of the current behavior. With it, you can express all three outcomes: filter wins (return your own list), response wins (return Small bonus from parsing the header back out: On
|
…meister/wordpress-develop into fix/rest-cors-allow-methods-clobber * 'fix/rest-cors-allow-methods-clobber' of github.com:moonmeister/wordpress-develop: Docs: Add missing descriptions and declare types for `Translation_Entry` properties. Docs: Remove a blank line between `@param` and `@return` in core docblocks. Build/Test Tools: Stop committing changes to tracked files in pull requests. Code Quality: Resolve return.empty PHPStan errors in baseline. Build/Test Tools: Make `SLACK_GHA_TIMEOUT_WEBHOOK` optional. Filesystem API: Reject UNC and device paths in `validate_file()`. Tests: Use `assertIsCallable()` for callable assertions. Build/Test Tools: Distinguish between cancelled and timed out workflow runs. Build/Test Tools: Remove all `npx` commands. Build/Test Tools: Allow text compression in the local environment.
|
Really happy to see progress on a ticket that I opened in trac 7 years ago. |
| * serve requests through a real WP_REST_Server and read the emitted headers with | ||
| * xdebug_get_headers(), following tests/phpunit/tests/oembed/headers.php. | ||
| * | ||
| * @runTestsInSeparateProcesses |
There was a problem hiding this comment.
Tests that run in a separate process are very slow. We should avoid them if possible.
| ob_end_clean(); | ||
|
|
||
| /** @var list<string> $headers */ | ||
| $headers = xdebug_get_headers(); |
There was a problem hiding this comment.
This function is problematic to use because it's not always available. Not sure if there is an alternative.
There was a problem hiding this comment.
hmm, maybe we can use headers-list instead? https://www.php.net/manual/en/function.headers-list.php
Is this available at all in our test builds? maybe we could only run the tests if it is available.
There was a problem hiding this comment.
The problem is that output has already been sent, so header() returns false and headers_list() surely wouldn't include anything.
There was a problem hiding this comment.
I think the main problem is that we're calling header() in the first place. It seems like we should actually be setting the headers on the $response object. Then we can inspect the headers on $result->get_headers() without ever attempting to send them, right?
Note this is what rest_send_allow_header() does already. So I think we should refactor to follow that same model.
There was a problem hiding this comment.
For testing, note also that Spy_REST_Server has a $sent_headers class property.
There was a problem hiding this comment.
Agreed that calling header() directly is the root of the testing pain. One timing problem with setting the headers on $result, though: by the time rest_pre_serve_request fires, serve_request() has already sent the response's headers (class-wp-rest-server.php L473–474), so anything added to $result here never reaches the client.
rest_send_allow_header() gets away with it because it runs on rest_post_dispatch, before that point. Were you thinking of moving rest_send_cors_headers() there too? If so, I'd be wary of two things:
- Sites that disable core CORS with
remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' )would silently get the headers back. - A later
rest_post_dispatchcallback could overwriteAccess-Control-Allow-OriginandAccess-Control-Allow-Credentials, which currently can't be set from the response.
An alternative that I think gets you the testability without moving the hook: rest_pre_serve_request already passes the server as its fourth argument, and _oembed_rest_pre_serve_request() already uses it this way to send Content-Type. So we can send through it:
add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers', 10, 4 );
function rest_send_cors_headers( $value, $result = null, $request = null, $server = null ) {
$server = $server instanceof WP_REST_Server ? $server : rest_get_server();
// ...
$server->send_header( 'Access-Control-Allow-Methods', implode( ', ', $allow_methods ) );
// ...
$server->remove_header( 'Access-Control-Allow-Methods' );Spy_REST_Server already records both into $sent_headers, so the tests could check the real output, including the empty-list removal and the Origin/Credentials headers, without Xdebug or separate processes. On a normal site nothing changes, since send_header() just calls header().
The Vary: Origin line would stay a raw header( ..., false ) because send_header() always replaces and Vary needs to append. That line isn't touched by this PR, but the tests would still need to deal with its warning.
westonruter
left a comment
There was a problem hiding this comment.
I pushed up some fixes for PHPStan errors. I left some feedback as well for what should be addressed.
Co-authored-by: Weston Ruter <westonruter@gmail.com>
|
@moonmeister I'm going to push some changes here from the code review. |
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.
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.
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.
…pty. 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PkCnymaZCDVy1WUqzxYbN
| 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' ); | ||
| } |
There was a problem hiding this comment.
Instead of calling header() and header_remove(), why not work on $result instead to set the headers there?
There was a problem hiding this comment.
Same question as the thread on the tests. Replied there: #13151 (comment)
…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 <noreply@anthropic.com>
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.
Description refreshed by Claude to match the current head, 674e883.
Changes
1. Don't clobber a value set on the response.
rest_send_cors_headers()runs onrest_pre_serve_request, after the response's own headers have been sent, andheader()replaces by default - so the hardcoded list discarded anything set viarest_post_dispatch. A response value now seeds the method list instead, narrowed to the methods in the default list. Header names and method names are matched without regard to case.get_headers()rather thanheaders_list(): it reads what the response intends rather than what PHP already emitted, and works under CLI.2. Add
rest_allowed_cors_methods. Same shape as the sibling CORS list filtersrest_exposed_cors_headersandrest_allowed_cors_headers- array in, imploded out,$requestin context.rest_pre_serve_requestalready passes$request, henceadd_filter( ..., 10, 3 ).The filter always runs, so a callback can add methods outside the default list, narrow the list, or replace a value the response set. An empty list omits the header, and removes one the response already sent.
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.
$resultand$requestare untyped, so an unexpected value passed throughrest_pre_serve_requestis ignored rather than fatal. The default output is unchanged for every site.Caveat: under
?_envelope=1,envelope_response()moves the response's headers into the body before this runs, so a response value is not seen and the filtered default is sent.Tests
rest_send_cors_headers()had no coverage at all. New file, 11 tests: default list, no Origin, response value passed through and narrowed, case-insensitive header and method matching, unexpected arguments ignored, filter add and override, the request in context when serving, and the envelope caveat.header()under the CLI SAPI, so the tests read the final list from arest_allowed_cors_methodscallback atPHP_INT_MAXinstead of from the wire. No Xdebug needed, and nothing skips silently.header_remove()branch for an empty list has no test. Xdebug doesn't trackheader_remove()either, so there is no way to observe it under CLI.--group restapi: 3746 tests, no failures.Proposed commit message
Getting this one ready to land, here is the message I plan to commit it with.
Claude drafted this from the PR and the ticket history:
Shout if the props list is missing anyone, or if the framing is off.
AI Use
My follow-up commits on this branch and the refreshed description came out of a session with 🤖 Claude Code. I will review and test.