Issues (102)

Security Analysis    no vulnerabilities found

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  Header Injection
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

plugin/Controllers/ReviewController.php (1 issue)

Labels
Severity
1
<?php
2
3
namespace GeminiLabs\SiteReviews\Controllers;
4
5
use GeminiLabs\SiteReviews\Commands\AssignPosts;
6
use GeminiLabs\SiteReviews\Commands\AssignTerms;
7
use GeminiLabs\SiteReviews\Commands\AssignUsers;
8
use GeminiLabs\SiteReviews\Commands\CreateReview;
9
use GeminiLabs\SiteReviews\Commands\ToggleStatus;
10
use GeminiLabs\SiteReviews\Commands\UnassignPosts;
11
use GeminiLabs\SiteReviews\Commands\UnassignTerms;
12
use GeminiLabs\SiteReviews\Commands\UnassignUsers;
13
use GeminiLabs\SiteReviews\Database;
14
use GeminiLabs\SiteReviews\Database\Cache;
15
use GeminiLabs\SiteReviews\Database\CountManager;
16
use GeminiLabs\SiteReviews\Database\Query;
17
use GeminiLabs\SiteReviews\Database\ReviewManager;
18
use GeminiLabs\SiteReviews\Defaults\RatingDefaults;
19
use GeminiLabs\SiteReviews\Helper;
20
use GeminiLabs\SiteReviews\Helpers\Arr;
21
use GeminiLabs\SiteReviews\Helpers\Cast;
22
use GeminiLabs\SiteReviews\Metaboxes\ResponseMetabox;
23
use GeminiLabs\SiteReviews\Modules\Avatar;
24
use GeminiLabs\SiteReviews\Modules\Html\ReviewHtml;
25
use GeminiLabs\SiteReviews\Modules\Queue;
26
use GeminiLabs\SiteReviews\Request;
27
use GeminiLabs\SiteReviews\Review;
28
29
class ReviewController extends AbstractController
30
{
31
    /**
32
     * @param \WP_Post[] $posts
33
     *
34
     * @return \WP_Post[]
35
     *
36
     * @filter the_posts
37
     */
38
    public function filterPostsToCacheReviews(array $posts): array
39
    {
40
        $reviews = array_filter($posts, fn ($post) => glsr()->post_type === $post->post_type);
41
        if ($postIds = wp_list_pluck($reviews, 'ID')) {
42
            glsr(Query::class)->reviews([], $postIds); // this caches the associated Review objects
43
        }
44
        return $posts;
45
    }
46
47
    /**
48
     * @filter wp_insert_post_data
49
     */
50 28
    public function filterReviewPostData(array $data, array $sanitized): array
51
    {
52 28
        if (empty($sanitized['ID']) || empty($sanitized['action']) || glsr()->post_type !== Arr::get($sanitized, 'post_type')) {
53 28
            return $data;
54
        }
55
        if (!empty(filter_input(INPUT_GET, 'bulk_edit'))) {
56
            if (is_numeric(filter_input(INPUT_GET, 'post_author'))) {
57
                $data['post_author'] = filter_input(INPUT_GET, 'post_author');
58
            } else {
59
                unset($data['post_author']);
60
            }
61
        }
62
        if (is_numeric(filter_input(INPUT_POST, 'post_author_override'))) {
63
            // use the value from the author meta box
64
            $data['post_author'] = filter_input(INPUT_POST, 'post_author_override');
65
        }
66
        return $data;
67
    }
68
69
    /**
70
     * @filter site-reviews/rendered/template/review
71
     */
72 8
    public function filterReviewTemplate(string $template, array $data): string
73
    {
74 8
        $search = 'id="review-';
75 8
        $dataType = Arr::get($data, 'review.type', 'local');
76 8
        $replace = sprintf('data-type="%s" %s', $dataType, $search);
77 8
        if (Arr::get($data, 'review.is_pinned')) {
78
            $replace = 'data-pinned="1" '.$replace;
79
        }
80 8
        if (Arr::get($data, 'review.is_verified')) {
81
            $replace = 'data-verified="1" '.$replace;
82
        }
83 8
        return str_replace($search, $replace, $template);
84
    }
85
86
    /**
87
     * @filter site-reviews/query/sql/clause/operator
88
     */
89 8
    public function filterSqlClauseOperator(string $operator): string
90
    {
91 8
        $operators = ['loose' => 'OR', 'strict' => 'AND'];
92 8
        return Arr::get($operators, glsr_get_option('reviews.assignment', 'strict', 'string'), $operator);
93
    }
94
95
    /**
96
     * @filter site-reviews/review/build/after
97
     */
98 8
    public function filterTemplateTags(array $tags, Review $review, ReviewHtml $reviewHtml): array
99
    {
100 8
        $tags['assigned_links'] = $reviewHtml->buildTemplateTag($review, 'assigned_links', $review->assigned_posts);
101 8
        return $tags;
102
    }
103
104
    /**
105
     * Triggered after one or more categories are added or removed from a review.
106
     *
107
     * @action set_object_terms
108
     */
109 28
    public function onAfterChangeAssignedTerms(
110
        int $postId,
111
        array $terms,
112
        array $newTTIds,
113
        string $taxonomy,
114
        bool $append,
115
        array $oldTTIds
116
    ): void {
117 28
        if (Review::isReview($postId)) {
118 24
            $review = glsr(ReviewManager::class)->get($postId);
119 24
            $diff = $this->getAssignedDiffs($oldTTIds, $newTTIds);
120 24
            $this->execute(new UnassignTerms($review, $diff['old']));
121 24
            $this->execute(new AssignTerms($review, $diff['new']));
122
        }
123
    }
124
125
    /**
126
     * Triggered when a post status changes or when a review is approved|unapproved|trashed.
127
     *
128
     * @action transition_post_status
129
     */
130 28
    public function onAfterChangeStatus(string $new, string $old, ?\WP_Post $post): void
131
    {
132 28
        if (is_null($post)) {
133 28
            return; // This should never happen, but some plugins are bad actors so...
134
        }
135
        if (in_array($old, ['new', $new])) {
136
            return;
137
        }
138
        if (Review::isReview($post)) {
139
            $isAutoDraft = 'auto-draft' === $old && 'auto-draft' !== $new;
140
            if ($isAutoDraft) {
141
                glsr(ReviewManager::class)->createFromPost($post->ID);
142
            }
143
            $isPublished = 'publish' === $new;
144
            glsr(ReviewManager::class)->updateRating($post->ID, ['is_approved' => $isPublished]);
145
            glsr(Cache::class)->delete($post->ID, 'reviews');
146
            glsr(CountManager::class)->recalculate();
147
            if ($isAutoDraft) {
148
                return;
149
            }
150
            $review = glsr_get_review($post->ID);
151
            if ('publish' === $new) {
152
                glsr()->action('review/approved', $review, $old, $new);
153
            } elseif ('pending' === $new) {
154
                glsr()->action('review/unapproved', $review, $old, $new);
155
            } elseif ('trash' === $new) {
156
                glsr()->action('review/trashed', $review, $old, $new);
157
            }
158
            glsr()->action('review/transitioned', $review, $new, $old);
159
        } else {
160
            glsr(ReviewManager::class)->updateAssignedPost($post->ID);
161
        }
162
    }
163
164
    /**
165
     * Fallback action if ajax is not working for any reason.
166
     *
167
     * @action admin_action_approve
168
     */
169
    public function onApprove(): void
170
    {
171
        if (glsr()->id === filter_input(INPUT_GET, 'plugin')) {
172
            check_admin_referer('approve-review_'.($postId = $this->getPostId()));
173
            $this->execute(new ToggleStatus(new Request([
174
                'post_id' => $postId,
175
                'status' => 'publish',
176
            ])));
177
            wp_safe_redirect(wp_get_referer());
178
            exit;
179
        }
180
    }
181
182
    /**
183
     * Triggered when a review's assigned post IDs are updated.
184
     *
185
     * @action site-reviews/review/updated/post_ids
186
     */
187
    public function onChangeAssignedPosts(Review $review, array $postIds = []): void
188
    {
189
        $diff = $this->getAssignedDiffs($review->assigned_posts, $postIds);
190
        $this->execute(new UnassignPosts($review, $diff['old']));
191
        $this->execute(new AssignPosts($review, $diff['new']));
192
    }
193
194
    /**
195
     * Triggered when a review's assigned users IDs are updated.
196
     *
197
     * @action site-reviews/review/updated/user_ids
198
     */
199
    public function onChangeAssignedUsers(Review $review, array $userIds = []): void
200
    {
201
        $diff = $this->getAssignedDiffs($review->assigned_users, $userIds);
202
        $this->execute(new UnassignUsers($review, $diff['old']));
203
        $this->execute(new AssignUsers($review, $diff['new']));
204
    }
205
206
    /**
207
     * Triggered after a review is created.
208 24
     *
209
     * @action site-reviews/review/created
210 24
     */
211 24
    public function onCreatedReview(Review $review, CreateReview $command): void
212
    {
213
        $this->execute(new AssignPosts($review, $command->assigned_posts));
214
        $this->execute(new AssignUsers($review, $command->assigned_users));
215
    }
216
217
    /**
218
     * Triggered when a review is created.
219 24
     *
220
     * @action site-reviews/review/create
221 24
     */
222 24
    public function onCreateReview(int $postId, CreateReview $command): void
223 24
    {
224 24
        $values = glsr()->args($command->toArray()); // this filters the values
225 24
        $data = glsr(RatingDefaults::class)->restrict($values->toArray());
226
        $data['review_id'] = $postId;
227
        $data['is_approved'] = 'publish' === get_post_status($postId);
228
        if (false === glsr(Database::class)->insert('ratings', $data)) {
229
            glsr_log()->error('A review could not be created. Here are some things to try which may fix the problem:'.
230
                PHP_EOL.'1. First, deactivate Site Reviews and then reactivate it (this should fix any broken database table indexes).'.
231
                PHP_EOL.'2. Next, hold down the ALT key (Option key if using a Mac) and run the Migrate Plugin tool.'.
232
                PHP_EOL.'3. Finally, run the "Repair Review Relations" tool.'.
233
                PHP_EOL.'4. If the problem persists, please use the "Contact Support" section on the Help page.'
234
            );
235
            glsr_log()->debug($data);
236 24
            wp_delete_post($postId, true); // remove post as review was not created
237 24
            return;
238
        }
239
        $termIds = wp_set_object_terms($postId, $values->assigned_terms, glsr()->taxonomy);
240 24
        if (is_wp_error($termIds)) {
241
            glsr_log()->error($termIds->get_error_message());
242
        }
243
        $excluded = Cast::toArray($command->request()->excluded);
0 ignored issues
show
The property excluded does not seem to exist on GeminiLabs\SiteReviews\Request.
Loading history...
244 24
        if (!empty($excluded)) { // save the fields hidden in the review form
245
            glsr(Database::class)->metaSet($postId, 'excluded', $excluded);
246
        }
247
        if (!empty($values->response)) { // save the response if one is provided
248
            glsr(Database::class)->metaSet($postId, 'response', $values->response);
249
            glsr(Database::class)->metaSet($postId, 'response_by', $values->response_by); // @phpstan-ignore-line
250
        }
251
        foreach ($values->custom as $key => $value) {
252
            glsr(Database::class)->metaSet($postId, "custom_{$key}", $value);
253
        }
254
    }
255
256
    /**
257
     * Triggered when a review or other post type is deleted and the posts table uses the MyISAM engine.
258
     *
259
     * @action deleted_post
260
     */
261
    public function onDeletePost(int $postId, \WP_Post $post): void
262
    {
263
        if (glsr()->post_type === $post->post_type) {
264
            $this->onDeleteReview($postId);
265
            return;
266
        }
267
        $reviewIds = glsr(Query::class)->reviewIds([
268
            'assigned_posts' => $postId,
269
            'per_page' => -1,
270
            'status' => 'all',
271
        ]);
272
        if (glsr(Database::class)->delete('assigned_posts', ['post_id' => $postId])) {
273
            array_walk($reviewIds, function ($reviewId) {
274
                glsr(Cache::class)->delete($reviewId, 'reviews');
275
            });
276
        }
277
    }
278
279
    /**
280
     * Triggered when a review is deleted and the posts table uses the MyISAM engine.
281
     *
282
     * @see $this->onDeletePost()
283
     */
284
    public function onDeleteReview(int $reviewId): void
285
    {
286
        glsr(ReviewManager::class)->deleteRating($reviewId);
287
    }
288
289
    /**
290
     * Triggered when a user is deleted and the users table uses the MyISAM engine.
291
     *
292
     * @action deleted_user
293
     */
294
    public function onDeleteUser(int $userId = 0): void
295
    {
296
        $reviewIds = glsr(Query::class)->reviewIds([
297
            'assigned_users' => $userId,
298
            'per_page' => -1,
299
            'status' => 'all',
300
        ]);
301
        if (glsr(Database::class)->delete('assigned_users', ['user_id' => $userId])) {
302
            array_walk($reviewIds, function ($reviewId) {
303
                glsr(Cache::class)->delete($reviewId, 'reviews');
304
            });
305
        }
306
    }
307
308
    /**
309
     * Triggered when a review is edited or trashed.
310
     * It's unnecessary to trigger a term recount as this is done by the set_object_terms hook
311
     * We need to use "post_updated" to support revisions (vs "save_post").
312
     *
313
     * @action post_updated
314
     */
315
    public function onEditReview(int $postId, ?\WP_Post $post, ?\WP_Post $oldPost): void
316
    {
317
        if (is_null($post) || is_null($oldPost)) {
318
            return; // This should never happen, but some plugins are bad actors so...
319
        }
320
        if (!glsr()->can('edit_posts') || !$this->isEditedReview($post, $oldPost)) {
321
            return;
322
        }
323
        if (glsr()->id === filter_input(INPUT_GET, 'plugin')) {
324
            return; // the fallback approve/unapprove action is being run
325
        }
326
        if (!in_array(glsr_current_screen()->base, ['edit', 'post'])) {
327
            return; // only trigger this action from the Site Reviews edit/post screens
328
        }
329
        $review = glsr(ReviewManager::class)->get($postId);
330
        if ('edit' === glsr_current_screen()->base) {
331
            $this->bulkUpdateReview($review, $oldPost);
332
        } else {
333
            $this->updateReview($review, $oldPost);
334
        }
335
    }
336
337
    /**
338
     * Fallback action if ajax is not working for any reason.
339
     *
340
     * @action admin_action_unapprove
341
     */
342
    public function onUnapprove(): void
343
    {
344
        if (glsr()->id === filter_input(INPUT_GET, 'plugin')) {
345
            $postId = $this->getPostId();
346
            check_admin_referer("unapprove-review_{$postId}");
347
            $this->execute(new ToggleStatus(new Request([
348
                'post_id' => $postId,
349 24
                'status' => 'publish',
350
            ])));
351 24
            wp_safe_redirect(wp_get_referer());
352
            exit;
353
        }
354 24
    }
355 24
356
    /**
357
     * @action site-reviews/review/created
358
     */
359
    public function sendNotification(Review $review): void
360
    {
361
        if (defined('WP_IMPORTING')) {
362
            return;
363
        }
364
        if (empty(glsr_get_option('general.notifications'))) {
365
            return;
366
        }
367
        if (!in_array($review->status, ['pending', 'publish'])) {
368
            return; // this review is likely a draft made in the wp-admin
369
        }
370
        glsr(Queue::class)->async('queue/notification', ['review_id' => $review->ID]);
371
    }
372
373
    protected function bulkUpdateReview(Review $review, \WP_Post $oldPost): void
374
    {
375 24
        if ($assignedPostIds = filter_input(INPUT_GET, 'post_ids', FILTER_SANITIZE_NUMBER_INT, FILTER_FORCE_ARRAY)) {
376
            glsr()->action('review/updated/post_ids', $review, Cast::toArray($assignedPostIds)); // trigger a recount of assigned posts
377 24
        }
378 24
        if ($assignedUserIds = filter_input(INPUT_GET, 'user_ids', FILTER_SANITIZE_NUMBER_INT, FILTER_FORCE_ARRAY)) {
379 24
            glsr()->action('review/updated/user_ids', $review, Cast::toArray($assignedUserIds)); // trigger a recount of assigned users
380 24
        }
381 2
        $review = glsr(ReviewManager::class)->get($review->ID); // get a fresh copy of the review
382 2
        glsr()->action('review/updated', $review, [], $oldPost); // pass an empty array since review values are unchanged
383 2
    }
384
385 24
    protected function getAssignedDiffs(array $existing, array $replacements): array
386 24
    {
387 24
        sort($existing);
388 24
        sort($replacements);
389
        $new = $old = [];
390
        if ($existing !== $replacements) {
391
            $ignored = array_intersect($existing, $replacements);
392
            $new = array_diff($replacements, $ignored);
393
            $old = array_diff($existing, $ignored);
394
        }
395
        return [
396
            'new' => $new,
397
            'old' => $old,
398
        ];
399
    }
400
401
    protected function isEditedReview(\WP_Post $post, \WP_Post $oldPost): bool
402
    {
403
        if (glsr()->post_type !== $post->post_type) {
404
            return false;
405
        }
406
        if (in_array('trash', [$post->post_status, $oldPost->post_status])) {
407
            return false; // trashed posts cannot be edited
408
        }
409
        $input = 'edit' === glsr_current_screen()->base ? INPUT_GET : INPUT_POST;
410
        return filter_input($input, 'action') !== glsr()->prefix.'admin_action'; // abort if not a proper post update (i.e. approve/unapprove)
411
    }
412
413
    protected function refreshAvatar(array $data, Review $review): string
414
    {
415
        $avatarUrl = Cast::toString($data['avatar'] ?? '');
416
        if ($review->author === ($data['name'] ?? false)) {
417
            return $avatarUrl;
418
        }
419
        $url = preg_replace('/(.*)\/site-reviews\/avatars\/[\p{L&}]+\.svg$/u', '', $avatarUrl);
420
        if (empty($url)) { // only update the initials fallback avatar
421
            $review->set('author', $data['name'] ?? '');
422
            $avatarUrl = glsr(Avatar::class)->generateInitials($review);
423
        }
424
        return $avatarUrl;
425
    }
426
427
    /**
428
     * This is run after editing a review in the admin.
429
     */
430
    protected function updateReview(Review $review, \WP_Post $oldPost): void
431
    {
432
        $customDefaults = array_fill_keys(array_keys($review->custom()->toArray()), '');
433
        $data = Helper::filterInputArray(glsr()->id);
434
        $data = wp_parse_args($data, $customDefaults); // this ensures we save all empty custom values
435
        if (Arr::get($data, 'is_editing_review')) {
436
            $data['avatar'] = $this->refreshAvatar($data, $review);
437
            $data['rating'] ??= '';
438
            $data['terms'] ??= 0;
439
        }
440
        if (Arr::getAs('bool', $data, 'is_pinned') === $review->is_pinned) {
441
            unset($data['is_pinned']);
442
        }
443
        if (Arr::getAs('bool', $data, 'is_verified') === $review->is_verified || !glsr()->filterBool('verification/enabled', false)) {
444
            unset($data['is_verified']);
445
        }
446
        if (!empty($data)) {
447
            glsr(ReviewManager::class)->updateCustom($review->ID, $data); // values are sanitized here
448
            glsr(ReviewManager::class)->updateRating($review->ID, $data); // values are sanitized here
449
            $review = glsr(ReviewManager::class)->get($review->ID); // get a fresh copy of the review
450
        }
451
        $assignedPostIds = filter_input(INPUT_POST, 'post_ids', FILTER_SANITIZE_NUMBER_INT, FILTER_FORCE_ARRAY);
452
        $assignedUserIds = filter_input(INPUT_POST, 'user_ids', FILTER_SANITIZE_NUMBER_INT, FILTER_FORCE_ARRAY);
453
        glsr()->action('review/updated/post_ids', $review, Cast::toArray($assignedPostIds)); // trigger a recount of assigned posts
454
        glsr()->action('review/updated/user_ids', $review, Cast::toArray($assignedUserIds)); // trigger a recount of assigned users
455
        glsr(ResponseMetabox::class)->save($review);
456
        $review = glsr(ReviewManager::class)->get($review->ID); // get a fresh copy of the review
457
        glsr()->action('review/updated', $review, $data, $oldPost);
458
    }
459
}
460