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 +227 -9 3.5.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.
@@ -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 );
@@ -1055,9 +1082,8 @@
1055 1082 if ( ! $attachment->id ) {
1056 1083 return;
1057 1084 }
1058 1085 $auth_code = isset($_REQUEST['auth_code']) ? sanitize_text_field( $_REQUEST['auth_code'] ) : ''; // phpcs:ignore
1059 - $advanced = get_option( 'wpsc-ms-advanced-settings' );
1060 1086 switch ( $attachment->source ) {
1061 1087
1062 1088 case 'cf':
1063 1089 $cf = new WPSC_Custom_Field( $attachment->source_id );
@@ -1092,9 +1118,9 @@
1092 1118 $ticket_class::$ticket = $ticket;
1093 1119 $has_access = (
1094 1120 ( $current_user->is_agent && $ticket_class::has_ticket_cap( 'view' ) ) ||
1095 1121 $ticket_class::is_customer() ||
1096 - ( ! $advanced['ticket-url-auth'] && $ticket->auth_code && hash_equals( (string) $ticket->auth_code, $auth_code ) )
1122 + ( $ticket->auth_code && hash_equals( (string) $ticket->auth_code, $auth_code ) )
1097 1123 );
1098 1124
1099 1125 if ( ! $has_access ) {
1100 1126 wp_send_json_error( __( 'Unauthorized!', 'supportcandy' ), 401 );
@@ -1147,9 +1173,9 @@
1147 1173 $ticket_class::$ticket = $ticket;
1148 1174 $has_access = (
1149 1175 ( $current_user->is_agent && $ticket_class::has_ticket_cap( 'view' ) ) ||
1150 1176 $ticket_class::is_customer() ||
1151 - ( ! $advanced['ticket-url-auth'] && $ticket->auth_code && hash_equals( (string) $ticket->auth_code, $auth_code ) )
1177 + ( $ticket->auth_code && hash_equals( (string) $ticket->auth_code, $auth_code ) )
1152 1178 );
1153 1179
1154 1180 if ( ! $has_access ) {
1155 1181 wp_send_json_error( __( 'Unauthorized!', 'supportcandy' ), 401 );
@@ -1223,12 +1249,17 @@
1223 1249 }
1224 1250 break;
1225 1251 case 'img_editor_tmp':
1226 1252 // Not yet attached to any ticket/thread, so there is no auth-code
1227 - // to check it against. Gate on the per-attachment nonce that was
1228 - // handed only to whoever uploaded it.
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.
1229 1259 $view_nonce = isset( $_REQUEST['wpsc_nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['wpsc_nonce'] ) ) : ''; // phpcs:ignore
1230 - if ( ! $view_nonce || ! wp_verify_nonce( $view_nonce, self::get_img_editor_tmp_nonce_action( $attachment->id ) ) ) {
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 ) ) ) ) {
1231 1262 wp_send_json_error( 'Unauthorized!', 401 );
1232 1263 }
1233 1264 self::file_download( $attachment );
1234 1265 }
@@ -1342,9 +1373,11 @@
1342 1373 }
1343 1374
1344 1375 // Temporary in-editor images have no ticket yet to check an auth-code
1345 1376 // against, so gate access with a per-attachment nonce instead, known
1346 - // only to whoever just uploaded it via this response.
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 );
1347 1380 $view_nonce = wp_create_nonce( self::get_img_editor_tmp_nonce_action( $attachment->id ) );
1348 1381
1349 1382 wp_send_json( array( 'imgURL' => home_url( '/' ) . '?wpsc_attachment=' . $attachment->id . '&wpsc_nonce=' . $view_nonce ) );
1350 1383
@@ -1355,15 +1388,200 @@
1355 1388 }
1356 1389 }
1357 1390
1358 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 + /**
1359 1573 * Nonce action used to gate access to a not-yet-attached (img_editor_tmp)
1360 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.
1361 1579 *
1362 1580 * @param int $attachment_id - attachment id.
1363 1581 * @return string
1364 1582 */
1365 - private static function get_img_editor_tmp_nonce_action( $attachment_id ) {
1583 + public static function get_img_editor_tmp_nonce_action( $attachment_id ) {
1366 1584 return 'wpsc_view_img_editor_tmp_' . $attachment_id;
1367 1585 }
1368 1586 }
1369 1587 endif;