PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / 2.1.2
bBlocks – Essential Gutenberg Blocks & Patterns Collection v2.1.2
2.1.6 2.1.5 2.1.4 2.1.3 2.1.2 2.1.1 2.1.0 2.0.43 2.0.42 2.0.41 2.0.40 2.0.39 2.0.38 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 106 releases
b-blocks / includes / blocks / OptinRateLimit.php

OptinRateLimit.php in bBlocks – Essential Gutenberg Blocks & Patterns Collection 2.1.2, at includes/blocks/OptinRateLimit.php

116 lines 3.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Transient-based per-IP rate limiter for public optin AJAX endpoints.
4 *
5 * Shared by the Newsletter Optin and Popup Optin AJAX handlers. Caps
6 * unauthenticated (and authenticated) submissions to a configurable number of
7 * requests within a rolling time window, keyed by hashed IP address.
8 *
9 * Usage (inside an AJAX handler, after nonce verification):
10 *
11 * BBlocksOptinRateLimit::check( 'bb_po' ); // sends JSON error and exits if over limit
12 *
13 * The implementation uses WordPress transients so it works on any host without
14 * requiring Redis, APCu, or a custom DB table. Transient keys are prefixed and
15 * hashed so they never expose the raw IP address in the options table.
16 *
17 * @package bBlocks
18 */
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 if ( ! class_exists( 'BBlocksOptinRateLimit' ) ) {
25
26 class BBlocksOptinRateLimit {
27
28 /**
29 * Maximum submissions allowed per IP within the time window.
30 */
31 const MAX_HITS = 5;
32
33 /**
34 * Length of the rolling window in seconds.
35 */
36 const WINDOW_SECONDS = 60;
37
38 /**
39 * Check the rate limit for the current request IP.
40 *
41 * Increments the counter for the given endpoint prefix. If the counter
42 * exceeds MAX_HITS within WINDOW_SECONDS it calls wp_send_json_error()
43 * and terminates the request (consistent with how nonce failures are
44 * handled in our handlers).
45 *
46 * @param string $prefix Short namespace token to separate counters per
47 * endpoint, e.g. 'bb_po' or 'bb_no'.
48 */
49 public static function check( $prefix ) {
50 $ip = self::get_ip();
51 $key = 'bb_rl_' . sanitize_key( $prefix ) . '_' . md5( $ip );
52
53 $hits = (int) get_transient( $key );
54
55 if ( $hits >= self::MAX_HITS ) {
56 wp_send_json_error(
57 [ 'message' => __( 'Too many requests. Please wait a moment and try again.', 'b-blocks' ) ],
58 429
59 );
60 }
61
62 // Increment. First hit also sets the expiry window.
63 if ( 0 === $hits ) {
64 set_transient( $key, 1, self::WINDOW_SECONDS );
65 } else {
66 // get_transient returned an existing value; update in place.
67 // We intentionally do not refresh the expiry on subsequent
68 // hits so the window is rolling from the *first* request.
69 set_transient( $key, $hits + 1, self::WINDOW_SECONDS );
70 }
71 }
72
73 /**
74 * Resolve the client IP address.
75 *
76 * Falls back through common proxy headers to REMOTE_ADDR. Each value
77 * is validated with filter_var so a spoofed X-Forwarded-For cannot
78 * inject an arbitrary string into the transient key.
79 *
80 * @return string A valid IP string, or '0.0.0.0' as a safe fallback.
81 */
82 private static function get_ip() {
83 $candidates = [];
84
85 // Trusted proxy headers — only check these when WordPress itself
86 // is behind a known proxy (e.g. a load balancer). Using them
87 // without a trusted proxy allowlist means clients can spoof the
88 // value, but a spoofed IP only affects which rate-limit bucket
89 // they land in — it doesn't bypass the check.
90 if ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
91 // X-Forwarded-For can be a comma-separated list; the leftmost
92 // IP is the originating client.
93 $xfwd = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
94 $candidates[] = trim( $xfwd[0] );
95 }
96
97 if ( isset( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
98 $candidates[] = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
99 }
100
101 if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
102 $candidates[] = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
103 }
104
105 foreach ( $candidates as $candidate ) {
106 $ip = filter_var( trim( $candidate ), FILTER_VALIDATE_IP );
107 if ( false !== $ip ) {
108 return $ip;
109 }
110 }
111
112 return '0.0.0.0';
113 }
114 }
115 }
116