Skip to main content

Tune the follower signup abuse limits — and know what turning them off costs

Free Intermediate

[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 overwriteX-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.

Code

<?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();
} );

View on GitHub →

Hooks Used