*/ private static $state = array(); /** * Whether this request was a form post larger than the server accepts. PHP * then drops every field, including which form was sent. * * @var bool */ private static $oversized = false; /** * Handle a submission, if one is present. * * Runs on template_redirect so a redirect is still possible. */ public static function maybe_handle() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Anonymous public form submission. A WordPress nonce would break cached pages and is not an identity for visitors; the request key, schema version, spam checks and validator protect this endpoint. $form_id = isset( $_POST['af_form_id'] ) ? absint( $_POST['af_form_id'] ) : 0; if ( ! $form_id ) { self::$oversized = self::oversized_post(); self::detect_sent_state(); return; } $raw = isset( $_POST['af'] ) && is_array( $_POST['af'] ) ? map_deep( wp_unslash( $_POST['af'] ), 'sanitize_textarea_field' ) : array(); $request = isset( $_POST['af_request'] ) && is_string( $_POST['af_request'] ) ? sanitize_text_field( wp_unslash( $_POST['af_request'] ) ) : ''; $version = isset( $_POST['af_version'] ) && is_string( $_POST['af_version'] ) ? sanitize_text_field( wp_unslash( $_POST['af_version'] ) ) : ''; $is_ajax = ! empty( $_POST['af_ajax'] ); $instance = self::normalize_instance( wp_unslash( $_POST['af_instance'] ?? 1 ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalize_instance() accepts only bounded positive integers. // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Standard cache-plugin interoperability constant. } nocache_headers(); if ( ! $request && ! $is_ajax ) { // Cached HTML cannot safely carry an identity shared by anonymous visitors. // The uncached fallback supplies one on GET. A direct native POST gets a private review step. $values = strlen( wp_json_encode( $raw ) ) <= 100000 ? map_deep( $raw, 'sanitize_textarea_field' ) : array(); $schema = Schema::get( $form_id ); // List any problems now, so the visitor corrects them before sending once more. $field_errors = $schema ? Validator::validate( $schema, $raw )[1] : array(); self::$state[ $form_id ] = array( 'instance' => $instance, 'sent' => false, 'values' => $values, 'errors' => array( '_form' => $field_errors ? __( 'Your answers are ready to review. Correct the problems listed, then submit the form again. If you attached files, select them again.', 'accessible-forms-by-accessible-org' ) : __( 'Your answers are ready to review. Submit the form again to send this message. If you attached files, select them again.', 'accessible-forms-by-accessible-org' ), ) + $field_errors, 'notices' => self::notices( $form_id, $raw ), ); return; } $state = self::process( $form_id, $raw, $request, $version ); if ( empty( $state['sent'] ) ) { $state['notices'] = self::notices( $form_id, $raw ); } $state['instance'] = $instance; self::$state[ $form_id ] = $state; if ( $is_ajax ) { self::send_json( $form_id, ! empty( $state['sent'] ) ); } if ( empty( $state['sent'] ) ) { return; } $schema = Schema::get( $form_id ); $url = FormOptions::confirmation_url( (array) $schema ); if ( ! $url ) { $url = add_query_arg( array( 'af_sent' => $form_id, 'af_instance' => $instance, 'af_receipt' => self::receipt( $form_id, $instance ), ), self::current_url() ) . '#' . Renderer::instance_prefix( $form_id, $instance ) . '-status'; } wp_safe_redirect( $url, 303 ); exit(); } /** Testable submission service. Validation and storage precede external work. */ public static function process( $form_id, array $raw, $request = '', $version = '' ) { $schema = Schema::get( $form_id ); $fail = static function ( $message, $values = array(), $errors = array() ) { return array( 'errors' => $errors ?: array( '_form' => $message ), 'values' => $values, 'sent' => false, ); }; if ( strlen( wp_json_encode( $raw ) ) > 100000 ) { return $fail( __( 'Shorten your answers before submitting.', 'accessible-forms-by-accessible-org' ) ); } $display = map_deep( $raw, 'sanitize_textarea_field' ); if ( ! $schema ) { return $fail( __( 'This form is unavailable. Your unsent answers are kept below so you can copy them.', 'accessible-forms-by-accessible-org' ), $display ); } if ( $request && ! preg_match( '/^[a-zA-Z0-9-]{16,80}$/', $request ) ) { return $fail( __( 'Refresh the form before trying again.', 'accessible-forms-by-accessible-org' ), $display ); } $request_key = $request ? hash( 'sha256', get_current_blog_id() . ':' . $form_id . ':' . $request ) : null; // A generous burst budget bounds expensive upload/provider work while allowing corrections. if ( ! Locks::limit( 'attempt:' . $form_id . ':' . Spam::ip_hash(), 180, 10 * MINUTE_IN_SECONDS ) ) { return $fail( __( 'This network is sending many requests. Wait a few minutes and try again. Your answers are kept below.', 'accessible-forms-by-accessible-org' ), $display ); } // Only the same request waits during validation and external verification. $lease = Locks::acquire( 'submit-request:' . ( $request_key ?: wp_generate_uuid4() ), 120 ); if ( ! $lease ) { return $fail( __( 'This message is still being processed. Wait a moment and try again; your answers are kept.', 'accessible-forms-by-accessible-org' ), $display ); } $commit = false; try { // Add-ons check uploads before computing the full request identity. $inspected = (array) Extensions::filter( 'accessible_forms_inspect_submission', array(), $schema, $raw ); $file_errors = (array) ( $inspected['errors'] ?? array() ); $payload = $raw; ksort( $payload ); $file_hashes = (array) ( $inspected['hash'] ?? array() ); ksort( $file_hashes ); $payload_hash = hash( 'sha256', wp_json_encode( array( $payload, $file_hashes ) ) ); $existing = $request_key ? Entries::by_request( $request_key ) : null; if ( $existing ) { if ( ! hash_equals( (string) $existing['payload_hash'], $payload_hash ) ) { return $fail( __( 'An earlier version of this message was already received. Start a new form to send different answers.', 'accessible-forms-by-accessible-org' ), $display ); } return array( 'sent' => true, 'errors' => array(), 'values' => array(), 'duplicate' => true, ); } if ( $version && ! hash_equals( Revisions::version( $schema ), $version ) ) { return $fail( __( 'This form changed while you were completing it. Review the updated questions and submit again. Your previous answers are shown below.', 'accessible-forms-by-accessible-org' ), $display ) + array( 'previous_values' => $display ); } if ( FormOptions::closed( $form_id, $schema ) ) { return $fail( $schema['closed_message'], $display ); } $raw = array_merge( $raw, (array) ( $inspected['values'] ?? array() ) ); [$values, $errors, $codes] = Validator::validate( $schema, $raw ); $errors = array_merge( $errors, $file_errors ); if ( $errors ) { // Add-ons may count which questions needed correcting, without answers; bots excluded. if ( ! Spam::honeypot_filled() ) { Extensions::notify( 'accessible_forms_answers_checked', (int) $form_id, $schema, $codes, $file_errors ); } return $fail( '', $values, $errors ); } $spam = Spam::check( $form_id, $values ); if ( 'rate' === $spam['reason'] ) { return $fail( __( 'Too many messages have been sent from this network. Please try again in an hour. Your answers are kept below.', 'accessible-forms-by-accessible-org' ), $values ); } // Reasons that mark a message as spam outright; any other held message waits for review. $certain = (array) Extensions::filter( 'accessible_forms_spam_certain_reasons', array( 'honeypot', 'links' ) ); $status = ! $spam['block'] ? 'received' : ( in_array( $spam['reason'], $certain, true ) ? 'spam' : 'review' ); if ( 'received' === $status ) { Extensions::notify( 'accessible_forms_answers_checked', (int) $form_id, $schema, array(), array() ); } // Serialize only the local save and recheck capacity after other requests may have saved. for ( $attempt = 0; $attempt < 5 && ! $commit; ++$attempt ) { $commit = Locks::acquire( 'submit:' . $form_id, 60 ); if ( ! $commit ) { usleep( 50000 ); } } if ( ! $commit ) { return $fail( __( 'Another message is being saved. Wait a moment and try again; your answers are kept.', 'accessible-forms-by-accessible-org' ), $values ); } if ( FormOptions::closed( $form_id, $schema ) ) { return $fail( $schema['closed_message'], $values ); } $entry_id = Entries::insert( $form_id, $values, $status, $spam['reason'], array( 'request_key' => $request_key, 'payload_hash' => $payload_hash, ) ); if ( ! $entry_id ) { return $fail( __( 'Your message could not be saved. Please try again in a moment. Your answers have been kept below.', 'accessible-forms-by-accessible-org' ), $values ); } // Add-ons store what belongs with the entry, such as files. A failure undoes the entry. $stored = Extensions::filter( 'accessible_forms_entry_stored', true, $entry_id, (int) $form_id ); if ( true !== $stored ) { Entries::delete( array( $entry_id ) ); return $fail( is_wp_error( $stored ) ? $stored->get_error_message() : __( 'Your message could not be saved. Please try again; your answers are kept.', 'accessible-forms-by-accessible-org' ), $values ); } if ( 'received' === $status ) { Notifications::queue( $entry_id ); } else { Entries::set_mail_status( $entry_id, 'skipped' ); } Locks::release( $commit ); $commit = false; Extensions::notify( 'accessible_forms_entry_saved', $entry_id, $form_id, $status ); return array( 'sent' => true, 'errors' => array(), 'values' => array(), ); } finally { Locks::release( $commit ); Locks::release( $lease ); } } /** * Answers a failed request could not keep, such as chosen files, which * add-ons ask the visitor to give again. * * @param int $form_id Form post ID. * @param array $raw Submitted answers, which decide which fields are shown. * @return array Field name => array{summary: string, inline: string}. */ private static function notices( $form_id, array $raw ) { $schema = Schema::get( $form_id ); if ( ! $schema ) { return array(); } // A field that is not shown with these answers has nothing to give again. return array_intersect_key( (array) Extensions::filter( 'accessible_forms_field_notices', array(), $schema ), Validator::active( $schema, $raw ) ); } /** Bound an untrusted display instance; it never grants submission permission. */ public static function normalize_instance( $value ) { return filter_var( $value, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1, 'max_range' => 10000 ) ) ) ?: 1; } public static function receipt( $form_id, $instance = 1 ) { $time = (string) time(); $key = $form_id . ( $instance > 1 ? ':' . $instance : '' ); return $time . '.' . hash_hmac( 'sha256', $key . ':' . $time, wp_salt( 'nonce' ) ); } private static function valid_receipt( $form_id, $receipt, $instance = 1 ) { if ( ! is_string( $receipt ) || ! preg_match( '/^(\d+)\.([a-f0-9]{64})$/', $receipt, $parts ) || (int) $parts[1] < time() - HOUR_IN_SECONDS || (int) $parts[1] > time() + 30 ) { return false; } $key = $form_id . ( $instance > 1 ? ':' . $instance : '' ); return hash_equals( hash_hmac( 'sha256', $key . ':' . $parts[1], wp_salt( 'nonce' ) ), $parts[2] ); } /** * Recognise the post-redirect success state. */ private static function detect_sent_state() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only display state after a redirect; the receipt is an HMAC checked by valid_receipt(). $form_id = isset( $_GET['af_sent'] ) ? absint( $_GET['af_sent'] ) : 0; $receipt = isset( $_GET['af_receipt'] ) && is_string( $_GET['af_receipt'] ) ? sanitize_text_field( wp_unslash( $_GET['af_receipt'] ) ) : ''; $instance = self::normalize_instance( wp_unslash( $_GET['af_instance'] ?? 1 ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalize_instance() accepts only bounded positive integers. // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( $form_id > 0 && self::valid_receipt( $form_id, $receipt, $instance ) ) { self::$state[ $form_id ] = array( 'instance' => $instance, 'errors' => array(), 'values' => array(), 'sent' => true, ); } } /** * Render state for a form in this request. * * @param int $form_id Form post ID. * @param int|null $instance Restrict display state to this embed, or null to inspect the result. * @return array */ public static function state_for( $form_id, $instance = null ) { if ( ! isset( self::$state[ (int) $form_id ] ) && self::$oversized ) { // Nothing arrived, so say why instead of showing the empty form again. return array( 'errors' => array( '_form' => sprintf( /* translators: %s: largest upload the server accepts, such as 8 MB. */ __( 'Your message was not sent: the attached files were larger than this site accepts (%s in total). Choose smaller files, then fill in and send the form again.', 'accessible-forms-by-accessible-org' ), size_format( wp_max_upload_size() ) ), ), 'values' => array(), 'sent' => false, ); } $state = self::$state[ (int) $form_id ] ?? null; if ( $state && ( null === $instance || $instance === ( $state['instance'] ?? 1 ) ) ) { return $state; } return array( 'errors' => array(), 'values' => array(), 'sent' => false, ); } /** * Whether this is a POST whose body was too large for PHP to read. * * @return bool */ private static function oversized_post() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Only checks that nothing was received. $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; $length = isset( $_SERVER['CONTENT_LENGTH'] ) ? absint( $_SERVER['CONTENT_LENGTH'] ) : 0; $empty = empty( $_POST ) && empty( $_FILES ); // phpcs:enable WordPress.Security.NonceVerification.Missing $limit = wp_convert_hr_to_bytes( (string) ini_get( 'post_max_size' ) ); return 'post' === $method && $empty && $limit > 0 && $length > $limit; } /** * Respond to the progressive-enhancement path. * * @param int $form_id Form post ID. * @param bool $ok Whether the submission succeeded. */ private static function send_json( $form_id, $ok ) { $schema = Schema::get( $form_id ); $redirect = $ok ? FormOptions::confirmation_url( (array) $schema ) : ''; $state = self::state_for( $form_id ); $instance = $state['instance'] ?? 1; $prefix = Renderer::instance_prefix( $form_id, $instance ); wp_send_json( array( 'ok' => (bool) $ok, 'redirect' => $redirect ? wp_validate_redirect( $redirect, '' ) : '', 'html' => Renderer::render( $form_id, $state, false, null, $instance ), 'focus' => $prefix . ( $ok ? '-status' : '-summary' ), ) ); } /** * The current request path, with one-shot state parameters removed. * * Deliberately relative. Passing REQUEST_URI through home_url() would * duplicate the subdirectory on installs that live in one, because * REQUEST_URI already contains it. A relative action is valid HTML, is * accepted by wp_safe_redirect(), and sidesteps scheme and host mismatches * behind proxies. * * @return string */ public static function current_url() { // The path is kept as the browser sent it, percent-encoding included: a page // whose slug has non-ASCII letters is requested as /%C3%BC.../, and stripping // those sequences (as sanitize_text_field() does) would point the form at a // different page. wp_sanitize_redirect() keeps encoded bytes, encodes raw // UTF-8 and removes everything a URL cannot carry, including quotes and // encoded line breaks. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reduced to URL characters by wp_sanitize_redirect() and checked below. $path = isset( $_SERVER['REQUEST_URI'] ) && is_string( $_SERVER['REQUEST_URI'] ) ? wp_sanitize_redirect( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; // Reject anything that is not a site-relative path. if ( '' === $path || '/' !== $path[0] || str_starts_with( $path, '//' ) || strlen( $path ) > 2000 ) { $path = '/'; } return remove_query_arg( array( 'af_sent', 'af_receipt', 'af_instance' ), $path ); } /** Page identity on the native fallback is signed when WordPress renders its link. */ public static function page_signature( $form_id, $page_id ) { return hash_hmac( 'sha256', 'af-page:' . get_current_blog_id() . ':' . (int) $form_id . ':' . (int) $page_id, wp_salt( 'auth' ) ); } /** Resolve public page details through WordPress, including on the uncached form. */ public static function page_context( $form_id ) { $page = get_queried_object(); if ( doing_action( 'admin_post_accessible_forms_complete' ) || doing_action( 'admin_post_nopriv_accessible_forms_complete' ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- A public, read-only reference with its own site/form signature. $id = isset( $_GET['af_page'] ) && is_string( $_GET['af_page'] ) ? absint( $_GET['af_page'] ) : 0; $key = isset( $_GET['af_page_key'] ) && is_string( $_GET['af_page_key'] ) ? sanitize_text_field( wp_unslash( $_GET['af_page_key'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $page = $id && hash_equals( self::page_signature( $form_id, $id ), $key ) ? get_post( $id ) : null; } if ( ! $page instanceof \WP_Post || ! is_post_publicly_viewable( $page ) || '' !== $page->post_password ) { return array(); } $url = esc_url_raw( (string) get_permalink( $page ), array( 'http', 'https' ) ); if ( ! $url || strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ) !== strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) { return array(); } return array( 'id' => $page->ID, 'title' => sanitize_text_field( get_the_title( $page ) ), 'url' => $url ); } }