diff --git a/app/helper/RTMediaModel.php b/app/helper/RTMediaModel.php index 88a12a2d5..c3221d240 100755 --- a/app/helper/RTMediaModel.php +++ b/app/helper/RTMediaModel.php @@ -91,7 +91,7 @@ public function get( $columns, $offset = false, $per_page = false, $order_by = ' } else { $join .= " LEFT JOIN {$wpdb->prefix}{$this->meta_table_name} as {$tbl_alias} ON {$this->table_name}.id = {$tbl_alias}.media_id "; } - $meta_query['compare'] = esc_sql( $meta_query['compare'] ); + $meta_query['compare'] = $this->sanitize_sql_compare_operator( $meta_query['compare'], '=' ); if ( isset( $meta_query['value'] ) ) { $where .= $wpdb->prepare( " AND ({$tbl_alias}.meta_key = %s and {$tbl_alias}.meta_value {$meta_query["compare"]} %s ) ", $meta_query['key'], $meta_query['value'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared } else { @@ -105,14 +105,15 @@ public function get( $columns, $offset = false, $per_page = false, $order_by = ' $compare = $colvalue['compare']; } + $compare = $this->sanitize_sql_compare_operator( $compare ); + $tmp_val = isset( $colvalue['value'] ) ? $colvalue['value'] : $colvalue; $col_val_comapare = ( is_array( $tmp_val ) ) ? implode( "','", esc_sql( $tmp_val ) ) : esc_sql( $tmp_val ); if ( 'IS NOT' === $compare ) { - $col_val_comapare = ! empty( $colvalue['value'] ) ? $colvalue['value'] : $col_val_comapare; + $col_val_comapare = ! empty( $colvalue['value'] ) ? esc_sql( $colvalue['value'] ) : $col_val_comapare; } - $compare = esc_sql( $compare ); $where .= " AND {$this->table_name}.{$colname} {$compare} ('{$col_val_comapare}')"; } else { $where .= $wpdb->prepare( " AND {$this->table_name}.{$colname} = %s", $colvalue ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -404,10 +405,10 @@ public function get_counts( $user_id = false, $where_query = false ) { } else { $compare = $colvalue['compare']; } + $compare = $this->sanitize_sql_compare_operator( $compare ); if ( ! isset( $colvalue['value'] ) ) { $colvalue['value'] = $colvalue; } - $compare = esc_sql( $compare ); $where_query_sql .= " AND {$this->table_name}.{$colname} {$compare} ('" . implode( "','", esc_sql( $colvalue['value'] ) ) . "')"; } else { $where_query_sql .= $wpdb->prepare( " AND {$this->table_name}.{$colname} = %s", $colvalue ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared diff --git a/app/helper/db/RTDBModel.php b/app/helper/db/RTDBModel.php index a9c02229d..9257f8886 100644 --- a/app/helper/db/RTDBModel.php +++ b/app/helper/db/RTDBModel.php @@ -181,6 +181,24 @@ public function update( $data, $where ) { return $wpdb->update( $this->table_name, $data, $where ); // phpcs:ignore } + /** + * Validate a SQL comparison operator against an allowlist. + * + * The operator is interpolated directly into query strings, so it must be + * restricted to a fixed set of known-safe operators to prevent SQL injection. + * + * @param string $compare Operator supplied by the caller. + * @param string $default Fallback operator when $compare is not allowed. + * + * @return string Safe SQL comparison operator. + */ + public function sanitize_sql_compare_operator( $compare, $default = 'IN' ) { + $allowed = array( '=', '!=', '<>', '>', '>=', '<', '<=', 'IN', 'NOT IN', 'LIKE', 'IS', 'IS NOT' ); + $compare = strtoupper( trim( (string) $compare ) ); + + return in_array( $compare, $allowed, true ) ? $compare : $default; + } + /** * Get all the rows according to the columns set in $columns parameter. * offset and rows per page can also be passed for pagination. @@ -206,10 +224,11 @@ public function get( $columns, $offset = false, $per_page = false, $order_by = ' } else { $compare = $colvalue['compare']; } + $compare = $this->sanitize_sql_compare_operator( $compare ); if ( ! isset( $colvalue['value'] ) ) { - $colvalue['value'] = esc_sql( $colvalue ); + $colvalue['value'] = $colvalue; } - $col_val_comapare = ( is_array( $colvalue['value'] ) ) ? '(\'' . implode( "','", $colvalue['value'] ) . '\')' : '(\'' . $colvalue['value'] . '\')'; + $col_val_comapare = ( is_array( $colvalue['value'] ) ) ? '(\'' . implode( "','", esc_sql( $colvalue['value'] ) ) . '\')' : '(\'' . esc_sql( $colvalue['value'] ) . '\')'; $where .= " AND {$this->table_name}.{$colname} {$compare} {$col_val_comapare}"; } else { $where .= $wpdb->prepare( " AND {$this->table_name}.{$colname} = %s", $colvalue ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared diff --git a/app/main/controllers/api/RTMediaJsonApi.php b/app/main/controllers/api/RTMediaJsonApi.php index f16426139..dbda4949f 100644 --- a/app/main/controllers/api/RTMediaJsonApi.php +++ b/app/main/controllers/api/RTMediaJsonApi.php @@ -1233,6 +1233,18 @@ public function rtmedia_api_process_rtmedia_upload_media_request() { $uploaded['description'] = $description; $uploaded['taxonomy'] = array(); $uploaded['custom_fields'] = array(); + + // Verify the token user may write to the requested album/context before adding. + $upload_model = new RTMediaUploadModel(); + $upload_model->upload = array_merge( $upload_model->upload, $uploaded ); + $upload_model->authorize_targets(); + $uploaded['album_id'] = $upload_model->upload['album_id']; + $uploaded['context'] = $upload_model->upload['context']; + $uploaded['context_id'] = $upload_model->upload['context_id']; + if ( ! in_array( intval( $uploaded['privacy'] ), array( 0, 20, 40, 60 ), true ) ) { + $uploaded['privacy'] = get_rtmedia_default_privacy(); + } + $rtmedia = new RTMediaMedia(); $rtupload = $rtmedia->add( $uploaded, $new_look ); $id = rtmedia_media_id( $rtupload[0] ); @@ -1447,6 +1459,11 @@ public function rtmedia_api_process_rtmedia_get_media_details_request() { ); $media = $rtmediamodel->get( $args ); } + + if ( ! empty( $media ) && ! $this->rtmedia_api_current_user_can_view_media( $media[0] ) ) { + wp_send_json( $this->rtmedia_api_response_object( 'FALSE', $this->ec_invalid_media_id, $this->msg_invalid_media_id ) ); + } + $activity_id = ! empty( $media ) ? $media[0]->activity_id : ''; if ( empty( $activity_id ) ) { @@ -1454,11 +1471,66 @@ public function rtmedia_api_process_rtmedia_get_media_details_request() { } $media_single = $this->rtmediajsonapifunction->rtmedia_api_get_feed( false, $activity_id ); + // This endpoint returns a single media item; drop any sibling media sharing the + // same activity so another user's media is never exposed alongside the requested one. + if ( ! empty( $media_single[0]['media'] ) && is_array( $media_single[0]['media'] ) ) { + $requested_media = array(); + foreach ( $media_single[0]['media'] as $item ) { + if ( isset( $item['id'] ) && (int) $item['id'] === (int) $media_id ) { + $requested_media[] = $item; + } + } + $media_single[0]['media'] = $requested_media; + + // Re-scope comments to the requested media; get_feed sets them from the + // activity's first media, which may belong to another user. + if ( isset( $media_single[0]['comments'] ) ) { + $media_single[0]['comments'] = $this->rtmediajsonapifunction->rtmedia_api_get_media_comments( $media_id ); + } + } + if ( $media_single ) { wp_send_json( $this->rtmedia_api_response_object( 'TRUE', $ec_single_media, $msg_single_media, $media_single ) ); } } + /** + * Check whether the token-authenticated API user may view a media item. + * + * Mirrors RTMediaQuery::privacy_filter(), which is not registered during the + * API request lifecycle, so private media must be gated explicitly here. + * + * @param object $media Media row returned by RTMediaModel::get(). + * + * @return bool True if the current API user may view the media. + */ + private function rtmedia_api_current_user_can_view_media( $media ) { + $privacy = isset( $media->privacy ) && null !== $media->privacy ? (int) $media->privacy : 0; + $author = isset( $media->media_author ) ? (int) $media->media_author : 0; + $user = (int) $this->user_id; + + if ( $user && user_can( $user, 'list_users' ) ) { + return true; + } + // Public is exactly NULL or 0; values such as -1 / 80 are blocked, not public. + if ( 0 === $privacy ) { + return true; + } + if ( $user ) { + if ( 20 === $privacy ) { + return true; + } + if ( $author === $user && $privacy >= 40 ) { + return true; + } + if ( 40 === $privacy && function_exists( 'friends_check_friendship' ) && friends_check_friendship( $author, $user ) ) { + return true; + } + } + + return false; + } + /** * Function to log out user from api. */ diff --git a/app/main/controllers/api/RTMediaJsonApiFunctions.php b/app/main/controllers/api/RTMediaJsonApiFunctions.php index 7c75462ce..e105b97fb 100644 --- a/app/main/controllers/api/RTMediaJsonApiFunctions.php +++ b/app/main/controllers/api/RTMediaJsonApiFunctions.php @@ -348,13 +348,14 @@ public function rtmedia_api_get_media_comments( $media_id ) { $comments = get_comments( array( - 'comment_post_ID' => $id, - 'number' => 100, + 'post_id' => $id, + 'number' => 100, ) ); $media_comments = array(); if ( ! empty( $comments ) ) { + $media_comments['user'] = array(); foreach ( $comments as $comment ) { $media_comments['comments'][] = array( diff --git a/app/main/controllers/upload/RTMediaUploadEndpoint.php b/app/main/controllers/upload/RTMediaUploadEndpoint.php index 8567b9562..2107c4257 100755 --- a/app/main/controllers/upload/RTMediaUploadEndpoint.php +++ b/app/main/controllers/upload/RTMediaUploadEndpoint.php @@ -178,6 +178,11 @@ public function template_redirect( $create_activity = true ) { } // End if. $this->upload = apply_filters( 'rtmedia_media_param_before_upload', $this->upload ); + + $model->upload = $this->upload; + $model->authorize_targets(); + $this->upload = $model->upload; + $rtupload = new RTMediaUpload( $this->upload ); if ( $comment_media ) { diff --git a/app/main/controllers/upload/RTMediaUploadModel.php b/app/main/controllers/upload/RTMediaUploadModel.php index 6db5434db..4411c87aa 100755 --- a/app/main/controllers/upload/RTMediaUploadModel.php +++ b/app/main/controllers/upload/RTMediaUploadModel.php @@ -66,6 +66,14 @@ public function has_context() { */ public function sanitize_object() { + // Never trust a client-supplied author: force the acting user. On the token API + // (no WP session) keep the author the API layer has already set. + if ( is_user_logged_in() ) { + $this->upload['media_author'] = get_current_user_id(); + } elseif ( ! $this->has_author() ) { + $this->set_author(); + } + if ( ! $this->has_context() ) { // Set context_id to Logged in user id if context is profile and context_id is not provided. if ( 'profile' === $this->upload['context'] || 'bp_member' === $this->upload['context'] ) { @@ -79,6 +87,10 @@ public function sanitize_object() { } } + // Verify the acting user may write to the requested context and album; otherwise + // fall back to safe defaults (own profile / own default album). + $this->authorize_targets(); + if ( ! is_array( $this->upload['taxonomy'] ) ) { $this->upload['taxonomy'] = array( $this->upload['taxonomy'] ); } @@ -87,14 +99,6 @@ public function sanitize_object() { $this->upload['custom_fields'] = array( $this->upload['custom_fields'] ); } - if ( ! $this->has_album_id() || ! $this->has_album_permissions() ) { - $this->set_album_id(); - } - - if ( ! $this->has_author() ) { - $this->set_author(); - } - if ( is_rtmedia_privacy_enable() ) { if ( is_rtmedia_privacy_user_overide() ) { @@ -112,6 +116,11 @@ public function sanitize_object() { } else { $this->upload['privacy'] = 0; } + + // Restrict privacy to the known set; -1/80 are moderation states, not user-settable. + if ( ! in_array( intval( $this->upload['privacy'] ), array( 0, 20, 40, 60 ), true ) ) { + $this->upload['privacy'] = get_rtmedia_default_privacy(); + } } /** @@ -149,8 +158,88 @@ public function has_album_id() { * @return boolean */ public function has_album_permissions() { - // yet to be coded for the privacy options of the album. - return true; + $album_id = $this->upload['album_id']; + if ( ! $album_id || 'undefined' === $album_id ) { + return false; + } + + $user = intval( $this->upload['media_author'] ); + + // Site admins bypass, matching rtMedia's is_rt_admin() ( list_users ) convention. + if ( $user && user_can( $user, 'list_users' ) ) { + return true; + } + + // The shared global "wall post" albums are a valid destination for everyone. + if ( function_exists( 'rtmedia_global_albums' ) ) { + $globals = array_map( 'intval', (array) rtmedia_global_albums() ); + if ( in_array( intval( $album_id ), $globals, true ) ) { + return true; + } + } + + $model = new RTMediaModel(); + $album = $model->get( array( 'id' => $album_id ) ); + if ( empty( $album ) ) { + return false; + } + $album = $album[0]; + + // The album owner may add to their own album. + if ( $user && intval( $album->media_author ) === $user ) { + return true; + } + + // Group albums: users who may post in the owning group may add. + if ( 'group' === $album->context ) { + return $this->can_user_upload_in_target_group( $user, intval( $album->context_id ) ); + } + + return false; + } + + /** + * Validate the upload's context and album against the acting user, falling back to + * safe defaults (the user's own profile / default album) when not permitted. + */ + public function authorize_targets() { + $user = intval( $this->upload['media_author'] ); + + if ( 'profile' === $this->upload['context'] || 'bp_member' === $this->upload['context'] ) { + // A user may only upload to their own profile. + $this->upload['context'] = 'profile'; + $this->upload['context_id'] = $user; + } elseif ( 'group' === $this->upload['context'] ) { + // Only users who may post in the target group may upload into it. + if ( ! $this->can_user_upload_in_target_group( $user, intval( $this->upload['context_id'] ) ) ) { + $this->upload['context'] = 'profile'; + $this->upload['context_id'] = $user; + } + } + + if ( ! $this->has_album_id() || ! $this->has_album_permissions() ) { + $this->set_album_id(); + } + } + + /** + * Whether a user may upload into a given BuddyPress group (members, or site admins). + * + * @param int $user User id. + * @param int $group_id Group id. + * + * @return boolean + */ + private function can_user_upload_in_target_group( $user, $group_id ) { + if ( $user && user_can( $user, 'list_users' ) ) { + return true; + } + + $allowed = ( $user && $group_id && function_exists( 'groups_is_user_member' ) ) + ? (bool) groups_is_user_member( $user, $group_id ) + : false; + + return (bool) apply_filters( 'rtm_can_user_upload_in_group', $allowed, $group_id, $user ); } /** diff --git a/app/main/routers/query/RTMediaQuery.php b/app/main/routers/query/RTMediaQuery.php index c7ed00101..ac603fae7 100755 --- a/app/main/routers/query/RTMediaQuery.php +++ b/app/main/routers/query/RTMediaQuery.php @@ -610,7 +610,7 @@ public function &query( $query ) { if ( ! empty( $rtmedia_shortcode ) ) { $query_data = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.NoNonceVerification -- We are just replacing the args so nonce verification is not needed. foreach ( $query_data as $key => $val ) { - if ( ! in_array( $key, $allowed_query, true ) ) { + if ( ! in_array( $key, $allowed_query, true ) || is_array( $val ) ) { unset( $query_data[ $key ] ); } }