blocklist = $blocklist ?? new IpBlocklist(); } /** * Determines whether the honeypot is switched on. * * @return bool True when forms should render the trap field. */ public static function honeypot_enabled(): bool { return Settings::flag( 'honeypot' ); } /** * Returns the challenge a form should carry, if any. * * @return CaptchaProvider|null The provider, or null when none applies. */ public static function captcha(): ?CaptchaProvider { return CaptchaManager::active(); } /** * Determines whether a challenge is configured. * * @return bool True when submissions must answer one. */ public static function recaptcha_enabled(): bool { return self::captcha() instanceof CaptchaProvider; } /** * Returns the signed timestamp a form is rendered with. * * Signed because the value it protects is the one a bot would most like to * change: an unsigned timestamp can simply be posted back as an hour ago. */ public static function stamp(): string { $now = time(); return $now . '.' . self::sign( (string) $now ); } /** * Runs every enabled check against the current request. * * @param Form $form Form being submitted. * @param array $values Optional. Submitted values, for content signals. * @param array|null $signals Optional. Honeypot, render stamp and captcha answer, * as built by {@see self::signals()}. Default read * from the request. * * @return WP_Error|null The rejection, or null when the submission may proceed. */ public function check( Form $form, array $values = array(), ?array $signals = null ): ?WP_Error { $ip = $this->client_ip(); $signals = $signals ?? self::signals( array( Request::class, 'text' ) ); $blocked = $this->hard_blocks( $form, $ip ); if ( $blocked instanceof WP_Error ) { return $blocked; } $score = $this->score( $form, $values, $ip, $signals ); /** * Filters the spam score of a submission. * * Add points with `$score->add()` for a signal of your own, or subtract * them for a visitor you trust. The threshold is applied after this. * * @since 1.0.0 * * @param SpamScore $score Score collected so far. * @param Form $form Form being submitted. * @param array $values Submitted values. */ do_action( 'formpilot_spam_score', $score, $form, $values ); if ( $score->is_spam( $this->threshold() ) ) { /** * Fires when a submission is refused as spam. * * Carries why, which is the one thing the visitor is not told. The * logging service listens for this; a site can listen for it too. * * @since 1.0.0 * * @param Form $form Form being submitted. * @param int $score Points the submission collected. * @param array $signals Points keyed by the reason that added them. */ do_action( 'formpilot_spam_rejected', $form, $score->total(), $score->signals() ); /* * Deliberately vague: a bot should not learn which signal gave it away. * That includes the score, which used to ride along in the response and * let a client tune its requests until it slipped under the threshold. * The action above carries it to the log. */ return new WP_Error( 'formpilot_spam', __( 'Your submission could not be accepted.', 'devplugins-custom-form-builder' ) ); } /** * Filters the outcome of the spam checks. * * Return a WP_Error to reject a submission from an add-on. Prefer * `formpilot_spam_score` unless the rejection is certain. * * @since 1.0.0 * * @param WP_Error|null $error Rejection, or null when the submission passed. * @param Form $form Form being submitted. */ $error = apply_filters( 'formpilot_spam_check', null, $form ); return $error instanceof WP_Error ? $error : null; } /** * Records an accepted submission against the counters. * * A *failed* submission still does not start the interval throttle, so a * visitor who mistypes an email address is not made to wait before correcting * it. Attempts that were refused as spam are a different matter — see * {@see SpamGuard::record_rejection()}. * * @param int $form_id Form that was submitted. */ public function record( int $form_id ): void { $ip = $this->client_ip(); $this->count_attempt( $form_id, $ip ); $interval = Settings::number( 'submission_interval' ); if ( $interval > 0 ) { set_transient( $this->interval_key( $ip ), time(), $interval ); } } /** * Records an attempt that was refused as spam. * * The rate limit used to count successes only. Every rejection — honeypot, * failed captcha, spam score — incremented nothing, so the limit bounded * legitimate visitors and left an attacker free to hammer the endpoint * indefinitely. Because each attempt can cost an outbound captcha * verification with a ten second timeout, that was also an amplification * channel pointed at the site's own server. * * Only the counter moves. The interval throttle stays untouched, because a * shared address behind a proxy would otherwise let one bot lock out everyone * on it; the counter alone bounds the traffic without that reach. * * @param int $form_id Form that was submitted. */ public function record_rejection( int $form_id ): void { $this->count_attempt( $form_id, $this->client_ip() ); } /** * Adds one to the per-form, per-address attempt counter. * * @param int $form_id Form that was submitted. * @param string $ip Address of the visitor. */ private function count_attempt( int $form_id, string $ip ): void { if ( Settings::number( 'rate_limit' ) <= 0 ) { return; } $key = $this->rate_key( $form_id, $ip ); $window = $this->attempts( $key ); $now = time(); /* * A fixed window, written back with only the time it has left. Rewriting * the transient with the full WINDOW on every attempt pushed its expiry out * each time, so "5 a minute" meant "5 without a one-minute pause" - an * office behind one address submitting every 45 seconds was refused on * the sixth, for as long as it kept going. */ if ( $window['start'] + self::WINDOW <= $now ) { $window = array( 'count' => 0, 'start' => $now, ); } set_transient( $key, array( 'count' => $window['count'] + 1, 'start' => $window['start'], ), max( 1, $window['start'] + self::WINDOW - $now ) ); } /** * Reads the attempts counted in the current window. * * @param string $key Transient name. * * @return array{count: int, start: int} Attempts so far and when the window opened. */ private function attempts( string $key ): array { $stored = get_transient( $key ); if ( is_array( $stored ) ) { return array( 'count' => (int) ( $stored['count'] ?? 0 ), 'start' => (int) ( $stored['start'] ?? 0 ), ); } // A bare number is the counter as it was stored before the window had a start. return array( 'count' => (int) $stored, 'start' => false === $stored ? 0 : time(), ); } /** * Runs the checks that reject on their own. * * @param Form $form Form being submitted. * @param string $ip Address of the visitor. * * @return WP_Error|null The rejection, or null when none applied. */ private function hard_blocks( Form $form, string $ip ): ?WP_Error { if ( ! Nonce::verify_origin() ) { return new WP_Error( 'formpilot_cross_origin', __( 'Your submission could not be accepted.', 'devplugins-custom-form-builder' ) ); } if ( $this->blocklist->blocks( $ip ) ) { // Same wording as a spam rejection: an address that is blocked should // not be told so. return new WP_Error( 'formpilot_blocked', __( 'Your submission could not be accepted.', 'devplugins-custom-form-builder' ) ); } $interval = Settings::number( 'submission_interval' ); if ( $interval > 0 ) { $last = (int) get_transient( $this->interval_key( $ip ) ); if ( $last > 0 ) { return new WP_Error( 'formpilot_throttled', sprintf( /* translators: %s: Number of seconds. */ _n( 'Please wait %s second before sending another submission.', 'Please wait %s seconds before sending another submission.', $interval, 'devplugins-custom-form-builder' ), number_format_i18n( $interval ) ) ); } } $limit = Settings::number( 'rate_limit' ); if ( $limit > 0 && $this->attempts( $this->rate_key( $form->id(), $ip ) )['count'] >= $limit ) { return new WP_Error( 'formpilot_rate_limited', __( 'You are sending submissions too quickly. Please wait a moment and try again.', 'devplugins-custom-form-builder' ) ); } return null; } /** * Collects everything suspicious about the request. * * @param Form $form Form being submitted. * @param array $values Submitted values. * @param string $ip Address of the visitor. * @param array $signals Honeypot, render stamp and captcha answer. */ private function score( Form $form, array $values, string $ip, array $signals ): SpamScore { $score = new SpamScore(); if ( self::honeypot_enabled() && '' !== trim( (string) ( $signals['honeypot'] ?? '' ) ) ) { $score->add( 'honeypot', self::CERTAIN ); } $this->score_timing( $score, (string) ( $signals['stamp'] ?? '' ) ); $this->score_content( $score, $values ); if ( '' === Request::server( 'HTTP_USER_AGENT' ) ) { // Not proof of anything: a privacy tool may strip it. It is one point // of evidence among several, which is what the score is for. $score->add( 'no_user_agent', 25 ); } // Last, because it is the only check that leaves the server. $captcha = self::captcha(); if ( $captcha instanceof CaptchaProvider && $score->total() < $this->threshold() ) { $failure = $captcha->verify( $ip, (string) ( $signals['captcha'] ?? '' ) ); if ( $failure instanceof WP_Error ) { $score->add( 'captcha', self::CERTAIN ); } } return $score; } /** * Scores how long the form took to fill in. * * @param SpamScore $score Score being collected. * @param string $stamp Signed render stamp that came with the submission. */ private function score_timing( SpamScore $score, string $stamp ): void { $minimum = Settings::number( 'min_submit_seconds' ); if ( $minimum <= 0 ) { return; } $elapsed = $this->elapsed( $stamp ); /* * A missing or unsigned stamp is worth noting but not refusing on: a * cached page served before this version shipped has none, and so does a * form embedded by an add-on that builds its own markup. */ if ( null === $elapsed ) { $score->add( 'no_stamp', 20 ); return; } if ( $elapsed < $minimum ) { $score->add( 'too_fast', 60 ); } } /** * Scores what the submission contains. * * @param SpamScore $score Score being collected. * @param array $values Submitted values. */ private function score_content( SpamScore $score, array $values ): void { if ( array() === $values ) { return; } $text = ''; array_walk_recursive( $values, static function ( $value ) use ( &$text ): void { if ( is_scalar( $value ) ) { $text .= ' ' . (string) $value; } } ); $links = preg_match_all( '#\bhttps?://#i', $text ); $links = is_int( $links ) ? $links : 0; /* * Two links is a normal enquiry — someone's own site and a reference. * Beyond that each one adds a little, and the cap keeps this from ever * refusing a submission by itself. */ if ( $links > 2 ) { $score->add( 'links', min( 30, ( $links - 2 ) * 10 ) ); } } /** * Collects the request-borne signals the guard scores. * * The AJAX path reads them from the posted form; the REST path from its JSON * body, which never reaches `$_POST`. Both hand the guard the same shape. * * @param callable(string): string $read Reads one request parameter by name. * * @return array{honeypot: string, stamp: string, captcha: string} The signals. */ public static function signals( callable $read ): array { $captcha = self::captcha(); return array( 'honeypot' => (string) $read( self::HONEYPOT_FIELD ), 'stamp' => (string) $read( self::STAMP_FIELD ), 'captcha' => $captcha instanceof CaptchaProvider ? (string) $read( $captcha->response_field() ) : '', ); } /** * Returns how long ago the form was rendered. * * @param string $raw Signed render stamp. * * @return int|null The seconds elapsed, or null when there is no valid stamp. */ private function elapsed( string $raw ): ?int { if ( ! str_contains( $raw, '.' ) ) { return null; } list( $stamp, $signature ) = explode( '.', $raw, 2 ); if ( ! ctype_digit( $stamp ) || ! hash_equals( self::sign( $stamp ), $signature ) ) { return null; } $elapsed = time() - (int) $stamp; /* * A stamp older than the window is no longer evidence of anything. Without * an upper bound, one stamp harvested from any page defeated the timing * signals for every form on the site for as long as the salt lived, because * "posted a very long time after rendering" read as "took their time". */ if ( $elapsed > self::STAMP_LIFETIME ) { return null; } return max( 0, $elapsed ); } /** * Signs a value with the site's own salt. * * The site URL is mixed in so a stamp minted on one site of a network does not * verify on another: `NONCE_SALT` comes from `wp-config.php` and is shared * across every site in an install. * * @param string $value Value to sign. */ private static function sign( string $value ): string { return substr( hash_hmac( 'sha256', $value . '|' . get_site_url(), wp_salt( 'nonce' ) ), 0, 20 ); } /** * Returns the score at which a submission is refused. */ private function threshold(): int { $threshold = Settings::number( 'spam_threshold' ); /* * Capped at the weight of a certain signal. The setting accepts up to 500 * and its help text only ever suggests lowering it, so an administrator * raising it to reduce false positives could silently disarm the honeypot * and the captcha — both worth exactly CERTAIN — without being told. Above * this point the number stops meaning anything useful. */ return $threshold > 0 ? min( $threshold, self::CERTAIN ) : self::CERTAIN; } /** * Builds the transient key counting submissions. * * @param int $form_id Form being submitted. * @param string $ip Address of the visitor. */ private function rate_key( int $form_id, string $ip ): string { return 'formpilot_rate_' . md5( $ip . '|' . $form_id ); } /** * Builds the transient key holding the last submission time. * * Not per form: the throttle is about how fast one visitor is sending, and * hopping between forms should not be a way around it. * * @param string $ip Address of the visitor. */ private function interval_key( string $ip ): string { return 'formpilot_interval_' . md5( $ip ); } /** * Returns the address the counters are keyed on. * * `REMOTE_ADDR` is the only source trusted by default, and deliberately so: * `X-Forwarded-For` and friends are attacker-supplied unless a proxy in front * of the site overwrites them, so reading one would make the rate limit and * the blocklist trivially evadable. * * The mirror-image problem is real, though. Behind Cloudflare, a load balancer * or a caching proxy that does not rewrite `REMOTE_ADDR`, every visitor shares * one key: the default limit of five per minute becomes a site-wide lockout * and one blocklist entry blocks everybody. A site in that position supplies * the real address here — and only there, where the proxy is known, can that * header be trusted. */ private function client_ip(): string { $ip = Request::ip(); /** * Filters the client address used for rate limiting and blocklisting. * * Only override this where a trusted proxy is known to set the header being * read. Any header a client can send is a header a client can forge, and * reading one without that guarantee removes the rate limit entirely. * * @since 1.0.0 * * @param string $ip Address from `REMOTE_ADDR`. */ $filtered = (string) apply_filters( 'formpilot_client_ip', $ip ); $filtered = trim( $filtered ); if ( '' === $filtered || false === filter_var( $filtered, FILTER_VALIDATE_IP ) ) { return $ip; } return $filtered; } }