| @@ -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 | } |