PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.4
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.4
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
← All changes | src/Admin/RestApi.php +87 -18 2.0.2 → 2.0.4 View file →
@@ -28,11 +28,50 @@
28 28 * Register hooks.
29 29 */
30 30 public function boot(): void {
31 31 add_action( 'rest_api_init', array( $this, 'register' ) );
32 + // After core's cookie check (priority 100).
33 + add_filter( 'rest_authentication_errors', array( $this, 'accept_cached_page_token' ), 101 );
32 34 }
33 35
34 36 /**
37 + * (2.0.4) Let a page-cached beacon through on its cache-safe token.
38 + *
39 + * A cached page carries the nonce it was rendered with, which expires
40 + * within a day. WordPress rejects ANY REST request carrying an invalid
41 + * nonce (403 rest_cookie_invalid_nonce) before the route runs — logged in
42 + * or not — so the token those pages also send was never checked, and every
43 + * beacon from a day-old cached page failed. Since 2.0.4 the scripts send
44 + * the token alone; this covers pages cached by older versions. Only for
45 + * the two public endpoints, only with a valid token, and the request then
46 + * runs as no user — exactly as if no nonce had been sent.
47 + *
48 + * @param mixed $result Authentication result so far.
49 + * @return mixed
50 + */
51 + public function accept_cached_page_token( $result ) {
52 + if ( ! is_wp_error( $result ) || 'rest_cookie_invalid_nonce' !== $result->get_error_code() ) {
53 + return $result;
54 + }
55 +
56 + $route = isset( $GLOBALS['wp']->query_vars['rest_route'] ) ? untrailingslashit( (string) $GLOBALS['wp']->query_vars['rest_route'] ) : '';
57 +
58 + if ( ! in_array( $route, array( '/' . self::NS . '/beacon', '/' . self::NS . '/async-fonts' ), true ) ) {
59 + return $result;
60 + }
61 +
62 + $token = isset( $_GET['_eftoken'] ) ? (string) wp_unslash( $_GET['_eftoken'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
63 +
64 + if ( '' === $token || ! hash_equals( Settings::beacon_token(), $token ) ) {
65 + return $result;
66 + }
67 +
68 + wp_set_current_user( 0 );
69 +
70 + return true;
71 + }
72 +
73 + /**
35 74 * Register routes.
36 75 */
37 76 public function register(): void {
38 77 $admin = array( $this, 'can_manage' );
@@ -137,8 +176,31 @@
137 176 ) );
138 177 }
139 178
140 179 /**
180 + * Authorise a public (beacon / async-fonts) request: accept the cache-safe
181 + * site token, or a valid REST nonce (fresh, uncached pages).
182 + *
183 + * @param WP_REST_Request $request Request.
184 + * @return bool
185 + */
186 + private function public_request_authorized( WP_REST_Request $request ): bool {
187 + $token = (string) $request->get_param( '_eftoken' );
188 +
189 + if ( '' !== $token && hash_equals( Settings::beacon_token(), $token ) ) {
190 + return true;
191 + }
192 +
193 + $nonce = $request->get_header( 'X-WP-Nonce' );
194 +
195 + if ( ! $nonce ) {
196 + $nonce = $request->get_param( '_wpnonce' );
197 + }
198 +
199 + return (bool) wp_verify_nonce( (string) $nonce, 'wp_rest' );
200 + }
201 +
202 + /**
141 203 * Capability gate.
142 204 *
143 205 * @return bool
144 206 */
@@ -417,8 +479,9 @@
417 479 ( new UsageTracker() )->truncate();
418 480
419 481 delete_option( 'easyfonts_processed_local' );
420 482 delete_option( 'easyfonts_processed_external' );
483 + delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret.
421 484
422 485 $settings = Settings::all();
423 486 $settings['detectors'] = array();
424 487 Settings::save( $settings );
@@ -438,8 +501,9 @@
438 501 ( new Storage() )->purge();
439 502
440 503 delete_option( 'easyfonts_processed_local' );
441 504 delete_option( 'easyfonts_processed_external' );
505 + delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret.
442 506 delete_transient( 'easyfonts_schema_checked' );
443 507
444 508 $settings = Settings::all();
445 509 $settings['detectors'] = array();
@@ -470,15 +534,9 @@
470 534 * @param WP_REST_Request $request Request.
471 535 * @return WP_REST_Response
472 536 */
473 537 public function beacon( WP_REST_Request $request ): WP_REST_Response {
474 - $nonce = $request->get_header( 'X-WP-Nonce' );
475 -
476 - if ( ! $nonce ) {
477 - $nonce = $request->get_param( '_wpnonce' );
478 - }
479 -
480 - if ( ! wp_verify_nonce( (string) $nonce, 'wp_rest' ) ) {
538 + if ( ! $this->public_request_authorized( $request ) ) {
481 539 return new WP_REST_Response( array( 'ok' => false ), 403 );
482 540 }
483 541
484 542 $params = (array) $request->get_json_params();
@@ -484,10 +542,13 @@
484 542 $params = (array) $request->get_json_params();
485 543 $route = sanitize_text_field( $params['route'] ?? '/' );
486 544 $device = in_array( $params['device'] ?? '', array( 'mobile', 'desktop' ), true ) ? $params['device'] : 'any';
487 545
488 - // Cap the route length to keep the indexed column sane.
489 - $route = '' === $route ? '/' : substr( $route, 0, 480 );
546 + // Normalise to a canonical path (leading '/', no query/scheme) so
547 + // crafted route strings can't mint arbitrary rows, then cap the length
548 + // to keep the indexed column sane.
549 + $route = UsageTracker::route_for( $route );
550 + $route = substr( $route, 0, 480 );
490 551
491 552 $usage = new UsageTracker();
492 553
493 554 // Per-route/device throttle: once we record a beacon for a route we
@@ -604,15 +665,9 @@
604 665 * @param WP_REST_Request $request Request.
605 666 * @return WP_REST_Response
606 667 */
607 668 public function async_fonts( WP_REST_Request $request ): WP_REST_Response {
608 - $nonce = $request->get_header( 'X-WP-Nonce' );
609 -
610 - if ( ! $nonce ) {
611 - $nonce = $request->get_param( '_wpnonce' );
612 - }
613 -
614 - if ( ! wp_verify_nonce( (string) $nonce, 'wp_rest' ) ) {
669 + if ( ! $this->public_request_authorized( $request ) ) {
615 670 return new WP_REST_Response( array( 'ok' => false ), 403 );
616 671 }
617 672
618 673 if ( ! Settings::get( 'enabled', 1 ) || ! Settings::get( 'async_blocker', 0 ) ) {
@@ -646,10 +701,24 @@
646 701 }
647 702
648 703 if ( count( $stored ) !== $before ) {
649 704 update_option( 'easyfonts_async_urls', array_values( $stored ), false );
650 - // New runtime fonts discovered → invalidate so the next render hosts them.
651 - Settings::bump_buster();
705 +
706 + // New runtime fonts discovered → invalidate so the next render hosts
707 + // them. Rate-limited: this is public input, and every bump drops the
708 + // CSS fetch cache site-wide, so crafted unique URLs must not be able
709 + // to thrash it.
710 + if ( ! get_transient( 'easyfonts_async_bump_lock' ) ) {
711 + /**
712 + * Minimum seconds between async-endpoint cache-buster bumps.
713 + *
714 + * @param int $interval
715 + */
716 + $interval = max( 60, (int) apply_filters( 'easyfonts_async_bump_interval', 5 * MINUTE_IN_SECONDS ) );
717 +
718 + set_transient( 'easyfonts_async_bump_lock', 1, $interval );
719 + Settings::bump_buster();
720 + }
652 721 }
653 722
654 723 return new WP_REST_Response( array( 'ok' => true, 'queued' => count( $stored ) ), 200 );
655 724 }