Conversation
configurable WordPress.com REST proxy: allowlist, capability, cache
the dashboard keeps its table and three overrides; route unchanged
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖 Follow this PR Review Process:
If you have questions about anything, reach out in #jetpack-developers for guidance! Jetpack plugin: The Jetpack plugin has different release cadences depending on the platform:
If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. Premium Analytics plugin: No scheduled milestone found for this plugin. If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. |
Code Coverage SummaryCoverage changed in 2 files.
1 file is newly checked for coverage.
Full summary · PHP report · JS report If appropriate, add one of these labels to override the failing coverage check:
Covered by non-unit tests
|
no new package: Proxy_Controller next to the trait it generalizes
the dashboard drops the mechanics tests connection now owns
| * | ||
| * @since $$next-version$$ | ||
| */ | ||
| class Proxy_Controller extends WP_REST_Controller { |
There was a problem hiding this comment.
Would it be worth making the helpers private, so only the three documented seams can be overridden? Different plugins can load different versions of connection, so changing a protected method later could break an older subclass.
There was a problem hiding this comment.
Agreed. The rework on top of #53154 makes everything private except the three seams and passes what they need through $opts.
| return false; | ||
| } | ||
|
|
||
| if ( isset( $config['pattern'] ) && ! preg_match( '#^' . preg_quote( $prefix, '#' ) . '/' . $config['pattern'] . '$#i', rtrim( $value, '/' ) ) ) { |
There was a problem hiding this comment.
Could we wrap pattern in a group here and in the route regex? A pattern with a top-level | isn't anchored on both ends, so a request could reach endpoints the table meant to block. No current table does this, though.
| if ( isset( $config['pattern'] ) && ! preg_match( '#^' . preg_quote( $prefix, '#' ) . '/' . $config['pattern'] . '$#i', rtrim( $value, '/' ) ) ) { | |
| if ( isset( $config['pattern'] ) && ! preg_match( '#^' . preg_quote( $prefix, '#' ) . '/(?:' . $config['pattern'] . ')$#i', rtrim( $value, '/' ) ) ) { |
There was a problem hiding this comment.
Good catch. It lands in the rework, in both regexes.
| | --- | --- | --- | | ||
| | `capability` | yes | Capability that reads the group. `manage_options` always reads. A missing value admits administrators only. | | ||
| | `pattern` | no | Regex the sub-path must match in full, for a group that exposes specific endpoints only. Anchored in the route regex and re-checked on the request param. | | ||
| | `writes` | no | Sub-paths reachable with `POST`, the only write method. A matcher ending in `/` covers everything under it; otherwise it covers that endpoint only. | |
There was a problem hiding this comment.
nit: Could we mention that writes entries include the group key? The docs say "sub-paths", but a sub-path alone never matches, so every POST would get a 405.
| | `writes` | no | Sub-paths reachable with `POST`, the only write method. A matcher ending in `/` covers everything under it; otherwise it covers that endpoint only. | | |
| | `writes` | no | Endpoints reachable with `POST`, the only write method, including the group key (`stats/referrers/spam/`). A matcher ending in `/` covers everything under it; otherwise it covers that endpoint only. | |
There was a problem hiding this comment.
Taking the suggestion in the rework.
| */ | ||
| protected function get_forwarded_params( WP_REST_Request $request ): array { | ||
| $params = $request->get_query_params(); | ||
| unset( $params['rest_route'], $params['_locale'], $params['site'], $params['endpoint'], $params['version'], $params['force_refresh'] ); |
There was a problem hiding this comment.
An encoded _method parameter survives this filter and becomes a method override in the signed outbound request. I tested the controller locally with HTTP intercepted: an incoming GET produced an outbound GET with _method=PUT, which WordPress REST uses to dispatch the request as PUT. This means the effective upstream method can bypass the local write allowlist, although I have not executed an upstream write. Could we reject method overrides here and add a regression test that checks the final outbound request?
| $alternatives = array(); | ||
|
|
||
| foreach ( $this->prefix_config as $prefix => $config ) { | ||
| $suffix = isset( $config['pattern'] ) ? '/' . $config['pattern'] : '(?:/.*)?'; |
There was a problem hiding this comment.
A prefix without a pattern exposes every GET sub-path under that prefix, including endpoints added upstream after the consumer ships. I would expect the product to declare its intended endpoint scope and the shared controller to enforce it. Could unrestricted sub-paths require an explicit opt-in?
Fixes WOOA7S-2247. Built on #53154, which this PR targets until it merges.
Proposed changes
Every product that displays WordPress.com data in wp-admin ships its own REST proxy: a route, an allowlist of endpoints with a capability for each, a blog-signed
Clientcall, a five-minute transient, and an error for the unconnected site. The monorepo has five implementations of that mechanism, and they disagree on cache keys, bypass rules, timeouts and error shapes.This PR extracts the Premium Analytics one, the only endpoint-agnostic one, into the connection package as
Automattic\Jetpack\Connection\Proxy_Controller, and makes the dashboard its first consumer. The shape follows the discussion in pdWQjU-1Ke-p2: a tunnel for dashboard products (Premium Analytics, Ads, VideoPress, WooCommerce through connection) that routes to the forward the trait already owns. Typed controllers such as Social's stay onWPCOM_REST_API_Proxy_Request;docs/proxy-controller.mdstates the rule for choosing.The controller
Configured at construction with a REST namespace, a table of endpoint groups and a transient prefix, it registers
<namespace>/proxy/v<version>/<endpoint>, anchors the table in the route regex, revalidates the parameter, checks the row's capability, strips routing and control params, forwards signed as the blog with the version in the path, caches a 200 and passes WordPress.com's status and body through.request()calls the trait'sforward_request_to_wpcom()(Connection: share one forward to WordPress.com in the proxy request trait #53154) instead ofClient. Transport errors keepClient's code with a 500 status; theno_connectionmessage is neutral.patternis required:''exposes the endpoint alone,.*everything under the prefix, and a row without one is not routed and raises_doing_it_wrong(). The pattern is wrapped in a group in both regexes, so a top-level|stays anchored.manage_optionsis an opt-in option, off by default.cache_ttlandcache => falseon the row. Acache_bustgroup keys its reads by a generation counter that a successful write bumps, so parameterised reads miss too; the previous bust deleted the param-less key only.request(),prepare_body()andextract_forwarded_headers(), with the forwarded URL, timeout, version, base and the matched row in$opts. Everything else is private, so a subclass cannot lean on a helper that a later connection version changes.register_hooks()registers the route at once when called afterrest_api_init.The dashboard
REST\Api_Proxy_Controllerbecomes a table and three overrides: unsigned forwards for thepostsgroup, theuser_emailbody rewrite forjetpack-stats/user-feedback, and the pagination headers. The table now names each group's endpoints: of ten groups onlystatsstays open;analyticsisreports/.*,wordadsisstatsorearnings, five groups are single endpoints. The dashboard opts in tomanage_options, because no role grantsactivate_wordads. Route and namespace are unchanged; the frontend reads onlyno_connection, which is kept.Tests
The mechanics are tested once, in connection, through a synthetic table: route, patterns, permissions, cache per row, generation busting, errors and the seams. The dashboard's tests drive its own table through the route: capability tiers, the endpoint matrix with the WordPress.com path each one reaches, the rejected sub-paths, the busting group, the pagination headers and the three overrides.
REST_Endpoints_TesthooksPartnerexplicitly: it relied on hooks leaked by earlier test classes, which the new test file exposed as a coverage drop onclass-partner.php.Next
VideoPress (#52941) and Ads (#53108) move to this API before anything merges. Follow-ups in Linear: stale-on-error, user-context forwarding with its cache column, cache on the trait.
Related product discussion/links
Does this pull request change what data or activity we track or use?
No.
Testing instructions
On a connected site with the dashboard built and Jetpack Stats active, open the Premium Analytics dashboard: every widget loads as before. In the browser console:
wp transient list --search='jetpack-premium-analytics_proxy_*'lists one entry per distinct query./jetpack-premium-analytics/v1/proxy/v1.1/posts/<post id>/likesstill resolves unsigned;/jetpack-premium-analytics/v1/proxy/v2/mediaand/jetpack-premium-analytics/v1/proxy/v1.1/wordads/settingsare rejected withrest_no_route.view_statsbut notmanage_options, thestatscall resolves and ananalyticscall is rejected with a 403.wp jetpack disconnect blog), the call fails withno_connectionand the dashboard shows its reconnect state.