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 +150 -8 3.1.43.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,11 +31,11 @@
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)) {
@@ -49,10 +50,10 @@
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)
@@ -79,9 +80,19 @@
79 80 }
80 81 $order = null;
81 82 if (!\is_null($order_by)) {
82 83 $order_follow = \is_null($order_follow) ? 'ASC' : $order_follow;
83 - $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 + }
84 95 }
85 96 $paginate = null;
86 97 if (!\is_null($limit)) {
87 98 $limit = \intval($limit);
@@ -159,9 +170,9 @@
159 170 *
160 171 * @param array $data_to_update
161 172 * @param array $condition
162 173 *
163 - * @return void
174 + * @return mixed affected-row count on success, WP_Error on failure or when no row matched
164 175 */
165 176 public function update(array $data, array $condition)
166 177 {
167 178 if (
@@ -375,8 +386,88 @@
375 386 return ('integer' === gettype($value)) ?
376 387 '%d' : (('double' === gettype($value)) ? '%f' : '%s');
377 388 }
378 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 +
379 470 protected function getFormatedCondition($condition, $check_operator = null, $join_operator = ' AND ')
380 471 {
381 472 if (\is_null($condition)) {
382 473 return false;
@@ -385,13 +476,18 @@
385 476 $index_checker = 0;
386 477 $condition_to_check = ' WHERE ';
387 478 $all_values = [];
388 479 foreach ($condition as $key => $value) {
480 + if (!$this->isSafeConditionIdentifier($key)) {
481 + return $this->impossibleCondition();
482 + }
389 483 $value_type = '';
390 484 if (is_array($value)) {
391 485 // Check for raw SQL values first
392 486 if (isset($value['raw'])) {
393 - // Handle raw SQL - don't format or add to prepared values
487 + if (!\is_string($value['raw'])) {
488 + return $this->impossibleCondition();
489 + }
394 490 $set_check_operator = isset($value['operator']) ? $value['operator'] : '=';
395 491 $value_type = $value['raw']; // Use raw SQL directly
396 492 // Don't add to $all_values since it's raw SQL
397 493 } elseif (isset($value['operator'])) {
@@ -420,8 +516,11 @@
420 516 $set_check_operator = \is_null($check_operator) ? '=' : $check_operator;
421 517 $value_type .= $this->getFieldFormat($value);
422 518 $all_values[] = $value;
423 519 }
520 + if (!$this->isSafeConditionOperator($set_check_operator)) {
521 + return $this->impossibleCondition();
522 + }
424 523 $condition_to_check = $condition_to_check . $key . " $set_check_operator " . $value_type;
425 524 if ($index_checker < $no_condition - 1) {
426 525 $condition_to_check = $condition_to_check . " $join_operator ";
427 526 }
@@ -432,8 +531,24 @@
432 531 'values' => $all_values
433 532 ];
434 533 }
435 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 +
436 551 protected function checkCondition(array $condition)
437 552 {
438 553 if (!is_null($condition) && array_keys($condition) === range(0, count($condition) - 1)) {
439 554 return new WP_Error(
@@ -445,11 +560,26 @@
445 560 }
446 561
447 562 protected function execute($sql, $values = null)
448 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;
449 567 if (is_null($values)) {
450 568 $preparedQuery = $sql;
451 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 + }
452 582 $preparedQuery = $this->app_db->prepare($sql, $values);
453 583 }
454 584 // echo " Q S " . $preparedQuery . " Q EE";
455 585 if (empty($preparedQuery)) {
@@ -463,9 +593,21 @@
463 593 }
464 594
465 595 protected function getResult($db_response = null)
466 596 {
467 - $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 + }
468 610 if (!empty($this->app_db->last_error)) {
469 611 return new WP_Error('db_error', $this->app_db->last_error);
470 612 }
471 613 if (!$db_response) {