Issues (196)

Security Analysis    6 potential vulnerabilities

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 (4)
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 (1)
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.
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.

src/Model/UserRights.php (8 issues)

Labels
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Model;
6
7
use App\Helper\I18nHelper;
8
use App\Repository\UserRightsRepository;
9
use DateInterval;
10
use DateTimeImmutable;
11
use Exception;
12
13
/**
14
 * An UserRights provides methods around parsing changes to a user's rights.
15
 */
16
class UserRights extends Model
17
{
18
    protected I18nHelper $i18n;
19
20
    /** @var string[] Rights changes, keyed by timestamp then 'added' and 'removed'. */
21
    protected array $rightsChanges;
22
23
    /** @var string[] Localized names of the rights. */
24
    protected array $rightsNames;
25
26
    /** @var string[] Global rights changes (log), keyed by timestamp then 'added' and 'removed'. */
27
    protected array $globalRightsChanges;
28
29
    /** @var array The current and former rights of the user. */
30
    protected array $rightsStates = [];
31
32
    /**
33
     * @param UserRightsRepository $repository
34
     * @param User $user
35
     */
36
    public function __construct(UserRightsRepository $repository, Project $project, User $user, I18nHelper $i18n)
37
    {
38
        $this->repository = $repository;
39
        $this->project = $project;
40
        $this->user = $user;
41
        $this->i18n = $i18n;
42
    }
43
44
    /**
45
     * Get user rights changes of the given user.
46
     * @param int|null $limit
47
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
48
     */
49
    public function getRightsChanges(?int $limit = null): array
50
    {
51
        if (!isset($this->rightsChanges)) {
52
            $logData = $this->repository->getRightsChanges($this->project, $this->user);
0 ignored issues
show
The method getRightsChanges() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

52
            /** @scrutinizer ignore-call */ 
53
            $logData = $this->repository->getRightsChanges($this->project, $this->user);
Loading history...
53
54
            $this->rightsChanges = $this->processRightsChanges($logData);
55
56
            $acDate = $this->getAutoconfirmedTimestamp();
57
            if (false !== $acDate) {
58
                $this->rightsChanges[$acDate] = [
59
                    'logId' => null,
60
                    'performer' => null,
61
                    'comment' => null,
62
                    'added' => ['autoconfirmed'],
63
                    'removed' => [],
64
                    'grantType' => strtotime($acDate) > time() ? 'pending' : 'automatic',
65
                    'type' => 'local',
66
                ];
67
                krsort($this->rightsChanges);
68
            }
69
        }
70
71
        return array_slice($this->rightsChanges, 0, $limit, true);
72
    }
73
74
    /**
75
     * Checks the user rights log to see whether the user is an admin or used to be one.
76
     * @return string|false One of false (never an admin), 'current' or 'former'.
77
     */
78
    public function getAdminStatus()
79
    {
80
        $rightsStates = $this->getRightsStates();
81
82
        if (in_array('sysop', $rightsStates['local']['current'])) {
83
            return 'current';
84
        } elseif (in_array('sysop', $rightsStates['local']['former'])) {
85
            return 'former';
86
        } else {
87
            return false;
88
        }
89
    }
90
91
    /**
92
     * Get a list of the current and former rights of the user.
93
     * @return array With keys 'local' and 'global', each with keys 'current' and 'former'.
94
     */
95
    public function getRightsStates(): array
96
    {
97
        if (count($this->rightsStates) > 0) {
98
            return $this->rightsStates;
99
        }
100
101
        foreach (['local', 'global'] as $type) {
102
            [$currentRights, $rightsChanges] = $this->getCurrentRightsAndChanges($type);
103
104
            $former = [];
105
106
            // We'll keep track of added rights, which we'll later compare with the
107
            // current rights to ensure the list of former rights is complete.
108
            // This is because sometimes rights were removed but there mysteriously
109
            // is no log entry of it.
110
            $added = [];
111
112
            foreach ($rightsChanges as $change) {
113
                $former = array_diff(
114
                    array_merge($former, $change['removed']),
115
                    $change['added']
116
                );
117
118
                $added = array_unique(array_merge($added, $change['added']));
119
            }
120
121
            // Also tag on rights that were previously added but mysteriously
122
            // don't have a log entry for when they were removed.
123
            $former = array_merge(
124
                array_diff($added, $currentRights),
125
                $former
126
            );
127
128
            // Remove the current rights for good measure. Autoconfirmed is a special case -- it can never be former,
129
            // but will end up in $former from the above code.
130
            $former = array_diff(array_unique($former), $currentRights, ['autoconfirmed']);
131
132
            $this->rightsStates[$type] = [
133
                'current' => $currentRights,
134
                'former' => $former,
135
            ];
136
        }
137
138
        return $this->rightsStates;
139
    }
140
141
    /**
142
     * Get a list of the current rights (of given type) and the log.
143
     * @param string $type 'local' or 'global'
144
     * @return array [string[] current rights, array rights changes].
145
     */
146
    private function getCurrentRightsAndChanges(string $type): array
147
    {
148
        // Current rights are not fetched from the log because really old
149
        // log entries contained little or no metadata, and the rights
150
        // changes may be undetectable.
151
        if ('local' === $type) {
152
            $currentRights = $this->user->getUserRights($this->project);
0 ignored issues
show
The method getUserRights() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

152
            /** @scrutinizer ignore-call */ 
153
            $currentRights = $this->user->getUserRights($this->project);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
153
            $rightsChanges = $this->getRightsChanges();
154
155
            $acDate = $this->getAutoconfirmedTimestamp();
156
            if (false !== $acDate && strtotime($acDate) <= time()) {
157
                $currentRights[] = 'autoconfirmed';
158
            }
159
        } else {
160
            $currentRights = $this->user->getGlobalUserRights($this->project);
161
            $rightsChanges = $this->getGlobalRightsChanges();
162
        }
163
164
        return [$currentRights, $rightsChanges];
165
    }
166
167
    /**
168
     * Get a list of the current and former global rights of the user.
169
     * @return array With keys 'current' and 'former'.
170
     */
171
    public function getGlobalRightsStates(): array
172
    {
173
        return $this->getRightsStates()['global'];
174
    }
175
176
    /**
177
     * Get global user rights changes of the given user.
178
     * @param int|null $limit
179
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
180
     */
181
    public function getGlobalRightsChanges(?int $limit = null): array
182
    {
183
        if (!isset($this->globalRightsChanges)) {
184
            $logData = $this->repository->getGlobalRightsChanges($this->project, $this->user);
0 ignored issues
show
The method getGlobalRightsChanges() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

184
            /** @scrutinizer ignore-call */ 
185
            $logData = $this->repository->getGlobalRightsChanges($this->project, $this->user);
Loading history...
185
            $this->globalRightsChanges = $this->processRightsChanges($logData);
186
        }
187
188
        return array_slice($this->globalRightsChanges, 0, $limit, true);
189
    }
190
191
    /**
192
     * Get the localized names for the user groups, fetched from on-wiki system messages.
193
     * @return string[] Localized names keyed by database value.
194
     */
195
    public function getRightsNames(): array
196
    {
197
        if (isset($this->rightsNames)) {
198
            return $this->rightsNames;
199
        }
200
201
        $this->rightsNames = $this->repository->getRightsNames($this->project, $this->i18n->getLang());
0 ignored issues
show
The method getRightsNames() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

201
        /** @scrutinizer ignore-call */ 
202
        $this->rightsNames = $this->repository->getRightsNames($this->project, $this->i18n->getLang());
Loading history...
202
203
        return $this->rightsNames;
204
    }
205
206
    /**
207
     * Get the localized translation for the given user right.
208
     * @param string $name The name of the right, such as 'sysop'.
209
     * @return string
210
     */
211
    public function getRightsName(string $name): string
212
    {
213
        return $this->getRightsNames()[$name] ?? $name;
214
    }
215
216
    /**
217
     * Process the given rights changes, sorting an putting in a human-readable format.
218
     * @param array $logData As fetched with EditCounterRepository::getRightsChanges.
219
     * @return array
220
     */
221
    private function processRightsChanges(array $logData): array
222
    {
223
        $rightsChanges = [];
224
225
        foreach ($logData as $row) {
226
            // Can happen if the log entry has been deleted.
227
            if (!isset($row['log_params']) || null === $row['log_params']) {
228
                continue;
229
            }
230
231
            $unserialized = @unserialize($row['log_params']);
232
233
            if (false !== $unserialized) {
234
                $old = $unserialized['4::oldgroups'] ?? $unserialized['oldGroups'];
235
                $new = $unserialized['5::newgroups'] ?? $unserialized['newGroups'];
236
                $added = array_diff($new, $old);
237
                $removed = array_diff($old, $new);
238
                $oldMetadata = $unserialized['oldmetadata'] ?? $unserialized['oldMetadata'] ?? null;
239
                $newMetadata = $unserialized['newmetadata'] ?? $unserialized['newMetadata'] ?? null;
240
241
                // Check for changes only to expiry. If such exists, treat it as added. Various issets are safeguards.
242
                if (empty($added) && empty($removed) && isset($oldMetadata) && isset($newMetadata)) {
243
                    foreach ($old as $index => $right) {
244
                        $oldExpiry = $oldMetadata[$index]['expiry'] ?? null;
245
                        $newExpiry = $newMetadata[$index]['expiry'] ?? null;
246
247
                        // Check if an expiry was added, removed, or modified.
248
                        if ((null !== $oldExpiry && null === $newExpiry) ||
249
                            (null === $oldExpiry && null !== $newExpiry) ||
250
                            (null !== $oldExpiry && null !== $newExpiry)
251
                        ) {
252
                            $added[$index] = $right;
253
254
                            // Remove the last auto-removal(s), which must exist.
255
                            foreach (array_reverse($rightsChanges, true) as $timestamp => $change) {
256
                                if (in_array($right, $change['removed']) && !in_array($right, $change['added']) &&
257
                                    'automatic' === $change['grantType']
258
                                ) {
259
                                    unset($rightsChanges[$timestamp]);
260
                                }
261
                            }
262
                        }
263
                    }
264
                }
265
266
                // If a right was removed, remove any previously pending auto-removals.
267
                if (count($removed) > 0) {
268
                    $this->unsetAutoRemoval($rightsChanges, $removed);
269
                }
270
271
                $this->setAutoRemovals($rightsChanges, $row, $unserialized, $added);
272
            } else {
273
                // This is the old school format that most likely contains
274
                // the list of rights additions as a comma-separated list.
275
                try {
276
                    [$old, $new] = explode("\n", $row['log_params']);
277
                    $old = array_filter(array_map('trim', explode(',', $old)));
278
                    $new = array_filter(array_map('trim', explode(',', (string)$new)));
279
                    $added = array_diff($new, $old);
280
                    $removed = array_diff($old, $new);
281
                } catch (Exception $e) {
282
                    // Really, really old school format that may be missing metadata
283
                    // altogether. Here we'll just leave $added and $removed empty.
284
                    $added = [];
285
                    $removed = [];
286
                }
287
            }
288
289
            // Remove '(none)'.
290
            if (in_array('(none)', $added)) {
291
                array_splice($added, array_search('(none)', $added), 1);
0 ignored issues
show
It seems like array_search('(none)', $added) can also be of type string; however, parameter $offset of array_splice() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

291
                array_splice($added, /** @scrutinizer ignore-type */ array_search('(none)', $added), 1);
Loading history...
292
            }
293
            if (in_array('(none)', $removed)) {
294
                array_splice($removed, array_search('(none)', $removed), 1);
295
            }
296
297
            $rightsChanges[$row['log_timestamp']] = [
298
                'logId' => $row['log_id'],
299
                'performer' => 'autopromote' === $row['log_action'] ? null : $row['performer'],
300
                'comment' => $row['log_comment'],
301
                'added' => array_values($added),
302
                'removed' => array_values($removed),
303
                'grantType' => 'autopromote' === $row['log_action'] ? 'automatic' : 'manual',
304
                'type' => $row['type'],
305
            ];
306
        }
307
308
        krsort($rightsChanges);
309
310
        return $rightsChanges;
311
    }
312
313
    /**
314
     * Check the given log entry for rights changes that are set to automatically expire,
315
     * and add entries to $rightsChanges accordingly.
316
     * @param array $rightsChanges
317
     * @param array $row Log entry row from database.
318
     * @param array $params Unserialized log params.
319
     * @param string[] $added List of added user rights.
320
     */
321
    private function setAutoRemovals(array &$rightsChanges, array $row, array $params, array $added): void
322
    {
323
        foreach ($added as $index => $entry) {
324
            $newMetadata = $params['newmetadata'][$index] ?? $params['newMetadata'][$index] ?? null;
325
326
            // Skip if no expiry was set.
327
            if (null === $newMetadata || empty($newMetadata['expiry'])
328
            ) {
329
                continue;
330
            }
331
332
            $expiry = $newMetadata['expiry'];
333
334
            if (isset($rightsChanges[$expiry]) && !in_array($entry, $rightsChanges[$expiry]['removed'])) {
335
                // Temporary right expired.
336
                $rightsChanges[$expiry]['removed'][] = $entry;
337
            } else {
338
                // Temporary right was added.
339
                $rightsChanges[$expiry] = [
340
                    'logId' => $row['log_id'],
341
                    'performer' => $row['performer'],
342
                    'comment' => null,
343
                    'added' => [],
344
                    'removed' => [$entry],
345
                    'grantType' => strtotime($expiry) > time() ? 'pending' : 'automatic',
346
                    'type' => $row['type'],
347
                ];
348
            }
349
        }
350
351
        // Resort because the auto-removal timestamp could be before other rights changes.
352
        ksort($rightsChanges);
353
    }
354
355
    private function unsetAutoRemoval(array &$rightsChanges, array $removed): void
356
    {
357
        foreach ($rightsChanges as $timestamp => $change) {
358
            if ('pending' === $change['grantType']) {
359
                $rightsChanges[$timestamp]['removed'] = array_diff($change['removed'], $removed);
360
                if (empty($rightsChanges[$timestamp]['removed'])) {
361
                    unset($rightsChanges[$timestamp]);
362
                }
363
            }
364
        }
365
    }
366
367
    /**
368
     * Get the timestamp of when the user became autoconfirmed.
369
     * @return string|false YmdHis format, or false if date is in the future or if AC status could not be determined.
370
     */
371
    private function getAutoconfirmedTimestamp()
372
    {
373
        static $acTimestamp = null;
374
        if (null !== $acTimestamp) {
375
            return $acTimestamp;
376
        }
377
378
        $thresholds = $this->repository->getAutoconfirmedAgeAndCount($this->project);
0 ignored issues
show
The method getAutoconfirmedAgeAndCount() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

378
        /** @scrutinizer ignore-call */ 
379
        $thresholds = $this->repository->getAutoconfirmedAgeAndCount($this->project);
Loading history...
379
380
        // Happens for non-WMF installations, or if there is no autoconfirmed status.
381
        if (null === $thresholds) {
382
            return false;
383
        }
384
385
        $registrationDate = $this->user->getRegistrationDate($this->project);
386
387
        // Sometimes for old accounts the registration date is null, in which case
388
        // we won't attempt to find out when they were autoconfirmed.
389
        if (!is_a($registrationDate, 'DateTime')) {
390
            return false;
391
        }
392
393
        $regDateImmutable = new DateTimeImmutable(
394
            $registrationDate->format('YmdHis')
395
        );
396
397
        $acDate = $regDateImmutable->add(DateInterval::createFromDateString(
398
            $thresholds['wgAutoConfirmAge'].' seconds'
399
        ))->format('YmdHis');
400
401
        // First check if they already had 10 edits made as of $acDate
402
        $editsByAcDate = $this->repository->getNumEditsByTimestamp(
0 ignored issues
show
The method getNumEditsByTimestamp() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

402
        /** @scrutinizer ignore-call */ 
403
        $editsByAcDate = $this->repository->getNumEditsByTimestamp(
Loading history...
403
            $this->project,
404
            $this->user,
405
            $acDate
406
        );
407
408
        // If more than wgAutoConfirmCount, then $acDate is when they became autoconfirmed.
409
        if ($editsByAcDate >= $thresholds['wgAutoConfirmCount']) {
410
            return $acDate;
411
        }
412
413
        // Now check when the nth edit was made, where n is wgAutoConfirmCount.
414
        // This will be false if they still haven't made 10 edits.
415
        $acTimestamp = $this->repository->getNthEditTimestamp(
0 ignored issues
show
The method getNthEditTimestamp() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\UserRightsRepository. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

415
        /** @scrutinizer ignore-call */ 
416
        $acTimestamp = $this->repository->getNthEditTimestamp(
Loading history...
416
            $this->project,
417
            $this->user,
418
            $registrationDate->format('YmdHis'),
419
            $thresholds['wgAutoConfirmCount']
420
        );
421
422
        return $acTimestamp;
423
    }
424
}
425