| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | namespace SRFM\Inc; |
| 10 | 10 | |
| 11 | +use SRFM\Inc\Compatibility\Multilingual\Multilingual_Manager; | |
| 11 | 12 | use SRFM\Inc\Database\Tables\Entries; |
| 12 | 13 | use SRFM\Inc\Email\Email_Template; |
| 13 | 14 | use SRFM\Inc\Lib\Browser\Browser; |
| 14 | 15 | use SRFM\Inc\Traits\Get_Instance; |
| @@ -52,8 +53,25 @@ | ||
| 52 | 53 | * @since 0.0.1 |
| 53 | 54 | */ |
| 54 | 55 | public function __construct() { |
| 55 | 56 | add_action( 'rest_api_init', [ $this, 'register_custom_endpoint' ] ); |
| 57 | + // One submission getting through retires the failure notice. srfm_form_submit | |
| 58 | + // fires only on the success path. | |
| 59 | + add_action( 'srfm_form_submit', [ Client_Logger::class, 'reset_fault_streak' ] ); | |
| 60 | + | |
| 61 | + /** | |
| 62 | + * Fired when an integration fails to receive a submission. | |
| 63 | + * | |
| 64 | + * Pro's webhooks and native integrations write their outcome to the entry's | |
| 65 | + * own log, which nobody reads until a ticket is already open. Firing this | |
| 66 | + * as well surfaces it on the dashboard. | |
| 67 | + * | |
| 68 | + * @since 2.12.6 | |
| 69 | + * | |
| 70 | + * @param int $form_id Form the submission belongs to. | |
| 71 | + * @param string $reason Short description of what failed. | |
| 72 | + */ | |
| 73 | + add_action( 'srfm_integration_failed', [ $this, 'record_integration_failure' ], 10, 2 ); | |
| 56 | 74 | add_action( 'wp_ajax_validation_ajax_action', [ $this, 'field_unique_validation' ] ); |
| 57 | 75 | add_action( 'wp_ajax_nopriv_validation_ajax_action', [ $this, 'field_unique_validation' ] ); |
| 58 | 76 | // for quick action bar. |
| 59 | 77 | add_action( 'wp_ajax_srfm_global_update_allowed_block', [ $this, 'srfm_global_update_allowed_block' ] ); |
| @@ -75,11 +93,141 @@ | ||
| 75 | 93 | 'callback' => [ $this, 'handle_form_submission' ], |
| 76 | 94 | 'permission_callback' => [ $this, 'submit_form_permissions_check' ], |
| 77 | 95 | ] |
| 78 | 96 | ); |
| 97 | + | |
| 98 | + register_rest_route( | |
| 99 | + $this->namespace, | |
| 100 | + '/log-client-error', | |
| 101 | + [ | |
| 102 | + 'methods' => WP_REST_Server::CREATABLE, | |
| 103 | + 'callback' => [ $this, 'handle_client_error_log' ], | |
| 104 | + 'permission_callback' => [ $this, 'client_error_log_permissions_check' ], | |
| 105 | + ] | |
| 106 | + ); | |
| 79 | 107 | } |
| 80 | 108 | |
| 81 | 109 | /** |
| 110 | + * Record an integration failure against the form it happened on. | |
| 111 | + * | |
| 112 | + * Hooked - srfm_integration_failed. | |
| 113 | + * | |
| 114 | + * @param int $form_id Form the submission belongs to. | |
| 115 | + * @param string $reason Short description of what failed. | |
| 116 | + * @since 2.12.6 | |
| 117 | + * @return void | |
| 118 | + */ | |
| 119 | + public function record_integration_failure( $form_id = 0, $reason = '' ) { | |
| 120 | + $form_id = absint( $form_id ); | |
| 121 | + | |
| 122 | + Client_Logger::append( | |
| 123 | + Client_Logger::sanitize_entry( | |
| 124 | + [ | |
| 125 | + 'type' => 'message', | |
| 126 | + 'form_id' => $form_id, | |
| 127 | + 'form_title' => $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : '', | |
| 128 | + 'message' => 'Integration failed. ' . Helper::get_string_value( $reason ), | |
| 129 | + ] | |
| 130 | + ) | |
| 131 | + ); | |
| 132 | + | |
| 133 | + Client_Logger::record_failure( | |
| 134 | + 'integration', | |
| 135 | + $form_id, | |
| 136 | + $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : '' | |
| 137 | + ); | |
| 138 | + } | |
| 139 | + | |
| 140 | + /** | |
| 141 | + * Gate the client error log route. | |
| 142 | + * | |
| 143 | + * Order matters. The enabled check runs first and returns 404 rather than 403, | |
| 144 | + * because it is the only thing that actually stops logging: the frontend flag | |
| 145 | + * is baked into cached HTML and can be a full cache TTL out of date, so | |
| 146 | + * switching the setting off does not stop already-cached pages from posting. | |
| 147 | + * | |
| 148 | + * The submit token is then required for consistency with /submit-form, but be | |
| 149 | + * clear about what it buys. It is per-form, not per-visitor, valid for up to | |
| 150 | + * 48 hours, and readable from one GET of any public page carrying the form. It | |
| 151 | + * filters undirected scanners and costs nothing; it is not visitor | |
| 152 | + * authentication. The controls that carry real weight here are the fixed | |
| 153 | + * payload schema in Client_Logger::sanitize_entry() and the rate limit below. | |
| 154 | + * | |
| 155 | + * @param \WP_REST_Request $request Incoming REST request. | |
| 156 | + * @since 2.12.6 | |
| 157 | + * @return WP_Error|bool | |
| 158 | + */ | |
| 159 | + public function client_error_log_permissions_check( $request ) { | |
| 160 | + if ( ! Client_Logger::is_enabled() ) { | |
| 161 | + return new WP_Error( | |
| 162 | + 'srfm_rest_no_route', | |
| 163 | + __( 'Not found.', 'sureforms' ), | |
| 164 | + [ 'status' => 404 ] | |
| 165 | + ); | |
| 166 | + } | |
| 167 | + | |
| 168 | + $token = Helper::get_string_value( $request->get_header( 'X-WP-Submit-Token' ) ); | |
| 169 | + $form_id = absint( $request->get_param( 'form_id' ) ); | |
| 170 | + | |
| 171 | + if ( ! Submit_Token::verify( $token, $form_id ) ) { | |
| 172 | + return new WP_Error( | |
| 173 | + 'srfm_token_invalid', | |
| 174 | + __( 'Security verification failed.', 'sureforms' ), | |
| 175 | + [ 'status' => 403 ] | |
| 176 | + ); | |
| 177 | + } | |
| 178 | + | |
| 179 | + return true; | |
| 180 | + } | |
| 181 | + | |
| 182 | + /** | |
| 183 | + * Record one client-reported form submission failure. | |
| 184 | + * | |
| 185 | + * Always answers 204, whether or not a line was written. The browser has | |
| 186 | + * nothing useful to do with a failure here, and a response that distinguishes | |
| 187 | + * "written" from "dropped" would report back whether logging is on, whether | |
| 188 | + * the log is full, and whether the caller is being throttled. | |
| 189 | + * | |
| 190 | + * @param \WP_REST_Request $request Incoming REST request. | |
| 191 | + * @since 2.12.6 | |
| 192 | + * @return \WP_REST_Response | |
| 193 | + */ | |
| 194 | + public function handle_client_error_log( $request ) { | |
| 195 | + $response = new \WP_REST_Response( null, 204 ); | |
| 196 | + | |
| 197 | + $form_id = absint( $request->get_param( 'form_id' ) ); | |
| 198 | + | |
| 199 | + if ( $this->is_rate_limited( 'srfm_cl_', $form_id ) ) { | |
| 200 | + return $response; | |
| 201 | + } | |
| 202 | + | |
| 203 | + $entries = $request->get_param( 'entries' ); | |
| 204 | + | |
| 205 | + if ( ! is_array( $entries ) ) { | |
| 206 | + return $response; | |
| 207 | + } | |
| 208 | + | |
| 209 | + // Cap the batch as well as each entry: a single request must not be able to | |
| 210 | + // consume the whole file and evict the failure someone is trying to capture. | |
| 211 | + foreach ( array_slice( $entries, 0, 10 ) as $raw ) { | |
| 212 | + if ( ! is_array( $raw ) ) { | |
| 213 | + continue; | |
| 214 | + } | |
| 215 | + | |
| 216 | + $raw['form_id'] = $form_id; | |
| 217 | + | |
| 218 | + // Resolved here rather than sent by the browser: the title is what makes | |
| 219 | + // a log line identifiable at a glance, and taking it from the request | |
| 220 | + // would let a caller label an entry as any form it liked. | |
| 221 | + $raw['form_title'] = $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : ''; | |
| 222 | + | |
| 223 | + Client_Logger::append( Client_Logger::sanitize_entry( $raw ) ); | |
| 224 | + } | |
| 225 | + | |
| 226 | + return $response; | |
| 227 | + } | |
| 228 | + | |
| 229 | + /** | |
| 82 | 230 | * Check whether a given request has permission to submit the form. |
| 83 | 231 | * |
| 84 | 232 | * Validates the HMAC-based submission token embedded in the page at render |
| 85 | 233 | * time. Tokens remain valid for up to 48 hours (four 12-hour windows), so |
| @@ -281,8 +429,13 @@ | ||
| 281 | 429 | ] |
| 282 | 430 | ); |
| 283 | 431 | } |
| 284 | 432 | |
| 433 | + // Drop submitted keys this form does not define before anything consumes them. | |
| 434 | + // Runs on SUBMISSION only, so historical entries whose keys no longer match a | |
| 435 | + // rebuilt form (see #2665) stay fully readable on the read/export paths. | |
| 436 | + $form_data = Field_Validation::strip_unknown_field_keys( $form_data, $current_form_id ); | |
| 437 | + | |
| 285 | 438 | $validated_form_data = Field_Validation::validate_form_data( $form_data, $current_form_id ); |
| 286 | 439 | |
| 287 | 440 | if ( ! empty( $validated_form_data ) ) { |
| 288 | 441 | // Get the first error message to display as the main message. |
| @@ -583,34 +736,13 @@ | ||
| 583 | 736 | $browser = new Browser(); |
| 584 | 737 | $browser_name = sanitize_text_field( $browser->getBrowser() ); |
| 585 | 738 | $device_name = sanitize_text_field( $browser->getPlatform() ); |
| 586 | 739 | |
| 587 | - // Capture submission page URL server-side from the Referer header. The | |
| 588 | - // stored value is rebuilt from parsed components so a non-browser client | |
| 589 | - // cannot inject attacker-controlled bits a real browser would never send | |
| 590 | - // (userinfo, fragment) or mismatch the legitimate origin's port. Anything | |
| 591 | - // not same-origin http(s) is stored as empty. | |
| 592 | - $referer = isset( $_SERVER['HTTP_REFERER'] ) | |
| 593 | - ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) | |
| 594 | - : ''; | |
| 595 | - if ( '' !== $referer && strlen( $referer ) <= 2048 ) { | |
| 596 | - $parts = wp_parse_url( $referer ); | |
| 597 | - $home_parts = wp_parse_url( home_url() ); | |
| 598 | - if ( | |
| 599 | - is_array( $parts ) | |
| 600 | - && is_array( $home_parts ) | |
| 601 | - && isset( $parts['scheme'], $parts['host'], $home_parts['host'] ) | |
| 602 | - && in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true ) | |
| 603 | - && 0 === strcasecmp( (string) $parts['host'], (string) $home_parts['host'] ) | |
| 604 | - && ( $parts['port'] ?? null ) === ( $home_parts['port'] ?? null ) | |
| 605 | - ) { | |
| 606 | - $clean = $parts['scheme'] . '://' . $parts['host'] | |
| 607 | - . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) | |
| 608 | - . ( $parts['path'] ?? '' ) | |
| 609 | - . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ); | |
| 610 | - $submission_url = esc_url_raw( $clean, [ 'http', 'https' ] ); | |
| 611 | - } | |
| 612 | - } | |
| 740 | + // Capture submission page URL server-side from the Referer header. | |
| 741 | + // esc_url_raw() (not sanitize_text_field) preserves percent-encoded | |
| 742 | + // non-ASCII slugs; normalize_submission_url() then validates same-origin. | |
| 743 | + $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; | |
| 744 | + $submission_url = $this->normalize_submission_url( $referer ); | |
| 613 | 745 | } |
| 614 | 746 | |
| 615 | 747 | $form_markup = get_the_content( null, false, Helper::get_integer_value( $form_data['form-id'] ) ); |
| 616 | 748 | $pattern = '/"label":"(.*?)"/'; |
| @@ -620,17 +752,38 @@ | ||
| 620 | 752 | 'browser_name' => $browser_name, |
| 621 | 753 | 'device_name' => $device_name, |
| 622 | 754 | 'submission_url' => $submission_url, |
| 623 | 755 | ]; |
| 624 | - $entries_data = [ | |
| 756 | + // Resolve the language the visitor saw at form-render time (captured in a | |
| 757 | + // hidden srfm-form-language input) so the confirmation message and email | |
| 758 | + // notifications below can be rendered in it — WPML's language detection on | |
| 759 | + // the REST submit endpoint frequently falls back to the default. This value | |
| 760 | + // is used only to switch_language() at submit time; it is not persisted. The | |
| 761 | + // hidden input is client-supplied, so: | |
| 762 | + // 1. Validate shape with a BCP-47 regex. | |
| 763 | + // 2. Cross-check against the active multilingual provider's known | |
| 764 | + // languages (active + default) so a crafted request can't switch rendering | |
| 765 | + // to a code the site doesn't support. | |
| 766 | + // 3. Fall back to the provider's current_language() on either failure. | |
| 767 | + $entry_language = Multilingual_Manager::get_instance()->provider()->current_language(); | |
| 768 | + $submitted_language = isset( $form_data['srfm-form-language'] ) ? sanitize_text_field( Helper::get_string_value( $form_data['srfm-form-language'] ) ) : ''; | |
| 769 | + if ( '' !== $submitted_language && preg_match( '/^[a-z]{2,3}([_-][A-Za-z0-9]{2,8})?$/', $submitted_language ) === 1 && $this->is_known_language( $submitted_language ) ) { | |
| 770 | + $entry_language = $submitted_language; | |
| 771 | + } | |
| 772 | + | |
| 773 | + $entries_data = [ | |
| 625 | 774 | 'form_id' => $id, |
| 626 | 775 | 'form_data' => $submission_data, |
| 627 | 776 | 'submission_info' => $submission_info, |
| 628 | 777 | 'created_at' => current_time( 'mysql' ), |
| 629 | 778 | ]; |
| 630 | - if ( is_user_logged_in() ) { | |
| 631 | - // If user is logged in then save their user id. | |
| 632 | - $entries_data['user_id'] = get_current_user_id(); | |
| 779 | + // Resolved via Helper rather than get_current_user_id() directly: this runs on | |
| 780 | + // a REST request that carries no nonce, which core de-authenticates before | |
| 781 | + // dispatch, so the plain call returns 0 even for a signed-in submitter and the | |
| 782 | + // entry would lose its attribution. Returns 0 when genuinely anonymous. | |
| 783 | + $submitting_user_id = Helper::get_submitting_user_id(); | |
| 784 | + if ( $submitting_user_id ) { | |
| 785 | + $entries_data['user_id'] = $submitting_user_id; | |
| 633 | 786 | } |
| 634 | 787 | |
| 635 | 788 | $entries_data = apply_filters( |
| 636 | 789 | 'srfm_before_entry_data', |
| @@ -645,8 +798,19 @@ | ||
| 645 | 798 | if ( $entry_id ) { |
| 646 | 799 | // Inject entry_id so {entry_id} smart tag resolves in confirmation message, redirect URL, email notifications, and downstream integrations. |
| 647 | 800 | $form_data['entry_id'] = intval( $entry_id ); |
| 648 | 801 | |
| 802 | + // Switch the multilingual provider to the entry's language so the | |
| 803 | + // confirmation message, redirect URL, and email notifications render | |
| 804 | + // in the language the visitor saw at submit time. The REST submit | |
| 805 | + // endpoint doesn't carry the ?lang= URL parameter, so without this | |
| 806 | + // switch the provider would return strings in its default language | |
| 807 | + // even though the visitor filled the form in another language. | |
| 808 | + $provider = Multilingual_Manager::get_instance()->provider(); | |
| 809 | + if ( $provider->is_active() && '' !== $entry_language ) { | |
| 810 | + $provider->switch_language( $entry_language ); | |
| 811 | + } | |
| 812 | + | |
| 649 | 813 | // Send email after entry creation so {entry_id} is available when smart tags are processed. |
| 650 | 814 | $send_email = $this->send_email( $id, $submission_data, $form_data ); |
| 651 | 815 | if ( $send_email ) { |
| 652 | 816 | $emails = $send_email['emails']; |
| @@ -652,9 +816,16 @@ | ||
| 652 | 816 | $emails = $send_email['emails']; |
| 653 | 817 | } |
| 654 | 818 | |
| 655 | 819 | $confirmation_message = Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ); |
| 820 | + $redirect_url = Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ); | |
| 656 | 821 | |
| 822 | + if ( $provider->is_active() && '' !== $entry_language ) { | |
| 823 | + $provider->restore_language(); | |
| 824 | + } | |
| 825 | + | |
| 826 | + $after_submit_nonce = wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) ); | |
| 827 | + | |
| 657 | 828 | $response = [ |
| 658 | 829 | 'success' => true, |
| 659 | 830 | 'message' => $confirmation_message, |
| 660 | 831 | 'data' => [ |
| @@ -660,11 +831,22 @@ | ||
| 660 | 831 | 'data' => [ |
| 661 | 832 | 'name' => $name, |
| 662 | 833 | 'submission_id' => $entry_id, |
| 663 | 834 | 'after_submit' => true, |
| 664 | - 'after_submit_nonce' => wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) ), | |
| 835 | + 'after_submit_nonce' => $after_submit_nonce, | |
| 836 | + // Built here rather than assembled in JS. rest_url() already knows | |
| 837 | + // whether the route is a path or a `?rest_route=` query arg, and | |
| 838 | + // add_query_arg() knows whether the nonce needs `?` or `&` — the | |
| 839 | + // client has no way to get either right without reimplementing | |
| 840 | + // both, and concatenating produced a URL that did not route at all | |
| 841 | + // on plain-permalink sites. | |
| 842 | + 'after_submit_url' => add_query_arg( | |
| 843 | + 'after_submit_nonce', | |
| 844 | + $after_submit_nonce, | |
| 845 | + rest_url( 'sureforms/v1/after-submission/' . Helper::get_integer_value( $entry_id ) ) | |
| 846 | + ), | |
| 665 | 847 | ], |
| 666 | - 'redirect_url' => Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ), | |
| 848 | + 'redirect_url' => $redirect_url, | |
| 667 | 849 | ]; |
| 668 | 850 | |
| 669 | 851 | $form_submit_response = apply_filters( |
| 670 | 852 | 'srfm_form_submit_response', |
| @@ -847,8 +1029,14 @@ | ||
| 847 | 1029 | */ |
| 848 | 1030 | public static function send_email( $id, $submission_data, $form_data = [] ) { |
| 849 | 1031 | $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' ); |
| 850 | 1032 | $is_mail_sent = false; |
| 1033 | + // Any recipient failing counts as a failure for the whole submission, so | |
| 1034 | + // these are set inside the loop and only read after it. | |
| 1035 | + $notification_failed = false; | |
| 1036 | + // Whether any recipient's "success" came from the mail() fallback, which | |
| 1037 | + // reports true for a message the local MTA accepted and will bounce. | |
| 1038 | + $used_mail_fallback = false; | |
| 851 | 1039 | $emails = []; |
| 852 | 1040 | |
| 853 | 1041 | // Filter to determine whether the email notification should be sent. |
| 854 | 1042 | $email_notification = apply_filters( 'srfm_email_notification_should_send', $email_notification, $submission_data, $form_data ); |
| @@ -917,11 +1105,22 @@ | ||
| 917 | 1105 | $sent = wp_mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] ); |
| 918 | 1106 | if ( ! $sent ) { |
| 919 | 1107 | // Fallback to default PHP mail if for some reasons wp_mail fails. |
| 920 | 1108 | $sent = mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] ); |
| 1109 | + | |
| 1110 | + if ( $sent ) { | |
| 1111 | + // Accepted by the local MTA, not delivered. Good | |
| 1112 | + // enough to avoid recording a fault, not good | |
| 1113 | + // enough to retire one. | |
| 1114 | + $used_mail_fallback = true; | |
| 1115 | + } | |
| 921 | 1116 | } |
| 922 | 1117 | $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports. |
| 923 | 1118 | |
| 1119 | + if ( true !== $sent ) { | |
| 1120 | + $notification_failed = true; | |
| 1121 | + } | |
| 1122 | + | |
| 924 | 1123 | if ( is_int( $log_key ) ) { |
| 925 | 1124 | if ( true === $sent ) { |
| 926 | 1125 | $entries_db_instance->update_log( |
| 927 | 1126 | $log_key, |
| @@ -954,8 +1153,32 @@ | ||
| 954 | 1153 | ), |
| 955 | 1154 | ] |
| 956 | 1155 | ); |
| 957 | 1156 | |
| 1157 | + // Also record it in the debug log. The submission itself | |
| 1158 | + // succeeded, so the visitor saw nothing wrong and nobody | |
| 1159 | + // looks at the entry's own log until a ticket is already | |
| 1160 | + // open. The recipient address is not included -- the log | |
| 1161 | + // is downloadable and must not carry personal data. | |
| 1162 | + Client_Logger::append( | |
| 1163 | + Client_Logger::sanitize_entry( | |
| 1164 | + [ | |
| 1165 | + 'type' => 'message', | |
| 1166 | + 'form_id' => intval( $id ), | |
| 1167 | + 'form_title' => Helper::get_string_value( get_the_title( intval( $id ) ) ), | |
| 1168 | + 'message' => 'Email notification failed to send. ' . $reason, | |
| 1169 | + ] | |
| 1170 | + ) | |
| 1171 | + ); | |
| 1172 | + | |
| 1173 | + // Its own category: the entry saved, so this is not a | |
| 1174 | + // submission failure. The site owner is simply not being | |
| 1175 | + // told about entries they did receive. | |
| 1176 | + Client_Logger::record_failure( | |
| 1177 | + 'notification', | |
| 1178 | + intval( $id ), | |
| 1179 | + Helper::get_string_value( get_the_title( intval( $id ) ) ) | |
| 1180 | + ); | |
| 958 | 1181 | } |
| 959 | 1182 | } |
| 960 | 1183 | |
| 961 | 1184 | // Trigger an action after the email is sent, allowing additional processing or logging. |
| @@ -976,8 +1199,39 @@ | ||
| 976 | 1199 | if ( empty( $emails ) ) { |
| 977 | 1200 | $entries_db_instance->reset_logs(); |
| 978 | 1201 | $entries_db_instance->add_log( __( 'No emails were sent.', 'sureforms' ) ); |
| 979 | 1202 | } |
| 1203 | + | |
| 1204 | + // The notification fault clears when notifications work again. Nothing | |
| 1205 | + // else retired it: Client_Logger::clear_category() had a single caller | |
| 1206 | + // hardcoded to 'submission', and the notice is deliberately not | |
| 1207 | + // dismissible, so a site that had fixed its SMTP kept an undismissable | |
| 1208 | + // banner on every admin page until somebody opened a support ticket. | |
| 1209 | + // Held until the loop is done because one recipient succeeding while | |
| 1210 | + // another fails is still a failure. | |
| 1211 | + // | |
| 1212 | + // Scoped to the form the fault was recorded against. send_email() runs | |
| 1213 | + // on the public submit path and the counter is per category, not per | |
| 1214 | + // form, so without this an anonymous submission of a working form | |
| 1215 | + // wipes a different form's standing fault -- once per admin page load, | |
| 1216 | + // by anyone. The notice names a form, so the granularity is visible | |
| 1217 | + // now that this clears as well as records. | |
| 1218 | + // | |
| 1219 | + // wp_mail() only. The mail() fallback above returns true when the local | |
| 1220 | + // MTA merely accepts a message it will later bounce, which is the | |
| 1221 | + // broken configuration rather than the fixed one. | |
| 1222 | + // | |
| 1223 | + // is_int( $log_key ) mirrors the recording guard: record_failure() sits | |
| 1224 | + // inside it, so without it an install where add_log() returns a | |
| 1225 | + // non-int would never record a notification fault but would still | |
| 1226 | + // clear one. | |
| 1227 | + $open_failures = Client_Logger::get_failures(); | |
| 1228 | + | |
| 1229 | + if ( ! empty( $emails ) && ! $notification_failed && is_int( $log_key ) | |
| 1230 | + && ! $used_mail_fallback | |
| 1231 | + && intval( $id ) === Helper::get_integer_value( $open_failures['notification']['form_id'] ?? 0 ) ) { | |
| 1232 | + Client_Logger::clear_category( 'notification' ); | |
| 1233 | + } | |
| 980 | 1234 | } |
| 981 | 1235 | |
| 982 | 1236 | return [ |
| 983 | 1237 | 'success' => $is_mail_sent, |
| @@ -1016,8 +1270,15 @@ | ||
| 1016 | 1270 | if ( $this->is_unique_validation_rate_limited( $form_id ) ) { |
| 1017 | 1271 | wp_send_json_error( [ 'error' => __( 'Too many requests. Please try again shortly.', 'sureforms' ) ], 429 ); |
| 1018 | 1272 | } |
| 1019 | 1273 | |
| 1274 | + // SECURITY INVARIANT — only the fields the form itself marks unique may be | |
| 1275 | + // probed through this unauthenticated handler. The allowlist is what keeps the | |
| 1276 | + // lookup scoped to values a site owner opted into checking, rather than to | |
| 1277 | + // stored submission data generally. A form with no unique fields therefore | |
| 1278 | + // matches nothing and always answers with an empty set. | |
| 1279 | + $unique_block_ids = $this->get_unique_field_block_ids( $form_id ); | |
| 1280 | + | |
| 1020 | 1281 | // Extract and validate field values from POST data. |
| 1021 | 1282 | $skip_keys = [ 'action', 'token', 'id' ]; |
| 1022 | 1283 | $duplicates = []; |
| 1023 | 1284 | |
| @@ -1037,8 +1298,15 @@ | ||
| 1037 | 1298 | if ( '' === $value ) { |
| 1038 | 1299 | continue; |
| 1039 | 1300 | } |
| 1040 | 1301 | |
| 1302 | + // The key must resolve to a block this form configured as unique. | |
| 1303 | + $block_id = Helper::get_block_id_from_key( $field_key ); | |
| 1304 | + | |
| 1305 | + if ( '' === $block_id || ! isset( $unique_block_ids[ $block_id ] ) ) { | |
| 1306 | + continue; | |
| 1307 | + } | |
| 1308 | + | |
| 1041 | 1309 | // Single optimized query per field instead of loading all entries. |
| 1042 | 1310 | if ( Entries::has_duplicate_field_value( $form_id, $field_key, $value ) ) { |
| 1043 | 1311 | $duplicates[] = [ $field_key => 'not unique' ]; |
| 1044 | 1312 | } |
| @@ -1217,8 +1485,226 @@ | ||
| 1217 | 1485 | ]; |
| 1218 | 1486 | } |
| 1219 | 1487 | |
| 1220 | 1488 | /** |
| 1489 | + * Sanitise and validate a Referer into a storable submission URL. | |
| 1490 | + * | |
| 1491 | + * The value is rebuilt from parsed components so a non-browser client cannot | |
| 1492 | + * inject bits a real browser would never send (userinfo, fragment) or mismatch | |
| 1493 | + * the legitimate origin's port. Anything that is not a same-origin http(s) URL, | |
| 1494 | + * or is longer than 2048 chars, is rejected and returns an empty string. | |
| 1495 | + * | |
| 1496 | + * Uses esc_url_raw() rather than sanitize_text_field(): the latter strips | |
| 1497 | + * percent-encoded octets (`%E0%A4...`), which mangles the URLs of translated | |
| 1498 | + * pages whose slugs contain non-ASCII characters (e.g. WPML Hindi/Arabic | |
| 1499 | + * permalinks) down to bare hyphens. esc_url_raw() preserves the percent-encoding | |
| 1500 | + * so the recorded submission URL stays accurate. | |
| 1501 | + * | |
| 1502 | + * @param string $referer Raw (unslashed) Referer header value. | |
| 1503 | + * @since 2.11.0 | |
| 1504 | + * @return string Same-origin http(s) URL, or empty string when invalid. | |
| 1505 | + */ | |
| 1506 | + protected function normalize_submission_url( string $referer ): string { | |
| 1507 | + $referer = esc_url_raw( $referer ); | |
| 1508 | + | |
| 1509 | + if ( '' === $referer || strlen( $referer ) > 2048 ) { | |
| 1510 | + return ''; | |
| 1511 | + } | |
| 1512 | + | |
| 1513 | + $parts = wp_parse_url( $referer ); | |
| 1514 | + $home_parts = wp_parse_url( home_url() ); | |
| 1515 | + | |
| 1516 | + if ( | |
| 1517 | + ! is_array( $parts ) | |
| 1518 | + || ! is_array( $home_parts ) | |
| 1519 | + || ! isset( $parts['scheme'], $parts['host'], $home_parts['host'] ) | |
| 1520 | + || ! in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true ) | |
| 1521 | + || 0 !== strcasecmp( (string) $parts['host'], (string) $home_parts['host'] ) | |
| 1522 | + || ( $parts['port'] ?? null ) !== ( $home_parts['port'] ?? null ) | |
| 1523 | + ) { | |
| 1524 | + return ''; | |
| 1525 | + } | |
| 1526 | + | |
| 1527 | + $clean = $parts['scheme'] . '://' . $parts['host'] | |
| 1528 | + . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) | |
| 1529 | + . ( $parts['path'] ?? '' ) | |
| 1530 | + . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ); | |
| 1531 | + | |
| 1532 | + return esc_url_raw( $clean, [ 'http', 'https' ] ); | |
| 1533 | + } | |
| 1534 | + | |
| 1535 | + /** | |
| 1536 | + * Check whether the given language code is known to the active multilingual | |
| 1537 | + * provider (i.e. in its active-languages set or matches the default language). | |
| 1538 | + * | |
| 1539 | + * Used to reject crafted srfm-form-language hidden-input values that pass | |
| 1540 | + * the BCP-47 shape regex but reference languages the site doesn't actually | |
| 1541 | + * support. | |
| 1542 | + * | |
| 1543 | + * @param string $language Language code to check (e.g. 'hi', 'de-AT'). | |
| 1544 | + * @since 2.11.0 | |
| 1545 | + * @return bool True when the code is known, false otherwise. | |
| 1546 | + */ | |
| 1547 | + protected function is_known_language( string $language ): bool { | |
| 1548 | + if ( '' === $language ) { | |
| 1549 | + return false; | |
| 1550 | + } | |
| 1551 | + | |
| 1552 | + $provider = Multilingual_Manager::get_instance()->provider(); | |
| 1553 | + | |
| 1554 | + // When no provider is active there's no authoritative set to check | |
| 1555 | + // against. Accept whatever the visitor sent (shape-validated) so the | |
| 1556 | + // column still reflects the visitor's intent on non-WPML sites. | |
| 1557 | + if ( ! $provider->is_active() ) { | |
| 1558 | + return true; | |
| 1559 | + } | |
| 1560 | + | |
| 1561 | + // Default language is always considered known. | |
| 1562 | + if ( $language === $provider->default_language() ) { | |
| 1563 | + return true; | |
| 1564 | + } | |
| 1565 | + | |
| 1566 | + // Use WPML's filter when available — works regardless of which | |
| 1567 | + // multilingual plugin is the active provider, as Polylang implements | |
| 1568 | + // the same filter for compatibility. | |
| 1569 | + $active = apply_filters( 'wpml_active_languages', null, 'skip_missing=0' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own filter; the name must match WPML/Polylang exactly to integrate. | |
| 1570 | + if ( is_array( $active ) && ! empty( $active ) ) { | |
| 1571 | + return array_key_exists( $language, $active ); | |
| 1572 | + } | |
| 1573 | + | |
| 1574 | + // A provider IS active but its language list is unavailable. Rather than | |
| 1575 | + // fail open and trust an arbitrary client-supplied code, accept it only when | |
| 1576 | + // it matches the server-resolved current language. The caller already | |
| 1577 | + // defaults $entry_language to current_language(), so this keeps mis-tagging | |
| 1578 | + // to the server's own determination instead of the (cacheable) client value. | |
| 1579 | + return $language === $provider->current_language(); | |
| 1580 | + } | |
| 1581 | + | |
| 1582 | + /** | |
| 1583 | + * Collect the block IDs of the fields a form configures as unique. | |
| 1584 | + * | |
| 1585 | + * Derived from the stored form, never from the request — the whole point is that | |
| 1586 | + * the client cannot nominate which fields are probeable. The frontend already | |
| 1587 | + * sends only inputs rendered with data-unique="true", which comes from the same | |
| 1588 | + * isUnique attribute, so this is the server-side mirror of what the client does. | |
| 1589 | + * | |
| 1590 | + * @param int $form_id Form ID. | |
| 1591 | + * | |
| 1592 | + * @since 2.12.3 | |
| 1593 | + * @return array<string,true> Unique field block IDs, keyed by block ID. | |
| 1594 | + */ | |
| 1595 | + private function get_unique_field_block_ids( $form_id ) { | |
| 1596 | + $form = get_post( $form_id ); | |
| 1597 | + | |
| 1598 | + if ( ! $form instanceof \WP_Post || '' === $form->post_content ) { | |
| 1599 | + return []; | |
| 1600 | + } | |
| 1601 | + | |
| 1602 | + $visited_refs = []; | |
| 1603 | + $block_ids = $this->collect_unique_field_block_ids( parse_blocks( $form->post_content ), $visited_refs ); | |
| 1604 | + | |
| 1605 | + /** | |
| 1606 | + * Filters the block IDs treated as unique fields for the AJAX uniqueness check. | |
| 1607 | + * | |
| 1608 | + * Lets add-ons whose fields a static parse of the form cannot see contribute | |
| 1609 | + * their own unique fields. | |
| 1610 | + * | |
| 1611 | + * @since 2.12.3 | |
| 1612 | + * | |
| 1613 | + * @param array<string,true> $block_ids Unique field block IDs, keyed by block ID. | |
| 1614 | + * A plain list of IDs is accepted too and is | |
| 1615 | + * normalised to this shape. | |
| 1616 | + * @param int $form_id Form ID. | |
| 1617 | + */ | |
| 1618 | + $filtered = apply_filters( 'srfm_unique_field_block_ids', $block_ids, $form_id ); | |
| 1619 | + | |
| 1620 | + // Normalise rather than trust: the lookup is isset( $set[ $block_id ] ), so an | |
| 1621 | + // add-on returning a plain list would silently disable uniqueness for the form | |
| 1622 | + // instead of adding to it. A non-array return keeps the derived set. | |
| 1623 | + return is_array( $filtered ) ? self::normalize_block_id_set( $filtered ) : $block_ids; | |
| 1624 | + } | |
| 1625 | + | |
| 1626 | + /** | |
| 1627 | + * Normalise a block-ID collection to a block ID => true map. | |
| 1628 | + * | |
| 1629 | + * Accepts both the documented map shape and a plain list of IDs. | |
| 1630 | + * | |
| 1631 | + * @param array<mixed> $block_ids Block IDs as a map or a list. | |
| 1632 | + * | |
| 1633 | + * @since 2.12.3 | |
| 1634 | + * @return array<string,true> Block IDs keyed by block ID. | |
| 1635 | + */ | |
| 1636 | + private static function normalize_block_id_set( $block_ids ) { | |
| 1637 | + $normalized = []; | |
| 1638 | + | |
| 1639 | + foreach ( $block_ids as $key => $value ) { | |
| 1640 | + // List entry: the ID is the value. Map entry: the ID is the key. | |
| 1641 | + $block_id = is_int( $key ) ? $value : $key; | |
| 1642 | + | |
| 1643 | + if ( is_string( $block_id ) && '' !== $block_id ) { | |
| 1644 | + $normalized[ $block_id ] = true; | |
| 1645 | + } | |
| 1646 | + } | |
| 1647 | + | |
| 1648 | + return $normalized; | |
| 1649 | + } | |
| 1650 | + | |
| 1651 | + /** | |
| 1652 | + * Recursively collect block IDs of blocks whose isUnique attribute is enabled. | |
| 1653 | + * | |
| 1654 | + * Recurses into innerBlocks (repeater/container children) and expands | |
| 1655 | + * reusable/synced patterns, mirroring Form_Styling::collect_form_block_ids(). | |
| 1656 | + * | |
| 1657 | + * Note: parse_blocks() does NOT apply block.json defaults, unlike the render path. | |
| 1658 | + * Every field block therefore has to keep isUnique defaulting to false — a block | |
| 1659 | + * that defaults it to true would be serialised without the attribute and would be | |
| 1660 | + * missed here while still rendering data-unique="true". | |
| 1661 | + * | |
| 1662 | + * @param array<mixed> $blocks Parsed blocks from parse_blocks(). | |
| 1663 | + * @param array<int, true> $visited_refs Reusable-block post IDs already expanded, | |
| 1664 | + * keyed by ID — guards against reference cycles. | |
| 1665 | + * | |
| 1666 | + * @since 2.12.3 | |
| 1667 | + * @return array<string,true> Unique field block IDs, keyed by block ID. | |
| 1668 | + */ | |
| 1669 | + private function collect_unique_field_block_ids( $blocks, &$visited_refs = [] ) { | |
| 1670 | + $block_ids = []; | |
| 1671 | + | |
| 1672 | + foreach ( $blocks as $block ) { | |
| 1673 | + if ( ! is_array( $block ) ) { | |
| 1674 | + continue; | |
| 1675 | + } | |
| 1676 | + | |
| 1677 | + $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : []; | |
| 1678 | + | |
| 1679 | + if ( ! empty( $attrs['isUnique'] ) && ! empty( $attrs['block_id'] ) && is_scalar( $attrs['block_id'] ) ) { | |
| 1680 | + $block_ids[ Helper::get_string_value( $attrs['block_id'] ) ] = true; | |
| 1681 | + } | |
| 1682 | + | |
| 1683 | + // Reusable/synced pattern: expand the referenced wp_block post so a field | |
| 1684 | + // living inside a pattern is seen like an inline block. | |
| 1685 | + if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) { | |
| 1686 | + $ref = absint( $attrs['ref'] ); | |
| 1687 | + | |
| 1688 | + if ( $ref && ! isset( $visited_refs[ $ref ] ) ) { | |
| 1689 | + $visited_refs[ $ref ] = true; | |
| 1690 | + $ref_post = get_post( $ref ); | |
| 1691 | + | |
| 1692 | + if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && 'publish' === $ref_post->post_status && '' !== $ref_post->post_content ) { | |
| 1693 | + $block_ids += $this->collect_unique_field_block_ids( parse_blocks( $ref_post->post_content ), $visited_refs ); | |
| 1694 | + } | |
| 1695 | + } | |
| 1696 | + } | |
| 1697 | + | |
| 1698 | + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { | |
| 1699 | + $block_ids += $this->collect_unique_field_block_ids( $block['innerBlocks'], $visited_refs ); | |
| 1700 | + } | |
| 1701 | + } | |
| 1702 | + | |
| 1703 | + return $block_ids; | |
| 1704 | + } | |
| 1705 | + | |
| 1706 | + /** | |
| 1221 | 1707 | * Check if the current request is rate-limited for unique validation. |
| 1222 | 1708 | * |
| 1223 | 1709 | * Uses transients keyed by IP + form ID to throttle requests. |
| 1224 | 1710 | * Allows 10 requests per 60-second window per IP per form. |
| @@ -1227,8 +1713,23 @@ | ||
| 1227 | 1713 | * @since 2.7.0 |
| 1228 | 1714 | * @return bool True if rate-limited (should block), false if allowed. |
| 1229 | 1715 | */ |
| 1230 | 1716 | private function is_unique_validation_rate_limited( $form_id ) { |
| 1717 | + return $this->is_rate_limited( 'srfm_uv_', $form_id ); | |
| 1718 | + } | |
| 1719 | + | |
| 1720 | + /** | |
| 1721 | + * Throttle a public endpoint to 10 requests per minute per IP per form. | |
| 1722 | + * | |
| 1723 | + * Shared by the uniqueness check and the client log route rather than | |
| 1724 | + * duplicated, so a change to the window applies to both. | |
| 1725 | + * | |
| 1726 | + * @param string $prefix Transient key prefix, unique per endpoint. | |
| 1727 | + * @param int $form_id The form ID the request relates to. | |
| 1728 | + * @since 2.12.6 | |
| 1729 | + * @return bool True if rate-limited (should block), false if allowed. | |
| 1730 | + */ | |
| 1731 | + private function is_rate_limited( $prefix, $form_id ) { | |
| 1231 | 1732 | $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; |
| 1232 | 1733 | |
| 1233 | 1734 | if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { |
| 1234 | 1735 | return true; // Fail closed if IP cannot be determined. |
| @@ -1233,9 +1734,9 @@ | ||
| 1233 | 1734 | if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { |
| 1234 | 1735 | return true; // Fail closed if IP cannot be determined. |
| 1235 | 1736 | } |
| 1236 | 1737 | |
| 1237 | - $transient_key = 'srfm_uv_' . md5( $ip . '_' . $form_id ); | |
| 1738 | + $transient_key = $prefix . md5( $ip . '_' . $form_id ); | |
| 1238 | 1739 | $attempts = get_transient( $transient_key ); |
| 1239 | 1740 | |
| 1240 | 1741 | if ( false === $attempts ) { |
| 1241 | 1742 | set_transient( $transient_key, 1, MINUTE_IN_SECONDS ); |