diff --git a/includes/class-tmdo-rest-api.php b/includes/class-tmdo-rest-api.php index e135fb5..e2b949b 100644 --- a/includes/class-tmdo-rest-api.php +++ b/includes/class-tmdo-rest-api.php @@ -23,6 +23,9 @@ if ( ! defined( 'ABSPATH' ) ) { */ class TMDO_REST_API { + /** Blocked views are counted 1 in this many (wpdo_rl_stats). */ + private const RL_STATS_SAMPLE = 10; + const NAMESPACE = 'wpdo/v1'; /** @@ -725,14 +728,18 @@ class TMDO_REST_API { if ( $cookie_blocked || $ip_blocked ) { // Track rate-limit hits per post for admin visibility, capped at 100 entries. - $rl_stats = get_option( 'wpdo_rl_stats', array() ); - $rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1; - if ( count( $rl_stats ) > 100 ) { - // Keep only the top-100 posts by hit count to bound option size. - arsort( $rl_stats ); - $rl_stats = array_slice( $rl_stats, 0, 100, true ); + // Sampled (1 in RL_STATS_SAMPLE, each sample adding RL_STATS_SAMPLE) so + // anonymous requests cannot turn every blocked view into an option write. + if ( 1 === wp_rand( 1, self::RL_STATS_SAMPLE ) ) { + $rl_stats = get_option( 'wpdo_rl_stats', array() ); + $rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + self::RL_STATS_SAMPLE; + if ( count( $rl_stats ) > 100 ) { + // Keep only the top-100 posts by hit count to bound option size. + arsort( $rl_stats ); + $rl_stats = array_slice( $rl_stats, 0, 100, true ); + } + update_option( 'wpdo_rl_stats', $rl_stats, false ); } - update_option( 'wpdo_rl_stats', $rl_stats, false ); return new WP_REST_Response( array( diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php index 64d947f..b30cf7c 100644 --- a/tests/unit/RestApiTest.php +++ b/tests/unit/RestApiTest.php @@ -377,7 +377,11 @@ class RestApiTest extends TestCase { $this->assertSame( 'too_many_requests', $response->get_data()['code'] ); } - public function test_post_view_429_increments_rate_limit_stats(): void { + /** + * Blocked requests are counted 1 in 10 (each sample adds 10), so anonymous + * traffic cannot turn every blocked request into an option write. + */ + public function test_post_view_429_samples_rate_limit_stats(): void { $GLOBALS['_wp_post_types'][40] = 'hp_listing'; $_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block. $nonce = wp_create_nonce( 'wp_rest' ); @@ -385,10 +389,17 @@ class RestApiTest extends TestCase { $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' ); $req->set_param( 'id', 40 ); $req->set_header( 'X-WP-Nonce', $nonce ); - $this->api->post_view( $req ); + $writes = 0; + for ( $i = 0; $i < 200; $i++ ) { + $before = get_option( 'wpdo_rl_stats', [] ); + $this->assertSame( 429, $this->api->post_view( $req )->get_status() ); + $writes += get_option( 'wpdo_rl_stats', [] ) === $before ? 0 : 1; + } - $stats = get_option( 'wpdo_rl_stats', [] ); - $this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) ); + $count = (int) ( get_option( 'wpdo_rl_stats', [] )['40'] ?? 0 ); + $this->assertSame( 10 * $writes, $count, 'each sample adds 10' ); + $this->assertGreaterThan( 0, $writes ); + $this->assertLessThan( 60, $writes, 'about 20 writes for 200 blocked requests, not 200' ); } // ── get_status: rate_limit_stats ─────────────────────────────────────────