PluginProbe
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent / 3.5.4
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent v3.5.4
3.5.4 3.5.3 3.5.2 3.5.1 3.4.9 3.5.0 3.4.8 3.4.7 trunk 2.3.1 3.3.6 3.3.7 3.3.8 3.3.9 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5 3.4.6
← All changes | includes/models/class-wpsc-attachment.php +264 -15 3.4.3 → 3.5.4 View file →
@@ -42,8 +42,18 @@
42 42 */
43 43 private static $tl_search_items;
44 44
45 45 /**
46 + * Customer id of the visitor this request actually belongs to, captured
47 + * on 'init' before anything can reassign WPSC_Current_User::$current_user
48 + * (change_current_user() is called from a number of places, one of them
49 + * driven by request input). Used to record and check attachment ownership.
50 + *
51 + * @var int
52 + */
53 + private static $session_customer_id = 0;
54 +
55 + /**
46 56 * Initialize this class
47 57 *
48 58 * @return void
49 59 */
@@ -51,8 +61,11 @@
51 61
52 62 // Apply schema for this model.
53 63 add_action( 'init', array( __CLASS__, 'apply_schema' ), 2 );
54 64
65 + // Capture whose session this request is, before anything reassigns it.
66 + add_action( 'init', array( __CLASS__, 'snapshot_session_customer' ), 11 );
67 +
55 68 // Get object of this class.
56 69 add_filter( 'wpsc_load_ref_classes', array( __CLASS__, 'load_ref_class' ) );
57 70
58 71 // File upload.
@@ -185,8 +198,13 @@
185 198 'has_ref' => false,
186 199 'ref_class' => '',
187 200 'has_multiple_val' => false,
188 201 ),
202 + 'uploaded_by' => array(
203 + 'has_ref' => false,
204 + 'ref_class' => '',
205 + 'has_multiple_val' => false,
206 + ),
189 207 );
190 208 self::$schema = apply_filters( 'wpsc_attachment_schema', $schema );
191 209
192 210 // Prevent modify.
@@ -543,9 +561,9 @@
543 561 */
544 562 public static function file_upload() {
545 563
546 564 if ( check_ajax_referer( 'wpsc_file_upload', '_ajax_nonce', false ) != 1 ) {
547 - wp_send_json_error( 'Unauthorised request!', 401 );
565 + wp_send_json_error( 'Unauthorized request!', 401 );
548 566 }
549 567
550 568 if ( ! WPSC_Text_Editor::is_allow_attachments() ) {
551 569 wp_send_json_error( new WP_Error( '001', 'Unauthorized' ), 401 );
@@ -551,9 +569,9 @@
551 569 wp_send_json_error( new WP_Error( '001', 'Unauthorized' ), 401 );
552 570 }
553 571
554 572 $recaptcha = get_option( 'wpsc-recaptcha-settings' );
555 - if ( $recaptcha['allow-recaptcha'] === 1 && $recaptcha['recaptcha-version'] == 3 && $recaptcha['recaptcha-site-key'] && $recaptcha['recaptcha-secret-key'] ) {
573 + if ( $recaptcha['captcha-provider'] === 'google-recaptcha' && $recaptcha['recaptcha-version'] == 3 && $recaptcha['recaptcha-site-key'] && $recaptcha['recaptcha-secret-key'] ) {
556 574 WPSC_MS_Recaptcha::validate( 'file_upload' );
557 575 }
558 576
559 577 $file = isset( $_FILES['wpscFileAttachment'] ) ? $_FILES['wpscFileAttachment'] : false; // phpcs:ignore
@@ -626,10 +644,19 @@
626 644 if ( ! $attachment->id ) {
627 645 wp_send_json_error( 'Something went wrong!', 500 );
628 646 }
629 647
630 - wp_send_json( array( 'id' => $attachment->id ) );
648 + // Record who uploaded it, and hand them a per-attachment token,
649 + // so that only they can later bind it to a ticket.
650 + self::set_uploader( $attachment );
631 651
652 + wp_send_json(
653 + array(
654 + 'id' => $attachment->id,
655 + 'token' => wp_create_nonce( self::get_img_editor_tmp_nonce_action( $attachment->id ) ),
656 + )
657 + );
658 +
632 659 } else {
633 660
634 661 $error_message = isset( $uploaded_file['error'] ) ? $uploaded_file['error'] : 'Something went wrong!';
635 662 wp_send_json_error( $error_message, 500 );
@@ -650,9 +677,17 @@
650 677 } else {
651 678 $attachments = self::find(
652 679 array(
653 680 'search' => $search,
654 - 'items_per_page' => 0,
681 + 'items_per_page' => 500,
682 + 'meta_query' => array(
683 + 'relation' => 'AND',
684 + array(
685 + 'slug' => 'is_active',
686 + 'compare' => '=',
687 + 'val' => '1',
688 + ),
689 + ),
655 690 )
656 691 )['results'];
657 692 if ( $attachments ) {
658 693 foreach ( $attachments as $attachment ) {
@@ -672,9 +707,9 @@
672 707 */
673 708 public static function add_custom_image_tinymce() {
674 709
675 710 if ( check_ajax_referer( 'wpsc_add_custom_image_tinymce', '_ajax_nonce', false ) != 1 ) {
676 - wp_send_json_error( 'Unauthorised request!', 401 );
711 + wp_send_json_error( 'Unauthorized request!', 401 );
677 712 }
678 713
679 714 $title = esc_attr__( 'Insert/edit image', 'supportcandy' );
680 715
@@ -829,9 +864,9 @@
829 864 */
830 865 public static function edit_custom_image_tinymce() {
831 866
832 867 if ( check_ajax_referer( 'wpsc_edit_custom_image_tinymce', '_ajax_nonce', false ) != 1 ) {
833 - wp_send_json_error( 'Unauthorised request!', 401 );
868 + wp_send_json_error( 'Unauthorized request!', 401 );
834 869 }
835 870
836 871 $title = esc_attr__( 'Edit image', 'supportcandy' );
837 872
@@ -1047,9 +1082,8 @@
1047 1082 if ( ! $attachment->id ) {
1048 1083 return;
1049 1084 }
1050 1085 $auth_code = isset($_REQUEST['auth_code']) ? sanitize_text_field( $_REQUEST['auth_code'] ) : ''; // phpcs:ignore
1051 - $advanced = get_option( 'wpsc-ms-advanced-settings' );
1052 1086 switch ( $attachment->source ) {
1053 1087
1054 1088 case 'cf':
1055 1089 $cf = new WPSC_Custom_Field( $attachment->source_id );
@@ -1068,9 +1102,9 @@
1068 1102 }
1069 1103
1070 1104 if ( ! $has_auth ) {
1071 1105 $auth_code = isset( $_REQUEST['auth_code'] ) ? sanitize_text_field( $_REQUEST['auth_code'] ) : ''; // phpcs:ignore
1072 - if ( ! $auth_code || $auth_code != $ticket->auth_code ) {
1106 + if ( ! $auth_code || ! $ticket->auth_code || ! hash_equals( (string) $ticket->auth_code, $auth_code ) ) {
1073 1107 wp_send_json_error( 'Unauthorized!', 401 );
1074 1108 }
1075 1109 }
1076 1110
@@ -1084,9 +1118,9 @@
1084 1118 $ticket_class::$ticket = $ticket;
1085 1119 $has_access = (
1086 1120 ( $current_user->is_agent && $ticket_class::has_ticket_cap( 'view' ) ) ||
1087 1121 $ticket_class::is_customer() ||
1088 - ( ! $advanced['ticket-url-auth'] && $ticket->auth_code === $auth_code )
1122 + ( $ticket->auth_code && hash_equals( (string) $ticket->auth_code, $auth_code ) )
1089 1123 );
1090 1124
1091 1125 if ( ! $has_access ) {
1092 1126 wp_send_json_error( __( 'Unauthorized!', 'supportcandy' ), 401 );
@@ -1123,9 +1157,9 @@
1123 1157 }
1124 1158
1125 1159 if ( ! $has_auth ) {
1126 1160 $auth_code = isset( $_REQUEST['auth_code'] ) ? sanitize_text_field( $_REQUEST['auth_code'] ) : ''; // phpcs:ignore
1127 - if ( ! $auth_code || $auth_code != $ticket->auth_code ) {
1161 + if ( ! $auth_code || ! $ticket->auth_code || ! hash_equals( (string) $ticket->auth_code, $auth_code ) ) {
1128 1162 wp_send_json_error( 'Unauthorized!', 401 );
1129 1163 }
1130 1164 }
1131 1165
@@ -1139,9 +1173,9 @@
1139 1173 $ticket_class::$ticket = $ticket;
1140 1174 $has_access = (
1141 1175 ( $current_user->is_agent && $ticket_class::has_ticket_cap( 'view' ) ) ||
1142 1176 $ticket_class::is_customer() ||
1143 - ( ! $advanced['ticket-url-auth'] && $ticket->auth_code === $auth_code )
1177 + ( $ticket->auth_code && hash_equals( (string) $ticket->auth_code, $auth_code ) )
1144 1178 );
1145 1179
1146 1180 if ( ! $has_access ) {
1147 1181 wp_send_json_error( __( 'Unauthorized!', 'supportcandy' ), 401 );
@@ -1160,9 +1194,9 @@
1160 1194 }
1161 1195
1162 1196 if ( ! $has_auth ) {
1163 1197 $auth_code = isset( $_REQUEST['auth_code'] ) ? sanitize_text_field( $_REQUEST['auth_code'] ) : ''; // phpcs:ignore
1164 - if ( ! $auth_code || $auth_code != $ticket->auth_code ) {
1198 + if ( ! $auth_code || ! $ticket->auth_code || ! hash_equals( (string) $ticket->auth_code, $auth_code ) ) {
1165 1199 wp_send_json_error( 'Unauthorized!', 401 );
1166 1200 }
1167 1201 }
1168 1202
@@ -1203,9 +1237,9 @@
1203 1237 if (
1204 1238 ! (
1205 1239 ( $current_user->is_agent && $ticket_class::has_ticket_cap( 'view' ) ) ||
1206 1240 $ticket_class::is_customer() ||
1207 - ( $ticket->auth_code == $auth_code )
1241 + ( $ticket->auth_code && $auth_code && hash_equals( (string) $ticket->auth_code, $auth_code ) )
1208 1242 )
1209 1243 ) {
1210 1244 wp_send_json_error( 'Unauthorized!', 401 );
1211 1245 }
@@ -1214,8 +1248,20 @@
1214 1248 wp_send_json_error( __( 'Invalid ticket type.', 'supportcandy' ), 400 );
1215 1249 }
1216 1250 break;
1217 1251 case 'img_editor_tmp':
1252 + // Not yet attached to any ticket/thread, so there is no auth-code
1253 + // to check it against. Serve it only to whoever uploaded it,
1254 + // proven either by the recorded uploader identity or by the
1255 + // per-attachment nonce handed out in the upload response.
1256 + // The identity check also keeps the image loading in the editor
1257 + // when a guest logs in or registers mid-compose, which changes
1258 + // the user the nonce is bound to.
1259 + $view_nonce = isset( $_REQUEST['wpsc_nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['wpsc_nonce'] ) ) : ''; // phpcs:ignore
1260 + if ( ! self::is_uploaded_by_current_user( $attachment ) &&
1261 + ( ! $view_nonce || ! wp_verify_nonce( $view_nonce, self::get_img_editor_tmp_nonce_action( $attachment->id ) ) ) ) {
1262 + wp_send_json_error( 'Unauthorized!', 401 );
1263 + }
1218 1264 self::file_download( $attachment );
1219 1265 }
1220 1266 }
1221 1267 }
@@ -1265,9 +1311,9 @@
1265 1311 */
1266 1312 public static function tinymce_upload_file() {
1267 1313
1268 1314 if ( check_ajax_referer( 'wpsc_tinymce_upload_file', '_ajax_nonce', false ) != 1 ) {
1269 - wp_send_json_error( 'Unauthorised request!', 401 );
1315 + wp_send_json_error( 'Unauthorized request!', 401 );
1270 1316 }
1271 1317
1272 1318 $file = isset( $_FILES['file'] ) ? $_FILES['file'] : false; // phpcs:ignore
1273 1319 if ( ! $file ) {
@@ -1325,15 +1371,218 @@
1325 1371 if ( ! $attachment->id ) {
1326 1372 wp_send_json_error( 'Something went wrong!', 500 );
1327 1373 }
1328 1374
1329 - wp_send_json( array( 'imgURL' => home_url( '/' ) . '?wpsc_attachment=' . $attachment->id ) );
1375 + // Temporary in-editor images have no ticket yet to check an auth-code
1376 + // against, so gate access with a per-attachment nonce instead, known
1377 + // only to whoever just uploaded it via this response. The uploader
1378 + // is also recorded so the later claim can be checked server side.
1379 + self::set_uploader( $attachment );
1380 + $view_nonce = wp_create_nonce( self::get_img_editor_tmp_nonce_action( $attachment->id ) );
1330 1381
1382 + wp_send_json( array( 'imgURL' => home_url( '/' ) . '?wpsc_attachment=' . $attachment->id . '&wpsc_nonce=' . $view_nonce ) );
1383 +
1331 1384 } else {
1332 1385
1333 1386 $error_message = isset( $uploaded_file['error'] ) ? $uploaded_file['error'] : 'Something went wrong!';
1334 1387 wp_send_json_error( $error_message, 500 );
1335 1388 }
1389 + }
1390 +
1391 + /**
1392 + * Capture the customer this request's session genuinely belongs to.
1393 + * Runs on 'init' right after the current user is resolved, so that a
1394 + * later change_current_user() (used when acting on behalf of someone
1395 + * else, and in one case driven by request input) cannot influence
1396 + * attachment ownership checks.
1397 + *
1398 + * @return void
1399 + */
1400 + public static function snapshot_session_customer() {
1401 +
1402 + $current_user = WPSC_Current_User::$current_user;
1403 + self::$session_customer_id = ( $current_user && $current_user->is_customer && $current_user->customer && $current_user->customer->id )
1404 + ? intval( $current_user->customer->id )
1405 + : 0;
1406 + }
1407 +
1408 + /**
1409 + * All identities that can legitimately be claimed by whoever is making
1410 + * the current request. A visitor can hold more than one at a time, and
1411 + * can move between them mid-compose (a guest who attaches a file and
1412 + * then logs in or registers before submitting the ticket), so a claim
1413 + * is accepted when the recorded uploader matches any one of them.
1414 + *
1415 + * Identities:
1416 + * c:<customer id> anyone SupportCandy knows as a customer, which
1417 + * covers both registered WordPress users (a customer
1418 + * record is created for them on first sight) and
1419 + * guest-login (OTP) customers;
1420 + * g:<token> a random token in a cookie, issued on first upload,
1421 + * for a plain guest who has no customer record yet -
1422 + * a first time visitor only gets one when the ticket
1423 + * is finally submitted.
1424 + *
1425 + * @param boolean $create - whether to issue a guest token if none exists.
1426 + * @return array
1427 + */
1428 + public static function get_current_uploader_ids( $create = false ) {
1429 +
1430 + $ids = array();
1431 +
1432 + // Resolve the logged-in user's customer here rather than relying on
1433 + // the 'init' snapshot alone: on REST requests an application
1434 + // password is not authenticated until after 'init', so the snapshot
1435 + // is still empty at that point. get_current_user_id() reflects
1436 + // WordPress's own auth state and is unaffected by SupportCandy's
1437 + // change_current_user(), so it stays safe to trust.
1438 + $user_id = get_current_user_id();
1439 + if ( $user_id ) {
1440 +
1441 + $customer = WPSC_Customer::get_by_user_id( $user_id );
1442 + if ( ! $customer || ! $customer->id ) {
1443 + $user = get_userdata( $user_id );
1444 + $customer = $user ? WPSC_Customer::get_by_email( $user->user_email ) : false;
1445 + }
1446 +
1447 + if ( $customer && $customer->id ) {
1448 + $ids[] = 'c:' . $customer->id;
1449 + }
1450 + }
1451 +
1452 + // Guest login (OTP) keeps a customer record without a WordPress
1453 + // user, and is captured on 'init' before it can be reassigned.
1454 + if ( self::$session_customer_id && ! in_array( 'c:' . self::$session_customer_id, $ids, true ) ) {
1455 + $ids[] = 'c:' . self::$session_customer_id;
1456 + }
1457 +
1458 + $token = isset( $_COOKIE['wpsc_uploader'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['wpsc_uploader'] ) ) : '';
1459 + $token = preg_match( '/^[a-f0-9]{32}$/', $token ) ? $token : '';
1460 +
1461 + if ( ! $token && $create ) {
1462 +
1463 + $token = md5( wp_generate_password( 32, false ) . microtime() );
1464 + setcookie( 'wpsc_uploader', $token, time() + DAY_IN_SECONDS, COOKIEPATH ? COOKIEPATH : '/', COOKIE_DOMAIN, is_ssl(), true );
1465 + $_COOKIE['wpsc_uploader'] = $token;
1466 + }
1467 +
1468 + if ( $token ) {
1469 + $ids[] = 'g:' . $token;
1470 + }
1471 +
1472 + return $ids;
1473 + }
1474 +
1475 + /**
1476 + * Record the current requester as the uploader of the given attachment.
1477 + *
1478 + * @param WPSC_Attachment $attachment - attachment object.
1479 + * @return void
1480 + */
1481 + public static function set_uploader( $attachment ) {
1482 +
1483 + if ( ! $attachment || ! $attachment->id ) {
1484 + return;
1485 + }
1486 +
1487 + // Always issue the cookie too, so a visitor who changes identity
1488 + // mid-compose (guest who then logs in or registers) still holds the
1489 + // identity that was recorded here.
1490 + $uploader_ids = self::get_current_uploader_ids( true );
1491 + if ( ! $uploader_ids ) {
1492 + return;
1493 + }
1494 +
1495 + $attachment->uploaded_by = $uploader_ids[0];
1496 + $attachment->save();
1497 + }
1498 +
1499 + /**
1500 + * Whether the current requester is the one who uploaded this attachment.
1501 + *
1502 + * @param WPSC_Attachment $attachment - attachment object.
1503 + * @return boolean
1504 + */
1505 + public static function is_uploaded_by_current_user( $attachment ) {
1506 +
1507 + if ( ! $attachment || ! $attachment->id || ! $attachment->uploaded_by ) {
1508 + return false;
1509 + }
1510 +
1511 + foreach ( self::get_current_uploader_ids() as $uploader ) {
1512 + if ( hash_equals( (string) $attachment->uploaded_by, $uploader ) ) {
1513 + return true;
1514 + }
1515 + }
1516 +
1517 + return false;
1518 + }
1519 +
1520 + /**
1521 + * Single gate for binding (claiming) an attachment to a ticket.
1522 + *
1523 + * An attachment may only be bound when all of the following hold:
1524 + * - it exists;
1525 + * - it does not already carry a ticket id. Note this is deliberately
1526 + * independent of is_active: deleting a thread clears is_active but
1527 + * leaves ticket_id in place, and such an attachment must still be
1528 + * refused rather than becoming claimable again;
1529 + * - the requester is the one who uploaded it, proven either by the
1530 + * recorded uploader identity or by the per-attachment nonce handed
1531 + * out in the upload response.
1532 + *
1533 + * Every path that binds an attachment to a ticket must go through this.
1534 + *
1535 + * @param WPSC_Attachment $attachment - attachment object.
1536 + * @param string $nonce - optional per-attachment upload nonce.
1537 + * @return boolean
1538 + */
1539 + public static function can_claim( $attachment, $nonce = '' ) {
1540 +
1541 + if ( ! $attachment || ! $attachment->id || $attachment->ticket_id ) {
1542 + return false;
1543 + }
1544 +
1545 + if ( self::is_uploaded_by_current_user( $attachment ) ) {
1546 + return true;
1547 + }
1548 +
1549 + // Fall back to the upload nonce, which is only ever handed to the
1550 + // uploader in the response to their own upload request.
1551 + $nonce = $nonce ? $nonce : self::get_submitted_upload_nonce( $attachment->id );
1552 + return $nonce && wp_verify_nonce( $nonce, self::get_img_editor_tmp_nonce_action( $attachment->id ) ) ? true : false;
1553 + }
1554 +
1555 + /**
1556 + * Read the upload nonce submitted alongside an attachment id, if any.
1557 + * Sent by the upload widgets as wpsc_attachment_token[<id>].
1558 + *
1559 + * @param int $attachment_id - attachment id.
1560 + * @return string
1561 + */
1562 + public static function get_submitted_upload_nonce( $attachment_id ) {
1563 +
1564 + if ( ! isset( $_REQUEST['wpsc_attachment_token'] ) || ! is_array( $_REQUEST['wpsc_attachment_token'] ) ) { // phpcs:ignore
1565 + return '';
1566 + }
1567 +
1568 + $tokens = wp_unslash( $_REQUEST['wpsc_attachment_token'] ); // phpcs:ignore
1569 + return isset( $tokens[ $attachment_id ] ) ? sanitize_text_field( $tokens[ $attachment_id ] ) : '';
1570 + }
1571 +
1572 + /**
1573 + * Nonce action used to gate access to a not-yet-attached (img_editor_tmp)
1574 + * in-editor image, since it isn't yet tied to any ticket/auth-code.
1575 + * Also used, once the attachment is referenced from a reply/ticket body,
1576 + * as proof that the requester is the one who uploaded it before it is
1577 + * bound (claimed) to that ticket. Public: shared by reply/new-ticket
1578 + * handlers in other classes that perform that claim.
1579 + *
1580 + * @param int $attachment_id - attachment id.
1581 + * @return string
1582 + */
1583 + public static function get_img_editor_tmp_nonce_action( $attachment_id ) {
1584 + return 'wpsc_view_img_editor_tmp_' . $attachment_id;
1336 1585 }
1337 1586 }
1338 1587 endif;
1339 1588