| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | 3 | * @package FireBox |
| 4 | - * @version 3.1.7 Free | |
| 4 | + * @version 3.1.14 Free | |
| 5 | 5 | * |
| 6 | 6 | * @author FirePlugins <[email protected]> |
| 7 | 7 | * @link https://www.fireplugins.com |
| 8 | 8 | * @copyright Copyright © 2026 FirePlugins All Rights Reserved |
| @@ -20,8 +20,24 @@ | ||
| 20 | 20 | use \FireBox\Core\Helpers\BoxHelper; |
| 21 | 21 | |
| 22 | 22 | class Ajax |
| 23 | 23 | { |
| 24 | + /** | |
| 25 | + * Accepted submissions one client may make per minute. | |
| 26 | + * | |
| 27 | + * Set well above anything a person produces — the target is a script replaying a | |
| 28 | + * valid submission, not a fast typist. Only submissions that pass validation count, | |
| 29 | + * so the headroom also absorbs the cases where several visitors look like one | |
| 30 | + * client: an office or campus behind a single address, or a proxy that does not | |
| 31 | + * pass the visitor's address on. | |
| 32 | + * | |
| 33 | + * Sites can tune this through the firebox/rate_limit/limit filter, which switches | |
| 34 | + * the throttle off entirely when it returns 0. | |
| 35 | + * | |
| 36 | + * @var int | |
| 37 | + */ | |
| 38 | + const SUBMISSIONS_PER_MINUTE = 60; | |
| 39 | + | |
| 24 | 40 | public function __construct() |
| 25 | 41 | { |
| 26 | 42 | $this->setupAjax(); |
| 27 | 43 | |
| @@ -47,8 +63,17 @@ | ||
| 47 | 63 | * @return void |
| 48 | 64 | */ |
| 49 | 65 | public function fb_form_submission_status_change() |
| 50 | 66 | { |
| 67 | + if (!current_user_can('edit_fireboxes')) | |
| 68 | + { | |
| 69 | + echo wp_json_encode([ | |
| 70 | + 'error' => true, | |
| 71 | + 'message' => 'You are not allowed to do this.' | |
| 72 | + ]); | |
| 73 | + wp_die(); | |
| 74 | + } | |
| 75 | + | |
| 51 | 76 | $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; |
| 52 | 77 | |
| 53 | 78 | // verify nonce |
| 54 | 79 | if (!$verify = wp_verify_nonce($nonce, 'fb_form_submission_action')) |
| @@ -59,17 +84,28 @@ | ||
| 59 | 84 | ]); |
| 60 | 85 | wp_die(); |
| 61 | 86 | } |
| 62 | 87 | |
| 63 | - $submission_id = isset($_POST['submission_id']) ? sanitize_key(wp_unslash($_POST['submission_id'])) : ''; | |
| 88 | + $submission_id = isset($_POST['submission_id']) ? absint($_POST['submission_id']) : 0; | |
| 64 | 89 | $new_state = isset($_POST['new_state']) ? sanitize_key(wp_unslash($_POST['new_state'])) : ''; |
| 65 | 90 | |
| 66 | 91 | $new_state = $new_state === 'publish' ? 1 : 0; |
| 67 | - | |
| 92 | + | |
| 93 | + // Confirm the submission exists before reporting anything about it. | |
| 94 | + if (!$submission_id || !\FireBox\Core\Helpers\Form\Submission::exists($submission_id)) | |
| 95 | + { | |
| 96 | + echo wp_json_encode([ | |
| 97 | + 'error' => true, | |
| 98 | + 'message' => 'Submission state couldn\'t be updated.' | |
| 99 | + ]); | |
| 100 | + wp_die(); | |
| 101 | + } | |
| 102 | + | |
| 68 | 103 | if (!\FireBox\Core\Helpers\Form\Submission::updateState($submission_id, $new_state)) |
| 69 | 104 | { |
| 70 | 105 | echo wp_json_encode([ |
| 71 | - 'error' => false, | |
| 106 | + // This branch means the write failed, so report it as an error. | |
| 107 | + 'error' => true, | |
| 72 | 108 | 'message' => 'Submission state couldn\'t be updated.' |
| 73 | 109 | ]); |
| 74 | 110 | wp_die(); |
| 75 | 111 | } |
| @@ -92,17 +128,45 @@ | ||
| 92 | 128 | |
| 93 | 129 | // verify nonce |
| 94 | 130 | if (!$verify = wp_verify_nonce($nonce, 'fbox_js_nonce')) |
| 95 | 131 | { |
| 132 | + /** | |
| 133 | + * A full-page cache routinely outlives the 12-24h nonce baked into the cached | |
| 134 | + * HTML. Name the cause so the client can fetch a fresh nonce and replay the | |
| 135 | + * submission once, instead of losing the lead. | |
| 136 | + */ | |
| 96 | 137 | echo wp_json_encode([ |
| 138 | + 'error' => 'invalid_nonce', | |
| 139 | + 'message' => firebox()->_('FB_FORM_CANNOT_VERIFY_REQUEST') | |
| 140 | + ]); | |
| 141 | + wp_die(); | |
| 142 | + } | |
| 143 | + | |
| 144 | + /** | |
| 145 | + * The nonce above is printed into every page a campaign renders on and is the | |
| 146 | + * same for every logged-out visitor, so it does not bound how often this endpoint | |
| 147 | + * can be called. An accepted submission writes several rows and may send mail and | |
| 148 | + * hit third-party APIs, so throttle per client. | |
| 149 | + * | |
| 150 | + * Only the check happens here. The hit is recorded further down, once the | |
| 151 | + * submission has passed the honeypot, the captcha and validation — see | |
| 152 | + * RATE LIMIT below. Counting rejected attempts would let a spam bot exhaust an | |
| 153 | + * allowance that real visitors may be sharing: wherever a proxy leaves many | |
| 154 | + * visitors looking like one client, junk traffic would lock all of them out of a | |
| 155 | + * form they are entitled to use. It would also spend a visitor's own allowance on | |
| 156 | + * their typos. | |
| 157 | + */ | |
| 158 | + if (\FireBox\Core\Helpers\RateLimit::isLimited('form_submit', self::SUBMISSIONS_PER_MINUTE)) | |
| 159 | + { | |
| 160 | + echo wp_json_encode([ | |
| 97 | 161 | 'error' => true, |
| 98 | - 'message' => 'Cannot verify request.' | |
| 162 | + 'message' => firebox()->_('FB_FORM_TOO_MANY_SUBMISSIONS') | |
| 99 | 163 | ]); |
| 100 | 164 | wp_die(); |
| 101 | - } | |
| 165 | + } | |
| 102 | 166 | |
| 103 | 167 | $form_data = isset($_POST['form_data']) ? sanitize_text_field(wp_unslash($_POST['form_data'])) : ''; |
| 104 | - $form_data = $form_data ? json_decode(html_entity_decode(stripslashes($form_data)), true) : ''; | |
| 168 | + $form_data = $form_data ? json_decode(stripslashes($form_data), true) : ''; | |
| 105 | 169 | |
| 106 | 170 | if (!$form_data) |
| 107 | 171 | { |
| 108 | 172 | echo wp_json_encode([ |
| @@ -122,9 +186,9 @@ | ||
| 122 | 186 | wp_die(); |
| 123 | 187 | } |
| 124 | 188 | |
| 125 | 189 | $values = isset($form_data['fields']) ? $form_data['fields'] : false; |
| 126 | - if (!$form_id) | |
| 190 | + if (!$values || !is_array($values)) | |
| 127 | 191 | { |
| 128 | 192 | echo wp_json_encode([ |
| 129 | 193 | 'error' => true, |
| 130 | 194 | 'message' => 'Missing submission data.' |
| @@ -141,8 +205,18 @@ | ||
| 141 | 205 | ]); |
| 142 | 206 | wp_die(); |
| 143 | 207 | } |
| 144 | 208 | |
| 209 | + // Forms live inside campaigns; only a published campaign may accept submissions from the frontend. | |
| 210 | + if (empty($form['state']) || $form['state'] !== '1') | |
| 211 | + { | |
| 212 | + echo wp_json_encode([ | |
| 213 | + 'error' => true, | |
| 214 | + 'message' => 'This form does not exist.' | |
| 215 | + ]); | |
| 216 | + wp_die(); | |
| 217 | + } | |
| 218 | + | |
| 145 | 219 | $form_block = $form['block']; |
| 146 | 220 | $form_fields = $form['fields']; |
| 147 | 221 | |
| 148 | 222 | // Get the Campaign ID |
| @@ -158,9 +232,9 @@ | ||
| 158 | 232 | catch (\Exception $e) |
| 159 | 233 | { |
| 160 | 234 | echo wp_json_encode([ |
| 161 | 235 | 'error' => true, |
| 162 | - 'message' => $e->getMessage() | |
| 236 | + 'message' => wp_kses_post($e->getMessage()) | |
| 163 | 237 | ]); |
| 164 | 238 | wp_die(); |
| 165 | 239 | } |
| 166 | 240 | |
| @@ -170,9 +244,9 @@ | ||
| 170 | 244 | catch (\Exception $e) |
| 171 | 245 | { |
| 172 | 246 | echo wp_json_encode([ |
| 173 | 247 | 'error' => true, |
| 174 | - 'message' => $e->getMessage() | |
| 248 | + 'message' => wp_kses_post($e->getMessage()) | |
| 175 | 249 | ]); |
| 176 | 250 | wp_die(); |
| 177 | 251 | } |
| 178 | 252 | |
| @@ -190,8 +264,15 @@ | ||
| 190 | 264 | echo wp_json_encode($payload); |
| 191 | 265 | wp_die(); |
| 192 | 266 | } |
| 193 | 267 | |
| 268 | + /** | |
| 269 | + * RATE LIMIT: the submission is accepted from here on, and everything below it | |
| 270 | + * costs something — rows written, mail sent, integrations called. This is the | |
| 271 | + * point worth rationing, so the hit is recorded here rather than on arrival. | |
| 272 | + */ | |
| 273 | + \FireBox\Core\Helpers\RateLimit::hit('form_submit', MINUTE_IN_SECONDS); | |
| 274 | + | |
| 194 | 275 | $submission = []; |
| 195 | 276 | $submission_meta_data = []; |
| 196 | 277 | |
| 197 | 278 | /** |
| @@ -229,23 +310,26 @@ | ||
| 229 | 310 | ]); |
| 230 | 311 | wp_die(); |
| 231 | 312 | } |
| 232 | 313 | |
| 233 | - // Track conversion after storing the submission | |
| 234 | - if ($box_log_id) | |
| 314 | + /** | |
| 315 | + * Track conversion after storing the submission. | |
| 316 | + * | |
| 317 | + * box_log_id arrives from the client, so it is only trusted once we have | |
| 318 | + * confirmed it refers to a real impression of this campaign. Otherwise any | |
| 319 | + * submitter could attribute their conversion to any campaign. | |
| 320 | + */ | |
| 321 | + if ($box_log_id && firebox()->tables->boxlog->belongsToCampaign($box_log_id, $box_id)) | |
| 235 | 322 | { |
| 236 | - /** | |
| 237 | - * Track conversion | |
| 238 | - */ | |
| 239 | 323 | $factory = new \FPFramework\Base\Factory(); |
| 240 | 324 | $data = [ |
| 241 | - 'log_id' => $box_log_id, | |
| 325 | + 'log_id' => (int) $box_log_id, | |
| 242 | 326 | 'event' => 'conversion', |
| 243 | 327 | 'event_source' => 'form', |
| 244 | - 'event_label' => 'FireBox #' . $box_id . ' Form', | |
| 328 | + 'event_label' => 'FireBox #' . (int) $box_id . ' Form', | |
| 245 | 329 | 'date' => $factory->getDate()->format('Y-m-d H:i:s') |
| 246 | 330 | ]; |
| 247 | - | |
| 331 | + | |
| 248 | 332 | firebox()->tables->boxlogdetails->insert($data); |
| 249 | 333 | } |
| 250 | 334 | |
| 251 | 335 | // Replace Smart Tags in form attributes |
| @@ -263,9 +347,9 @@ | ||
| 263 | 347 | if (!$actions->run()) |
| 264 | 348 | { |
| 265 | 349 | echo wp_json_encode([ |
| 266 | 350 | 'error' => true, |
| 267 | - 'message' => $actions->getErrorMessage() | |
| 351 | + 'message' => wp_kses_post($actions->getErrorMessage()) | |
| 268 | 352 | ]); |
| 269 | 353 | wp_die(); |
| 270 | 354 | } |
| 271 | 355 | } |