PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 All 97 releases
← All changes | inc/form-submit.php +534 -33 2.10.12.12.7 View file →
@@ -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 );