hardening 2.3: 2meet-data-optimizer 1.3.0 (flat-table DDL outside web requests, routed count cache, audit fixes) #7
@@ -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(
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user