benecaster_rate_limit_client_ip
Filters the IP address that every rate limit in the plugin counts against — the REST limiter and the follower-signup throttle alike. Receives the value of REMOTE_ADDR, which may be ''.
X-Forwarded-For is NOT honoured by default, and must not be on a site with no proxy in front of it. Any client can send that header with any value, so trusting it turns every per-IP limit in the plugin into no limit at all — an attacker rotates the header and each request looks like a new visitor. That failure is silent: the limiter still appears to work, the counters still tick, and nothing surfaces to say the gate is open.
The filter exists because the safe default is wrong for a large minority of sites. Behind Cloudflare or any reverse proxy, REMOTE_ADDR is the proxy’s address, so every visitor shares one bucket and a per-IP limit silently becomes a site-wide one. A site in that position must opt in explicitly, and is responsible for reading a header its own proxy sets and overwrites.
One override affects every per-IP limit in the plugin, not just the form you were thinking about when you added it. That is deliberate — both consumers resolve the client IP the same way, so a proxy is configured once rather than half-applied.
Tune the follower signup abuse limits — and know what turning them off costs
[benecaster_follower_signup] is a public form that always reports success and sends an email on every submission. It collects no password either, so nothing costs a submitter any effort. Without a limit that is an open mail relay pointed at addresses of the submitter’s choosing, on your domain and your sending reputation — plus a way to exhaust an unlicensed show’s 100-follower ceiling with fake addresses.
The rate limits below are one layer of three. A hidden honeypot field and a minimum render-to-submit time run alongside them, on for every install automatically with no filter or setting to tune. Both share the rate limits’ own silent-drop behaviour: a caught submission shows the same success screen and does nothing. Confirmation-before-counting is a fourth, separate layer — see Recipe: require-follower-signup-confirmation (benecaster_follower_double_optin) — this recipe is about tuning the rate limits specifically, the one layer that is actually configurable.
Defaults: 3 submissions per email address per show per hour, 10 per IP per hour. The IP limit is deliberately not scoped per show, or a submitter’s allowance would be multiplied by the number of shows on the install.
A limit of 0 or less disables that limit entirely. On this form that is not “unlimited signups”, it is an open relay. If you are reaching for 0 because legitimate traffic is being caught, raise the number instead.
A throttled submission looks exactly like a successful one — same screen, nothing provisioned, nothing sent. This is deliberate and must not be “fixed” into a visible error. The per-address limit makes a visible message an activity oracle: submit somebody else’s address, get told you are rate limited, and you have learned that address was recently submitted to this site. That is a worse leak than the address enumeration the identical-response rule exists to prevent, and it would arrive dressed as a usability improvement. Use benecaster_follower_signup_throttled to log refusals instead of showing them.
Behind a reverse proxy. Every per-IP limit counts REMOTE_ADDR, which behind Cloudflare or similar is the proxy’s address — so every visitor shares one bucket and a per-IP limit silently becomes a site-wide one. Opt in explicitly with benecaster_rate_limit_client_ip. Never read a header your own proxy does not overwrite — X-Forwarded-For on a site with no proxy in front of it is attacker-controlled. Trusting one turns every per-IP limit in the plugin into no limit at all, and does so silently: the limiter still appears to work, the counters still tick, and every request just looks like a new visitor. This one filter affects the REST limiter as well as this form.
Writing your own limit. Call RateLimitStore::hit( $key, $limit, $window ) — it returns true when the request is over quota. Do not hand-roll another get_transient / set_transient pair; that class exists because the plugin was heading for three private copies of the same window arithmetic. Hash anything personal with RateLimitStore::hash() before it becomes part of a key: transient names land in wp_options on a site with no object cache, and a raw email address there is personal data in a place nobody checks during an erasure request. Full method reference: RateLimitStore.
<?php
/**
* Loosen the limits for a site running a signup drive.
*
* ⚠⚠ A limit of 0 or less DISABLES that limit. On a form that always
* reports success and mails on every submission, that is an open relay.
*
* @param array{address_limit:int, ip_limit:int, window:int} $limits
*/
add_filter( 'benecaster_follower_signup_limits', function ( array $limits, int $show_id ): array {
if ( 42 === $show_id ) {
$limits['ip_limit'] = 30;
}
return $limits;
}, 10, 2 );
/**
* Log refusals rather than showing them.
*
* ⚠ The submitter is told nothing, on purpose. A visible "too many
* requests" on a per-address limit tells a stranger that an address was
* recently submitted here.
*
* @param string $reason 'address' or 'ip'.
*/
add_action( 'benecaster_follower_signup_throttled', function ( int $show_id, string $reason ): void {
error_log( "benecaster: follower signup throttled on show {$show_id} ({$reason})" );
}, 10, 2 );
/**
* Count the real visitor IP when the site sits behind a reverse proxy.
*
* ⚠⚠ Only safe because THIS site's proxy sets and OVERWRITES this header.
* Reading a client-supplied header instead disables every per-IP limit in
* the plugin, silently — the counters still tick, every request just looks
* like a new visitor.
*/
add_filter( 'benecaster_rate_limit_client_ip', function ( string $ip ): string {
return isset( $_SERVER['HTTP_CF_CONNECTING_IP'] )
? (string) $_SERVER['HTTP_CF_CONNECTING_IP']
: $ip;
} );
/**
* Writing your own limit — reuse the shared store, do not hand-roll one.
*/
add_action( 'init', function (): void {
$store = new \Benecaster\Support\RateLimitStore();
// ⚠ Hash anything personal BEFORE it becomes part of a key: transient
// names land in wp_options on a site with no object cache.
$key = 'my_addon_' . $store->hash( $store->client_ip() );
if ( $store->hit( $key, 5, HOUR_IN_SECONDS ) ) {
return; // Over quota. Refuse quietly.
}
my_addon_do_the_expensive_thing();
} );
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
$ip |
string |
— | The value of `REMOTE_ADDR`, possibly an empty string. |
Returns:
string
Examples
Read the real visitor IP behind Cloudflare
add_filter( 'benecaster_rate_limit_client_ip', function ( string $ip ): string {
// Only safe because THIS site's proxy sets and overwrites this header.
return isset( $_SERVER['HTTP_CF_CONNECTING_IP'] )
? (string) $_SERVER['HTTP_CF_CONNECTING_IP']
: $ip;
} );
Notes
This filter changes who counts as "one visitor" for rate-limiting purposes; it does not lift a limit or make an exception to one. Adding it does not set a precedent for putting a similar override in front of any other gate in the plugin.
The returned value is never echoed or queried — it is used only as input to RateLimitStore::hash(), which salts and truncates it before it becomes part of a transient key. Returning '' disables per-IP counting for that request.