PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.3.1
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.3.1
3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 2.10.1 All 138 releases
← All changes | includes/Core/Database/Model.php +195 -35 2.10.13.3.1 View file →
@@ -9,8 +9,9 @@
9 9 /**
10 10 * Undocumented class
11 11 */
12 12
13 +use BitCode\BitForm\Core\Util\Log;
13 14 use WP_Error;
14 15
15 16 class Model
16 17 {
@@ -30,16 +31,16 @@
30 31 $this->table_name = $wpdb->prefix . static::$table;
31 32 }
32 33
33 34 /**
34 - * Undocumented function
35 + * Insert a row
35 36 *
36 - * @return void
37 + * @return mixed insert id on success, WP_Error on failure
37 38 */
38 39 public function insert($data = [])
39 40 {
40 41 if (is_null($data)) {
41 - return new WP_Error('empty_data', __('Form data is empty', 'bit-form'));
42 + return new WP_Error('empty_data', 'Form data is empty');
42 43 }
43 44 $result = $this->app_db->insert(
44 45 $this->table_name,
45 46 $data
@@ -49,22 +50,24 @@
49 50
50 51 /**
51 52 * Undocumented function
52 53 *
53 - * @param string $item
54 - * @param array $condition
54 + * @param string|string[] $item
55 + * @param array $condition
55 56 *
56 57 * @return mixed
57 58 */
58 59 public function get($item = '*', $condition = [], $limit = null, $offset = null, $order_by = null, $order_follow = null)
59 60 {
60 - $checkIfTableExists = $this->app_db->get_var(
61 - $this->app_db->prepare(
62 - 'SHOW TABLES LIKE %s',
63 - $this->table_name
64 - )
65 - );
66 - if (is_null($checkIfTableExists)) {
61 + static $tableExistsCache = [];
62 + if (!isset($tableExistsCache[$this->table_name])) {
63 + $tableExistsCache[$this->table_name] = !is_null(
64 + $this->app_db->get_var(
65 + $this->app_db->prepare('SHOW TABLES LIKE %s', $this->table_name)
66 + )
67 + );
68 + }
69 + if (!$tableExistsCache[$this->table_name]) {
67 70 return [];
68 71 }
69 72 if (\is_array($item)) {
70 73 $column_to_select = implode(',', $item);
@@ -77,9 +80,19 @@
77 80 }
78 81 $order = null;
79 82 if (!\is_null($order_by)) {
80 83 $order_follow = \is_null($order_follow) ? 'ASC' : $order_follow;
81 - $order .= " ORDER BY $order_by $order_follow";
84 + $direction = \is_string($order_follow) ? strtoupper(trim($order_follow)) : '';
85 + if ($this->isSafeConditionIdentifier($order_by) && \in_array($direction, ['ASC', 'DESC'], true)) {
86 + $order .= ' ORDER BY ' . $this->quoteIdentifier($order_by) . ' ' . $direction;
87 + } else {
88 + Log::debug_log([
89 + 'message' => 'Model::get() ignored an unsafe ORDER BY',
90 + 'table' => $this->table_name,
91 + 'order_by' => $order_by,
92 + 'order_follow' => $order_follow,
93 + ]);
94 + }
82 95 }
83 96 $paginate = null;
84 97 if (!\is_null($limit)) {
85 98 $limit = \intval($limit);
@@ -135,9 +148,9 @@
135 148 $all_values = null;
136 149 }
137 150 $result = $this->app_db->query(
138 151 $this->app_db->prepare(
139 - "SELECT COUNT(*) as count FROM `$this->table_name`"
152 + "SELECT COUNT(*) as count FROM `{$this->table_name}`"
140 153 . $condition_to_check,
141 154 $all_values
142 155 )
143 156 );
@@ -145,9 +158,9 @@
145 158 if (!$result) {
146 159 if ($this->app_db->last_error) {
147 160 return new WP_Error('db_error', $this->app_db->last_error);
148 161 }
149 - return new WP_Error('db_error', __('Result is empty', 'bit-form'));
162 + return new WP_Error('db_error', 'Result is empty');
150 163 } else {
151 164 return $this->app_db->last_result;
152 165 }
153 166 }
@@ -157,9 +170,9 @@
157 170 *
158 171 * @param array $data_to_update
159 172 * @param array $condition
160 173 *
161 - * @return void
174 + * @return mixed affected-row count on success, WP_Error on failure or when no row matched
162 175 */
163 176 public function update(array $data, array $condition)
164 177 {
165 178 if (
@@ -170,9 +183,9 @@
170 183 $data_to_update = $data;
171 184 } else {
172 185 return new WP_Error(
173 186 'update_error',
174 - __('Nothing to update', 'bit-form')
187 + 'Nothing to update'
175 188 );
176 189 }
177 190 $update_condition = (!\is_null($condition) &&
178 191 array_keys($condition) !== range(0, count($condition) - 1)) ? $condition : null;
@@ -202,9 +215,9 @@
202 215 $data_to_update = $data;
203 216 } else {
204 217 return new WP_Error(
205 218 'update_error',
206 - __('Nothing to update', 'bit-form')
219 + 'Nothing to update'
207 220 );
208 221 }
209 222
210 223 $update_fields = '';
@@ -229,9 +242,9 @@
229 242 $condition_to_check = null;
230 243 }
231 244 $result = $this->app_db->query(
232 245 $this->app_db->prepare(
233 - "UPDATE $this->table_name SET $update_fields $condition_to_check",
246 + "UPDATE `{$this->table_name}` SET $update_fields $condition_to_check",
234 247 $all_values
235 248 )
236 249 );
237 250 return $this->getResult($result);
@@ -254,9 +267,9 @@
254 267 && \is_array($duplicate)
255 268 && array_keys($duplicate) === range(0, count($duplicate) - 1))) {
256 269 return new WP_Error(
257 270 'duplicate_error',
258 - __('Nothing to duplicate', 'bit-form')
271 + 'Nothing to duplicate'
259 272 );
260 273 }
261 274
262 275 $dupCol = '';
@@ -281,10 +294,10 @@
281 294 if ($formatted_conditions) {
282 295 $condition_to_check = $formatted_conditions['conditions'];
283 296 $all_values = array_merge($all_values, $formatted_conditions['values']);
284 297 }
285 - $query = "INSERT INTO $this->table_name ($insCol)
286 - SELECT $dupCol FROM $this->table_name $condition_to_check";
298 + $query = "INSERT INTO `{$this->table_name}` ($insCol)
299 + SELECT $dupCol FROM `{$this->table_name}` $condition_to_check";
287 300 $this->execute($query, $all_values);
288 301 return $this->getResult();
289 302 }
290 303
@@ -298,9 +311,9 @@
298 311 $delete_condition = $condition;
299 312 } else {
300 313 return new WP_Error(
301 314 'deletion_error',
302 - __('At least 1 condition needed', 'bit-form')
315 + 'At least 1 condition needed'
303 316 );
304 317 }
305 318 $update_condition = (!\is_null($condition) &&
306 319 array_keys($condition) !== range(0, count($condition) - 1)) ? $condition : null;
@@ -322,9 +335,9 @@
322 335 $delete_condition = $condition;
323 336 } else {
324 337 return new WP_Error(
325 338 'deletion_error',
326 - __('At least 1 condition needed', 'bit-form')
339 + 'At least 1 condition needed'
327 340 );
328 341 }
329 342 $result = $this->app_db->delete(
330 343 $this->table_name,
@@ -343,9 +356,9 @@
343 356 $delete_condition = $condition;
344 357 } else {
345 358 return new WP_Error(
346 359 'deletion_error',
347 - __('At least 1 condition needed', 'bit-form')
360 + 'At least 1 condition needed'
348 361 );
349 362 }
350 363 // $formatted_conditions = $this->getFormatedCondition($delete_condition, $check_operator);
351 364 $formatted_conditions = $this->getFormatedCondition($delete_condition);
@@ -355,14 +368,14 @@
355 368 } else {
356 369 $condition_to_check = null;
357 370 return new WP_Error(
358 371 'deletion_error',
359 - __('At least 1 condition needed', 'bit-form')
372 + 'At least 1 condition needed'
360 373 );
361 374 }
362 375 $result = $this->app_db->query(
363 376 $this->app_db->prepare(
364 - "DELETE FROM $this->table_name $condition_to_check",
377 + "DELETE FROM `{$this->table_name}` $condition_to_check",
365 378 $all_values
366 379 )
367 380 );
368 381 return $this->getResult($result);
@@ -373,8 +386,88 @@
373 386 return ('integer' === gettype($value)) ?
374 387 '%d' : (('double' === gettype($value)) ? '%f' : '%s');
375 388 }
376 389
390 + /**
391 + *
392 + * @param mixed $identifier
393 + *
394 + * @return bool
395 + */
396 + protected function isSafeConditionIdentifier($identifier)
397 + {
398 + if (!\is_string($identifier)) {
399 + return false;
400 + }
401 + $identifier = trim($identifier);
402 + if ('' === $identifier) {
403 + return false;
404 + }
405 +
406 + // A condition column may be table-qualified and backtick-quoted — the multi-table JOIN DELETE
407 + // in FormEntryModel::bulkDelete() *must* pass `wp_bitforms_form_entries`.`id`, because a bare
408 + // `id` is ambiguous across the two joined tables. Validate each segment on its own.
409 + $parts = explode('.', $identifier);
410 + if (count($parts) > 2) {
411 + return false;
412 + }
413 + foreach ($parts as $part) {
414 + $part = trim($part);
415 + if (\strlen($part) > 1 && '`' === $part[0] && '`' === substr($part, -1)) {
416 + $part = substr($part, 1, -1);
417 + }
418 + if (1 !== preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $part)) {
419 + return false;
420 + }
421 + }
422 +
423 + return true;
424 + }
425 +
426 + /**
427 + * Backtick-quote a validated identifier, leaving an already-quoted or table-qualified one alone.
428 + * Only ever call this on a value isSafeConditionIdentifier() has approved.
429 + *
430 + * @param string $identifier
431 + *
432 + * @return string
433 + */
434 + protected function quoteIdentifier($identifier)
435 + {
436 + $identifier = trim($identifier);
437 + if (false !== strpos($identifier, '`') || false !== strpos($identifier, '.')) {
438 + return $identifier;
439 + }
440 +
441 + return '`' . $identifier . '`';
442 + }
443 +
444 + /**
445 + * @param mixed $operator
446 + *
447 + * @return bool
448 + */
449 + protected function isSafeConditionOperator($operator)
450 + {
451 + static $allowed = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'IS', 'IS NOT'];
452 +
453 + return \is_string($operator) && \in_array(strtoupper(trim($operator)), $allowed, true);
454 + }
455 +
456 + /**
457 + * A WHERE that matches nothing. Keeps a placeholder so the caller's
458 + * $wpdb->prepare($sql, $values) still has something to bind.
459 + *
460 + * @return array
461 + */
462 + private function impossibleCondition()
463 + {
464 + return [
465 + 'conditions' => ' WHERE 1=%d ',
466 + 'values' => [0],
467 + ];
468 + }
469 +
377 470 protected function getFormatedCondition($condition, $check_operator = null, $join_operator = ' AND ')
378 471 {
379 472 if (\is_null($condition)) {
380 473 return false;
@@ -383,15 +476,28 @@
383 476 $index_checker = 0;
384 477 $condition_to_check = ' WHERE ';
385 478 $all_values = [];
386 479 foreach ($condition as $key => $value) {
480 + if (!$this->isSafeConditionIdentifier($key)) {
481 + return $this->impossibleCondition();
482 + }
387 483 $value_type = '';
388 484 if (is_array($value)) {
389 - if (isset($value['operator'])) {
485 + // Check for raw SQL values first
486 + if (isset($value['raw'])) {
487 + if (!\is_string($value['raw'])) {
488 + return $this->impossibleCondition();
489 + }
490 + $set_check_operator = isset($value['operator']) ? $value['operator'] : '=';
491 + $value_type = $value['raw']; // Use raw SQL directly
492 + // Don't add to $all_values since it's raw SQL
493 + } elseif (isset($value['operator'])) {
494 + // logic for operator arrays
390 495 $set_check_operator = $value['operator'];
391 496 $value_type .= $this->getFieldFormat($value['value']);
392 497 $all_values[] = $value['value'];
393 498 } else {
499 + // logic for IN conditions
394 500 $set_check_operator = \is_null($check_operator) ? 'in' : $check_operator;
395 501 $value_type .= ' ( ';
396 502 $value_index_checker = 0;
397 503 $value_count = count($value) - 1;
@@ -405,12 +511,16 @@
405 511 }
406 512 $value_type .= ' )';
407 513 }
408 514 } else {
515 + // logic for simple values
409 516 $set_check_operator = \is_null($check_operator) ? '=' : $check_operator;
410 517 $value_type .= $this->getFieldFormat($value);
411 518 $all_values[] = $value;
412 519 }
520 + if (!$this->isSafeConditionOperator($set_check_operator)) {
521 + return $this->impossibleCondition();
522 + }
413 523 $condition_to_check = $condition_to_check . $key . " $set_check_operator " . $value_type;
414 524 if ($index_checker < $no_condition - 1) {
415 525 $condition_to_check = $condition_to_check . " $join_operator ";
416 526 }
@@ -421,8 +531,24 @@
421 531 'values' => $all_values
422 532 ];
423 533 }
424 534
535 + /**
536 + * @param array $values values about to be bound by $wpdb->prepare()
537 + *
538 + * @return string|null the offending PHP type, or null when every value is bindable
539 + */
540 + private function findUnbindableValue(array $values)
541 + {
542 + foreach ($values as $value) {
543 + if (!is_scalar($value) && !is_null($value)) {
544 + return \gettype($value);
545 + }
546 + }
547 +
548 + return null;
549 + }
550 +
425 551 protected function checkCondition(array $condition)
426 552 {
427 553 if (!is_null($condition) && array_keys($condition) === range(0, count($condition) - 1)) {
428 554 return new WP_Error(
@@ -434,16 +560,31 @@
434 560 }
435 561
436 562 protected function execute($sql, $values = null)
437 563 {
564 + // Clear the previous call's outcome before running a new query, so a failure can never be
565 + // read back by whatever this instance is used for next.
566 + $this->db_response = null;
438 567 if (is_null($values)) {
439 568 $preparedQuery = $sql;
440 569 } else {
570 + $invalid = $this->findUnbindableValue((array) $values);
571 + if (null !== $invalid) {
572 + Log::debug_log([
573 + 'message' => 'Model::execute() received an unbindable condition value',
574 + 'table' => $this->table_name,
575 + 'type' => $invalid,
576 + 'sql' => $sql,
577 + ]);
578 + $this->db_response = new WP_Error('invalid_query_value', 'Query value must be scalar, ' . $invalid . ' given');
579 +
580 + return $this;
581 + }
441 582 $preparedQuery = $this->app_db->prepare($sql, $values);
442 583 }
443 584 // echo " Q S " . $preparedQuery . " Q EE";
444 585 if (empty($preparedQuery)) {
445 - $this->db_response = new WP_Error('null_query', __('prepared query is empty', 'bit-form'));
586 + $this->db_response = new WP_Error('null_query', 'prepared query is empty');
446 587 } else {
447 588 $this->db_response = false !== stripos($preparedQuery, 'DELETE') ? $this->app_db->query($preparedQuery)
448 589 : $this->app_db->get_results($preparedQuery, OBJECT_K);
449 590 }
@@ -452,9 +593,21 @@
452 593 }
453 594
454 595 protected function getResult($db_response = null)
455 596 {
456 - $db_response = !empty($this->db_response) ? $this->db_response : $db_response;
597 + // The caller's own result wins. $db_response is an instance property that only execute()
598 + // writes, and models are reused (AdminFormHandler keeps a static FormModel for the whole
599 + // request), so letting the property override an explicitly passed result made insert() and
600 + // update() report the outcome of some earlier, unrelated query on the same object.
601 + // Without this fallback, execute()->getResult() (which passes no argument) never sees the
602 + // query it just ran and every read returns 'result_empty'.
603 + if (null === $db_response) {
604 + $db_response = $this->db_response;
605 + }
606 +
607 + if (is_wp_error($db_response)) {
608 + return $db_response;
609 + }
457 610 if (!empty($this->app_db->last_error)) {
458 611 return new WP_Error('db_error', $this->app_db->last_error);
459 612 }
460 613 if (!$db_response) {
@@ -463,9 +616,9 @@
463 616 }
464 617 if (is_wp_error($db_response)) {
465 618 $response = $db_response;
466 619 }
467 - $response = new WP_Error('result_empty', __('Result is empty', 'bit-form'));
620 + $response = new WP_Error('result_empty', 'Result is empty');
468 621 } elseif (is_array($this->app_db->last_result) && !empty($this->app_db->last_result)) {
469 622 $response = $this->app_db->last_result;
470 623 } elseif ($this->app_db->insert_id) {
471 624 $response = $this->app_db->insert_id;
@@ -475,13 +628,20 @@
475 628 $this->app_db->flush();
476 629 return $response;
477 630 }
478 631
479 - // get last entry id
632 + /**
633 + * Get last inserted id
634 + *
635 + * @return int
636 + */
480 637 public function lastId()
481 638 {
482 - $sql = "SELECT id FROM {$this->table_name}
639 + $sql = "SELECT id FROM `{$this->table_name}`
483 640 ORDER BY id DESC LIMIT 1";
484 - $result = $this->execute($sql)->getResult()[0];
485 - return $result->id;
641 + $result = $this->execute($sql)->getResult();
642 + if (is_wp_error($result)) {
643 + return 0;
644 + }
645 + return $result[0]->id;
486 646 }
487 647 }