Premium Analytics: extend the API proxy to cover Stats-Admin endpoints#49571
Premium Analytics: extend the API proxy to cover Stats-Admin endpoints#49571kangzj wants to merge 5 commits into
Conversation
Re-expose the stats-admin WPCOM pass-through endpoints under the jetpack-premium-analytics/v1 namespace, without the blog id in the URL, ahead of deprecating the stats-admin package. Extract a shared forward() core from the analytics catch-all, parameterised by version/base/method/body/cache, and drive the stats routes from a declarative table. A single stats/(?P<subpath>.+) route absorbs every /sites/<id>/stats/* endpoint; purchases uses a path override. Adds check_stats_permission and check_wordads_permission; the analytics route is unchanged. Local/non-proxy endpoints (posts, notices, user-feedback) and the stats-admin deprecation are deferred to follow-ups.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Code Coverage SummaryCoverage changed in 1 file.
|
|
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! |
- Add validate_subpath() (rejects traversal/absolute, allows UTM commas) on the stats/(?P<subpath>.+) route, for parity with the analytics endpoint guard. - Invalidate the matching read cache on a successful write to the dashboard modules / module-settings routes, mirroring stats-admin. - Make the proxy error strings route-neutral (no longer always "analytics"). - Guard build_stats_path against a missing wpcom template; add subpath tests.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Replace the per-endpoint route table with a single data proxy that accepts any sub-path under an allowed top-level prefix and lets the caller pick the WPCOM API version (base is derived: v2 -> wpcom, v1.x -> rest). Security boundary is the prefix allowlist baked into the route regex (stats, wordads, subscribers, jetpack-stats, jetpack-stats-dashboard, commercial-classification, upgrades), plus a write-method allowlist so only a few endpoints may mutate; everything else is read-only (405). Traversal guard, force_refresh cache bypass, and per-version cache keys included. The analytics proxy/* route is unchanged.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
… writes - Classify the endpoint prefix case-insensitively in check_data_permission, is_write_allowed and busts_cache. WordPress matches REST routes case- insensitively, so a mixed-case `WordAds/...` no longer slips past the activate_wordads gate as a stats endpoint. - Strip the caller-supplied `site` param before forwarding, so it can't duplicate / override the server-pinned site on the `upgrades` path. - Only POST may mutate; PUT/PATCH (the rest of EDITABLE) now 405 locally, matching the originating stats-admin verbs.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
🤖 Review-cycle summary —
|
| # | Finding | Severity | Resolution |
|---|---|---|---|
| 1 | subpath not traversal-validated like the analytics route |
medium | Added validation (later folded into the agnostic validate_data_endpoint) |
| 2 | Write-then-read cache staleness on dashboard modules/settings | medium | Bust the matching read cache on successful write (mirrors stats-admin) |
| 4 | Error strings always said "analytics" | nit | Route-neutral messages |
| 5 | build_*_path missing-template guard |
nit | ?? '' guard |
| — | Design pivot: per-endpoint registry → agnostic proxy | — | Prefix allowlist in route regex + caller-supplied version; write-method allowlist |
| S1 | Case-insensitive routing bypassed the WordAds authz gate | medium | Case-insensitive prefix classification across all checks |
| S2 | Caller ?site= could duplicate the pinned upgrades site |
medium | Strip site from forwarded params |
| S3 | EDITABLE widened writes to PUT/PATCH |
low | Only POST may mutate; others 405 locally |
Tests: 48 → 63 passing. phpcs clean. Coverage requirement met.
Unaddressed (flagged for owner): None.
CI: all required checks passing.
Fold analytics into the single agnostic route as the `analytics` prefix and remove the dedicated proxy/* route, so both surfaces share one shape: the caller passes ?version= (analytics defaults to 2) and the path is /sites/<id>/<prefix>/<subpath>. Analytics keeps its manage_options gate via check_data_permission dispatch. BREAKING: the analytics data layer must now call /jetpack-premium-analytics/v1/analytics/<subpath> instead of /proxy/<subpath> (tracked as a frontend follow-up). Drops the now-dead handle_proxy_request, validate_endpoint and get_item_schema.
|
Follow-up to the agnostic pivot: per maintainer feedback, analytics is now unified onto the same route (commit This is a breaking change for the analytics data layer ( |
|
Claude finished @kangzj's task in 3m 5s —— View job Re-review: unified permission dispatch + analytics→manage_options
Reviewed 🔴 High — the prefix allowlist can be bypassed via WP parameter precedenceThe PR's stated security boundary is "the route only matches these prefixes, so the blog token can never be driven against the whole WPCOM site API." That guarantee holds for the URL path, but not for the value actually used to build the WPCOM path. Both the permission dispatch and the request handler read the endpoint with // check_data_permission()
$endpoint = strtolower( (string) $request->get_param( 'endpoint' ) );
// handle_data_request()
$endpoint = (string) $request->get_param( 'endpoint' );In WordPress,
public function validate_data_endpoint( $value ): bool {
$value = (string) $value;
if ( str_contains( $value, '..' ) ) {
return false;
}
return (bool) preg_match( '#^[\w.,/-]+$#', $value ); // no prefix check
}Proof of concept (a user with only
The net effect: any Fix — re-enforce the prefix allowlist on the effective value, in public function validate_data_endpoint( $value ): bool {
$value = (string) $value;
if ( str_contains( $value, '..' ) ) {
return false;
}
if ( ! preg_match( '#^[\w.,/-]+$#', $value ) ) {
return false;
}
// The route regex only constrains the URL capture; get_param() can be shadowed by a
// GET/JSON/POST `endpoint`, so re-check the top-level prefix on the effective value.
$prefix = strtolower( explode( '/', $value )[0] );
return in_array( $prefix, self::ALLOWED_PREFIXES, true );
}( A test that locks this down: build a request whose URL capture is an allowed prefix but whose query What checks out ✅
Note Once the prefix re-check lands, the route regex still has value as a first-line filter, but |
Proposed changes
Replaces the Premium Analytics REST proxy's per-endpoint registry with a single endpoint-agnostic route under
jetpack-premium-analytics/v1that serves the whole surface — analytics and the re-exposedstats-adminWPCOM endpoints — without the blog id in the URL, ahead of deprecating thestats-adminpackage.How it works
<prefix>/<sub-path>→/sites/<id>/<prefix>/<sub-path>(andupgrades→/upgrades?site=<id>for purchases).?version=(e.g.1.1,1.2,2); the proxy derives the base (2→wpcom,1.x→rest). Analytics defaults toversion=2. Cache keys include the version so?version=1.1/?version=2don't collide.manage_options; WordAds →activate_wordads; the rest →manage_options/view_stats).Security boundary
analytics,stats,wordads,subscribers,jetpack-stats,jetpack-stats-dashboard,commercial-classification,upgradesmatch (anchored so prefix-extension strings 404). The blog token can't be driven against the whole WPCOM site API.POSTmutates, and onlyjetpack-stats-dashboard/,commercial-classification,stats/referrers/spam/; everything else 405s. Prefix checks are case-insensitive (WP routes case-insensitively).site/versionstripped from forwarded params,force_refreshcache bypass, write-then-read cache invalidation for the dashboard endpoints./jetpack-premium-analytics/v1/proxy/<subpath>to/jetpack-premium-analytics/v1/analytics/<subpath>(versiondefaults to 2). The dedicatedproxy/*route is removed. The dashboard data layer must migrate — tracked in WOOA7S-1539.Out of scope (deferred — sub-issues of WOOA7S-1534): local / non-WPCOM-proxy endpoints (
posts,posts/<id>,posts/<id>/likes, notices,jetpack-stats/user-feedback) and unregistering thestats-adminREST_Controller.Related product discussion/links
Does this pull request change what data or activity we track or use?
No. This re-routes existing WPCOM analytics/stats requests through one REST namespace; the data fetched and the blog-token auth are unchanged. Permissions match the originating controllers.
Testing instructions
cd projects/packages/premium-analytics && composer test-php— 58 tests pass (route registration, prefix/version/endpoint validation, permission dispatch incl. analytics→manage_options, write-method policy, path building, caching).composer phpcs:lint -- projects/packages/premium-analytics/src/REST/class-api-proxy-controller.php— clean.view_stats/activate_wordadsas appropriate):GET /wp-json/jetpack-premium-analytics/v1/analytics/<subpath>(defaults to v2)GET /wp-json/jetpack-premium-analytics/v1/stats/top-posts?version=1.1GET /wp-json/jetpack-premium-analytics/v1/subscribers/counts?version=2GET /wp-json/jetpack-premium-analytics/v1/wordads/earnings?version=1.1(requiresactivate_wordads; mixed-caseWordAds/...is still gated)GET /wp-json/jetpack-premium-analytics/v1/upgrades?version=1.2/posts) and the old/proxy/...both 404; POST/PUT to a read-only endpoint 405s.