site_key() && '' !== $this->secret(); } /** * Checks the answer that came with the current request. * * @param string $ip Address of the visitor. * @param string|null $answer Optional. The answer, when the caller read it itself. Default read from the request. * * @return WP_Error|null The reason it failed, or null when it passed. */ public function verify( string $ip = '', ?string $answer = null ): ?WP_Error { $answer = trim( $answer ?? Request::text( $this->response_field() ) ); if ( '' === $answer ) { return new WP_Error( 'formpilot_captcha_missing', __( 'The spam check did not complete. Please reload the page and try again.', 'devplugins-custom-form-builder' ) ); } // The verification endpoint is a constant belonging to the provider, but // this is an outbound request all the same, so it uses the safe client. $response = wp_safe_remote_post( $this->verify_url(), array( 'timeout' => 10, 'redirection' => 0, 'reject_unsafe_urls' => true, 'body' => array( 'secret' => $this->secret(), 'response' => $answer, 'remoteip' => $ip, ), ) ); if ( is_wp_error( $response ) ) { /** * Filters what happens when the captcha service cannot be reached. * * Letting the submission through keeps the form usable during an * outage at somebody else's data centre, which is the right default for * a contact form. Return a WP_Error to fail closed instead. * * @since 1.0.0 * * @param WP_Error|null $error Rejection, or null to accept. * @param WP_Error $response Transport error. * @param CaptchaProvider $provider Provider that was being verified. */ $error = apply_filters( 'formpilot_captcha_unreachable', null, $response, $this ); return $error instanceof WP_Error ? $error : null; } $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || empty( $body['success'] ) ) { /** * Fires when the captcha service refuses a token. * * Carries `error-codes`, which names the actual reason — a wrong secret, * a token already used, a token minted for a different site. The visitor * is told something deliberately vague, so this is the only place that * reason is available to somebody debugging it. * * @since 1.0.0 * * @param array $codes Codes the service returned. * @param CaptchaProvider $provider Provider that was being verified. */ do_action( 'formpilot_captcha_rejected', array_map( 'strval', (array) ( is_array( $body ) ? ( $body['error-codes'] ?? array() ) : array() ) ), $this ); return new WP_Error( 'formpilot_captcha_failed', __( 'The spam check rejected this submission.', 'devplugins-custom-form-builder' ) ); } $origin = $this->check_hostname( $body ); if ( $origin instanceof WP_Error ) { return $origin; } return $this->inspect( $body ); } /** * Refuses a token that was solved on somebody else's page. * * A reCAPTCHA or Turnstile site key is public — it is in the markup of every * page carrying the widget. Without this check, an attacker puts the victim's * site key on a page of their own, has real visitors solve real challenges * there, and posts the resulting tokens here; the service verifies them * happily, because they are genuine. `hostname` is the field that says which * page they were solved on, and it was being discarded along with `action`, * `challenge_ts` and `error-codes`. * * Absent or empty means the provider did not supply it, which is not evidence * of anything and is not treated as a failure. * * @param array $body Decoded response. * * @return WP_Error|null The reason it failed, or null when it passed. */ protected function check_hostname( array $body ): ?WP_Error { $claimed = strtolower( trim( (string) ( $body['hostname'] ?? '' ) ) ); if ( '' === $claimed ) { return null; } /* * Several names legitimately describe one site, and getting this list too * short is worse than not checking at all: a mismatch scores CERTAIN, and * the threshold is clamped to CERTAIN, so one wrong entry here refuses * *every* submission on the site, silently and with a deliberately vague * message. `www.example.com` solving a widget for a `home_url()` of * `example.com` is the ordinary case, not an attack. */ /* * The request's own Host header is not on this list. It is whatever the * client sent, so trusting it let a token solved on any domain through by * posting it with `Host: that-domain` to a server that does not route by * name. Further domains belong in the filter below. */ $hosts = array( strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ), strtolower( (string) wp_parse_url( site_url(), PHP_URL_HOST ) ), ); foreach ( $hosts as $known ) { if ( '' === $known ) { continue; } // The www and bare forms of one host are the same site. $hosts[] = str_starts_with( $known, 'www.' ) ? substr( $known, 4 ) : 'www.' . $known; } /** * Filters the host names a solved captcha may have come from. * * Add to this where the form is embedded on another domain, or where the * site is reached through several of them. * * @since 1.0.0 * * @param array $hosts Accepted host names. * @param CaptchaProvider $provider Provider that was being verified. */ $allowed = (array) apply_filters( 'formpilot_captcha_hostnames', array_values( array_unique( $hosts ) ), $this ); $allowed = array_filter( array_map( 'strtolower', array_map( 'strval', $allowed ) ) ); /* * `testkey.google.com` is what the documented test keys answer, and a site * using those is testing, not defending. */ if ( array() === $allowed || in_array( $claimed, $allowed, true ) || 'testkey.google.com' === $claimed ) { return null; } /* * Announced, because this is the one captcha failure an administrator cannot * guess at. Every submission being refused with no visible reason looks like * a broken form, not like a host-name policy, and `formpilot_captcha_hostnames` is * useless to somebody who does not know they need it. */ do_action( 'formpilot_log', 'A captcha token was solved on a host this site does not recognise, so the submission was refused. Add the host through formpilot_captcha_hostnames if it is legitimate.', 'error', 'validation', array( 'claimed' => $claimed, 'expected' => implode( ', ', $allowed ), ) ); return new WP_Error( 'formpilot_captcha_failed', __( 'The spam check rejected this submission.', 'devplugins-custom-form-builder' ) ); } /** * Looks at a successful response for anything else worth refusing. * * @param array $body Decoded response. * * @return WP_Error|null The reason it failed, or null when it passed. */ protected function inspect( array $body ): ?WP_Error { return null; } }