PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/class-rest-manager.php +143 -9 1.0.5 → 1.4.1 View file →
@@ -30,16 +30,25 @@
30 30 /**
31 31 * Register every route a module declared, prefixed with its slug.
32 32 */
33 33 public static function register_module( Module $module ): void {
34 - $routes = $module->rest_routes();
35 - if ( empty( $routes ) ) {
36 - return;
37 - }
38 -
34 + // rest_routes() is resolved INSIDE the callback, not here.
35 + //
36 + // This method runs at plugins_loaded, before `init`. Calling
37 + // rest_routes() there makes a module build its settings schema, and
38 + // those schemas carry __() labels — so WordPress emitted a
39 + // _load_textdomain_just_in_time notice for every module, on every
40 + // request including the front end (135 per page load with WP_DEBUG on).
41 + // The eager call existed only to skip add_action() for modules with no
42 + // routes; deferring costs one no-op hook each and moves all translation
43 + // work to where it belongs. (QA, 9 Aug 2026)
39 44 add_action(
40 45 'rest_api_init',
41 - static function () use ( $module, $routes ) {
46 + static function () use ( $module ) {
47 + $routes = $module->rest_routes();
48 + if ( empty( $routes ) ) {
49 + return;
50 + }
42 51 $slug = $module->slug();
43 52 foreach ( $routes as $route ) {
44 53 $path = '/' . trim( $slug, '/' ) . '/' . ltrim( $route['path'] ?? '', '/' );
45 54 $path = rtrim( $path, '/' );
@@ -90,23 +99,148 @@
90 99
91 100 if ( ! is_callable( $callback ) ) {
92 101 return new \WP_Error( 'xspeed_no_callback', 'Module REST callback is not callable.', array( 'status' => 500 ) );
93 102 }
94 - return call_user_func( $callback, $request );
103 +
104 + return self::run_isolated( $callback, $request, $module->slug() );
95 105 };
96 106 }
97 107
98 108 /**
109 + * Backstop for every module REST callback. Containment, not a substitute
110 + * for per-query guards: callbacks should still avoid failing queries.
111 + *
112 + * Two things leak non-JSON into a REST body and break the client's
113 + * JSON.parse ("Unexpected token '<'"):
114 + * 1. $wpdb echoes "WordPress database error: …" as an HTML <div> the
115 + * instant a query fails, when display-errors is on (common on the
116 + * hosts we ship to). That HTML is flushed BEFORE our handler
117 + * returns, so a try/catch around the return value can't catch it.
118 + * We suppress $wpdb's echo for the duration of the call (errors are
119 + * still logged) and capture any other stray output via an output
120 + * buffer, discarding it so only our JSON reaches the client.
121 + * 2. A thrown Throwable would surface as a fatal/HTML error page. We
122 + * convert it to a clean 500 WP_Error.
123 + *
124 + * $wpdb's prior show-errors state and the buffer are always restored in
125 + * finally, so global state is untouched after the call.
126 + */
127 + private static function run_isolated( callable $callback, \WP_REST_Request $request, string $slug ) {
128 + global $wpdb;
129 +
130 + $prev_show_errors = null;
131 + if ( $wpdb instanceof \wpdb ) {
132 + // hide_errors() returns the previous flag so we can restore it.
133 + $prev_show_errors = $wpdb->hide_errors();
134 + }
135 +
136 + ob_start();
137 + try {
138 + $result = call_user_func( $callback, $request );
139 + } catch ( \Throwable $e ) {
140 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
141 + error_log( sprintf( '[xspeed] REST callback for "%s" threw: %s', $slug, $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated behind WP_DEBUG.
142 + }
143 + $result = new \WP_Error(
144 + 'xspeed_rest_exception',
145 + __( 'The request could not be completed due to a server error.', 'xspeed' ),
146 + array( 'status' => 500 )
147 + );
148 + } finally {
149 + // Discard anything the callback (or $wpdb) echoed — DB-error
150 + // HTML, notices, debug output — so the response body is JSON only.
151 + $stray = ob_get_clean();
152 + if ( '' !== $stray && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
153 + error_log( sprintf( '[xspeed] discarded %d bytes of stray REST output from "%s"', strlen( $stray ), $slug ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated behind WP_DEBUG.
154 + }
155 + if ( $wpdb instanceof \wpdb && false !== $prev_show_errors && null !== $prev_show_errors ) {
156 + $wpdb->show_errors();
157 + }
158 + }
159 +
160 + return $result;
161 + }
162 +
163 + /**
164 + * Values that are shaped like a capability but cannot be one.
165 + *
166 + * WordPress's `__return_*` helpers are function names. Passed where a
167 + * capability belongs they are simply an unknown capability, and an unknown
168 + * capability is denied — so they close a route rather than open it. Listed
169 + * literally: the check has to be certain, because a capability that merely
170 + * happens to share a name with some function is legitimate.
171 + */
172 + private const NOT_A_CAPABILITY = array(
173 + '__return_true',
174 + '__return_false',
175 + '__return_zero',
176 + '__return_null',
177 + '__return_empty_array',
178 + '__return_empty_string',
179 + );
180 +
181 + /** Whether a declared capability is one `current_user_can()` could grant. */
182 + private static function is_capability( $capability ): bool {
183 + return is_string( $capability )
184 + && '' !== $capability
185 + && ! in_array( $capability, self::NOT_A_CAPABILITY, true );
186 + }
187 +
188 + /**
99 189 * Wrap permission_callback with the always-on cap check. A module may
100 190 * declare its own permission_callback for an extra-strict gate; both
101 191 * must pass.
192 + *
193 + * A route may opt out of the capability check with
194 + * `'allow_unauthenticated' => true`. That is ONLY for endpoints designed to
195 + * be called by anonymous frontend visitors — the RUM beacon is the reason
196 + * this exists: it collects Core Web Vitals from real visitors, who by
197 + * definition are not logged in, so the default `manage_options` gate
198 + * rejected every sample with a 401 and the feature could never record
199 + * anything. (FBS-84070)
200 + *
201 + * Opting out drops ONLY the capability check. A route-declared
202 + * `permission_callback` still runs and still has to pass, so a module can
203 + * keep its own validation (nonce, rate limit, payload shape) on top.
102 204 */
103 205 private static function wrap_permission( Module $module, array $route ): callable {
104 206 $declared = $route['permission_callback'] ?? null;
105 207 $capability = $route['capability'] ?? 'manage_options';
208 + $public = ! empty( $route['allow_unauthenticated'] );
106 209
107 - return static function ( \WP_REST_Request $request ) use ( $declared, $capability ) {
108 - if ( ! current_user_can( $capability ) ) {
210 + if ( ! $public && ! self::is_capability( $capability ) ) {
211 + /*
212 + * A route that reads as public and is closed to everyone.
213 + *
214 + * `'capability' => '__return_true'` is the shape this catches: a
215 + * function name, not a capability. `current_user_can()` denies an
216 + * unknown capability — for an anonymous caller AND for a logged-in
217 + * administrator — so the route answers 401 to every request while
218 + * looking, to the next person who reads it, like it lets everyone
219 + * through. One shipped that way, and what found it was a customer's
220 + * 401 rather than any test.
221 + *
222 + * It stays DENIED. Reading "public" out of a value that cannot be a
223 + * capability would turn a typo into an authentication bypass, which
224 + * is a far worse failure than the one being reported. The fix is to
225 + * say `'allow_unauthenticated' => true`, which is the only thing
226 + * that opens a route here, and this says so.
227 + */
228 + _doing_it_wrong(
229 + __METHOD__,
230 + esc_html(
231 + sprintf(
232 + 'Route "%s" declares "%s" as its capability. That is not a capability, so current_user_can() denies every caller, including administrators. Use \'allow_unauthenticated\' => true for a route that is meant to be public.',
233 + $module->slug() . ( $route['path'] ?? '' ),
234 + is_scalar( $capability ) ? (string) $capability : gettype( $capability )
235 + )
236 + ),
237 + 'xspeed 1.2.5'
238 + );
239 + }
240 +
241 + return static function ( \WP_REST_Request $request ) use ( $declared, $capability, $public ) {
242 + if ( ! $public && ! current_user_can( $capability ) ) {
109 243 return false;
110 244 }
111 245 if ( is_callable( $declared ) ) {
112 246 $result = call_user_func( $declared, $request );