[ title, description ]. * Add-ons can add their own through `accessible_forms_spam_providers`. * * @return array */ public static function providers() { return (array) Extensions::filter( 'accessible_forms_spam_providers', array( 'altcha' => array( __( 'Automatic, private verification', 'accessible-forms-by-accessible-org' ), __( 'Recommended: the open-source ALTCHA check runs on your site. No account, API keys, image puzzles, or external verification service.', 'accessible-forms-by-accessible-org' ), ), 'basic' => array( __( 'Basic local protection', 'accessible-forms-by-accessible-org' ), __( 'Honeypot, rate limit and link limit. Works without JavaScript, but offers less resistance to automated spam.', 'accessible-forms-by-accessible-org' ), ), ) ); } /** * The active verification method. A stored method whose add-on is no longer * active falls back to the free, private check rather than to none at all. * * @return string */ public static function provider() { $stored = (string) ( Settings::get()['spam_provider'] ?? 'basic' ); return 'none' === $stored || isset( self::providers()[ $stored ] ) ? $stored : 'altcha'; } /** * Whether visitors reach this site in a secure context. Browsers run the * ALTCHA widget only over HTTPS or on localhost; elsewhere no token is * produced and every message is held for review. * * @return bool */ public static function secure_context() { $home = wp_parse_url( home_url() ); $host = strtolower( (string) ( $home['host'] ?? '' ) ); return 'https' === strtolower( (string) ( $home['scheme'] ?? '' ) ) || in_array( $host, array( 'localhost', '127.0.0.1', '[::1]', '::1' ), true ) || str_ends_with( $host, '.localhost' ); } /** * Whether the active verification method can run for this site's visitors. * * @return bool */ public static function provider_usable() { return 'altcha' !== self::provider() || self::secure_context(); } /** * Whether the hidden honeypot field was filled in, which people never do. * * @return bool */ public static function honeypot_filled() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Anonymous public form; only reads the hidden trap field. $honeypot = isset( $_POST['af_hp'] ) ? sanitize_text_field( wp_unslash( $_POST['af_hp'] ) ) : ''; return '' !== trim( $honeypot ); } /** * Evaluate a submission. * * @param int $form_id Form post ID. * @param array $values Validated values. * @return array{block: bool, reason: string} Whether to reject, and the layer that decided. */ public static function check( $form_id, array $values ) { // Bound stored spam as well as notification attempts. if ( self::over_rate_limit( $form_id ) ) { return self::result( true, 'rate' ); } // Honeypot. Bots fill hidden inputs; people never see them. if ( self::honeypot_filled() ) { return self::result( true, 'honeypot' ); } $settings = self::settings( $form_id ); $result = self::result( false, '' ); $links = 0; array_walk_recursive( $values, static function ( $value ) use ( &$links ) { if ( is_scalar( $value ) ) { $links += preg_match_all( '~(?:https?://|www\.)[^\s<>]+~i', (string) $value ); } } ); if ( (int) $settings['max_links'] > 0 && $links > (int) $settings['max_links'] ) { $result = self::result( true, 'links' ); } elseif ( 'altcha' === $settings['spam_provider'] ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Public, signed proof-of-work token; verified below. $token = isset( $_POST['af_altcha'] ) && is_string( $_POST['af_altcha'] ) ? sanitize_text_field( wp_unslash( $_POST['af_altcha'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing $reason = ProofOfWork::verify( $token, (int) $form_id ); $result = self::result( '' !== $reason, $reason ); } // Add-ons add their own content rules and verification methods. $filtered = Extensions::filter( 'accessible_forms_spam_check', $result, (int) $form_id, $values, $settings ); return is_array( $filtered ) && isset( $filtered['block'], $filtered['reason'] ) ? self::result( $filtered['block'], $filtered['reason'] ) : $result; } /** * Record a submission against the rate limit and report whether it exceeds it. * * @param int $form_id Form post ID. * @return bool */ private static function over_rate_limit( $form_id ) { $settings = self::settings( $form_id ); $limit = max( 0, (int) $settings['rate_limit'] ); if ( 0 === $limit ) { return false; } $key = 'accessible_forms_rl_' . substr( self::ip_hash(), 0, 32 ) . '_' . (int) $form_id; $lease = Locks::acquire( $key, 5 ); if ( ! $lease ) { return true; } try { $count = (int) get_transient( $key ); if ( $count >= $limit ) { return true; } set_transient( $key, $count + 1, HOUR_IN_SECONDS ); return false; } finally { Locks::release( $lease ); } } /** * Client IP address. * * Only REMOTE_ADDR is trusted. Sites behind a CDN should resolve the real * client address themselves via the filter below. * * @return string */ public static function ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ip = filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : ''; /** * Filter the client IP used for rate limiting and hashing. * * @param string $ip Resolved IP address. */ return (string) apply_filters( 'accessible_forms_client_ip', $ip ); } /** * Salted hash of the client IP. Raw addresses are never stored. * * @return string */ public static function ip_hash() { $salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'accessible-forms'; return hash( 'sha256', self::ip() . $salt ); } /** * Shape a check result. * * @param bool $block Whether to reject. * @param string $reason Layer identifier. * @return array{block: bool, reason: string} */ private static function result( $block, $reason ) { return array( 'block' => (bool) $block, 'reason' => (string) $reason, ); } /** * Human-readable labels for the reason codes. * * @return array */ public static function reason_labels() { return (array) Extensions::filter( 'accessible_forms_spam_reasons', array( 'honeypot' => __( 'Honeypot', 'accessible-forms-by-accessible-org' ), 'rate' => __( 'Rate limit', 'accessible-forms-by-accessible-org' ), 'links' => __( 'Too many links', 'accessible-forms-by-accessible-org' ), 'altcha_missing' => __( 'Automatic verification missing: review needed', 'accessible-forms-by-accessible-org' ), 'altcha_invalid' => __( 'Automatic verification failed', 'accessible-forms-by-accessible-org' ), 'altcha_expired' => __( 'Automatic verification expired: review needed', 'accessible-forms-by-accessible-org' ), 'altcha_reused' => __( 'Verification already used or could not be recorded', 'accessible-forms-by-accessible-org' ), ) ); } }