Passed
Push — master ( 2a5557...3bdbd9 )
by Simon
04:11
created

RequestValidationHelper::validateName()   C

Complexity

Conditions 9
Paths 256

Size

Total Lines 47
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 90

Importance

Changes 0
Metric Value
eloc 18
dl 0
loc 47
ccs 0
cts 18
cp 0
rs 6.5222
c 0
b 0
f 0
cc 9
nc 256
nop 1
crap 90
1
<?php
2
/******************************************************************************
3
 * Wikipedia Account Creation Assistance tool                                 *
4
 * ACC Development Team. Please see team.json for a list of contributors.     *
5
 *                                                                            *
6
 * This is free and unencumbered software released into the public domain.    *
7
 * Please see LICENSE.md for the full licencing statement.                    *
8
 ******************************************************************************/
9
10
namespace Waca\Validation;
11
12
use Exception;
13
use Waca\DataObjects\Ban;
14
use Waca\DataObjects\Comment;
15
use Waca\DataObjects\Domain;
16
use Waca\DataObjects\Request;
17
use Waca\DataObjects\RequestQueue;
18
use Waca\ExceptionHandler;
19
use Waca\Exceptions\CurlException;
20
use Waca\Helpers\HttpHelper;
21
use Waca\Helpers\Interfaces\IBanHelper;
22
use Waca\Helpers\Logger;
23
use Waca\PdoDatabase;
24
use Waca\Providers\Interfaces\IAntiSpoofProvider;
25
use Waca\Providers\Interfaces\IXffTrustProvider;
26
use Waca\Providers\TorExitProvider;
27
use Waca\RequestStatus;
28
use Waca\SiteConfiguration;
29
30
/**
31
 * Performs the validation of an incoming request.
32
 */
33
class RequestValidationHelper
34
{
35
    /** @var IBanHelper */
36
    private $banHelper;
37
    /** @var PdoDatabase */
38
    private $database;
39
    /** @var IAntiSpoofProvider */
40
    private $antiSpoofProvider;
41
    /** @var IXffTrustProvider */
42
    private $xffTrustProvider;
43
    /** @var HttpHelper */
44
    private $httpHelper;
45
    /**
46
     * @var string
47
     */
48
    private $mediawikiApiEndpoint;
49
    private $titleBlacklistEnabled;
50
    /**
51
     * @var TorExitProvider
52
     */
53
    private $torExitProvider;
54
    /**
55
     * @var SiteConfiguration
56
     */
57
    private $siteConfiguration;
58
59
    private $validationRemoteTimeout = 5000;
60
61
    /**
62
     * Summary of __construct
63
     *
64
     * @param IBanHelper         $banHelper
65
     * @param PdoDatabase        $database
66
     * @param IAntiSpoofProvider $antiSpoofProvider
67
     * @param IXffTrustProvider  $xffTrustProvider
68
     * @param HttpHelper         $httpHelper
69
     * @param TorExitProvider    $torExitProvider
70
     * @param SiteConfiguration  $siteConfiguration
71
     */
72
    public function __construct(
73
        IBanHelper $banHelper,
74
        PdoDatabase $database,
75
        IAntiSpoofProvider $antiSpoofProvider,
76
        IXffTrustProvider $xffTrustProvider,
77
        HttpHelper $httpHelper,
78
        TorExitProvider $torExitProvider,
79
        SiteConfiguration $siteConfiguration
80
    ) {
81
        $this->banHelper = $banHelper;
82
        $this->database = $database;
83
        $this->antiSpoofProvider = $antiSpoofProvider;
84
        $this->xffTrustProvider = $xffTrustProvider;
85
        $this->httpHelper = $httpHelper;
86
87
        // FIXME: domains!
88
        /** @var Domain $domain */
89
        $domain = Domain::getById(1, $database);
90
91
        $this->mediawikiApiEndpoint = $domain->getWikiApiPath();
92
        $this->titleBlacklistEnabled = $siteConfiguration->getTitleBlacklistEnabled();
93
        $this->torExitProvider = $torExitProvider;
94
        $this->siteConfiguration = $siteConfiguration;
95
    }
96
97
    /**
98
     * Summary of validateName
99
     *
100
     * @param Request $request
101
     *
102
     * @return ValidationError[]
103
     */
104
    public function validateName(Request $request)
105
    {
106
        $errorList = array();
107
108
        // ERRORS
109
        // name is empty
110
        if (trim($request->getName()) == "") {
111
            $errorList[ValidationError::NAME_EMPTY] = new ValidationError(ValidationError::NAME_EMPTY);
112
        }
113
114
        // name is too long
115
        if (mb_strlen(trim($request->getName())) > 500) {
116
            $errorList[ValidationError::NAME_EMPTY] = new ValidationError(ValidationError::NAME_TOO_LONG);
117
        }
118
119
        // username already exists
120
        if ($this->userExists($request)) {
121
            $errorList[ValidationError::NAME_EXISTS] = new ValidationError(ValidationError::NAME_EXISTS);
122
        }
123
124
        // username part of SUL account
125
        if ($this->userSulExists($request)) {
126
            // using same error slot as name exists - it's the same sort of error, and we probably only want to show one.
127
            $errorList[ValidationError::NAME_EXISTS] = new ValidationError(ValidationError::NAME_EXISTS_SUL);
128
        }
129
130
        // username is numbers
131
        if (preg_match("/^[0-9]+$/", $request->getName()) === 1) {
132
            $errorList[ValidationError::NAME_NUMONLY] = new ValidationError(ValidationError::NAME_NUMONLY);
133
        }
134
135
        // username can't contain #@/<>[]|{}
136
        if (preg_match("/[" . preg_quote("#@/<>[]|{}", "/") . "]/", $request->getName()) === 1) {
137
            $errorList[ValidationError::NAME_INVALIDCHAR] = new ValidationError(ValidationError::NAME_INVALIDCHAR);
138
        }
139
140
        // username is an IP
141
        if (filter_var($request->getName(), FILTER_VALIDATE_IP)) {
142
            $errorList[ValidationError::NAME_IP] = new ValidationError(ValidationError::NAME_IP);
143
        }
144
145
        // existing non-closed request for this name
146
        if ($this->nameRequestExists($request)) {
147
            $errorList[ValidationError::OPEN_REQUEST_NAME] = new ValidationError(ValidationError::OPEN_REQUEST_NAME);
148
        }
149
150
        return $errorList;
151
    }
152
153
    /**
154
     * Summary of validateEmail
155
     *
156
     * @param Request $request
157
     * @param string  $emailConfirmation
158
     *
159
     * @return ValidationError[]
160
     */
161
    public function validateEmail(Request $request, $emailConfirmation)
162
    {
163
        $errorList = array();
164
165
        // ERRORS
166
167
        // email addresses must match
168
        if ($request->getEmail() != $emailConfirmation) {
169
            $errorList[ValidationError::EMAIL_MISMATCH] = new ValidationError(ValidationError::EMAIL_MISMATCH);
170
        }
171
172
        // email address must be validly formed
173
        if (trim($request->getEmail()) == "") {
174
            $errorList[ValidationError::EMAIL_EMPTY] = new ValidationError(ValidationError::EMAIL_EMPTY);
175
        }
176
177
        // email address must be validly formed
178
        if (!filter_var($request->getEmail(), FILTER_VALIDATE_EMAIL)) {
179
            if (trim($request->getEmail()) != "") {
180
                $errorList[ValidationError::EMAIL_INVALID] = new ValidationError(ValidationError::EMAIL_INVALID);
181
            }
182
        }
183
184
        // email address can't be wikimedia/wikipedia .com/org
185
        if (preg_match('/.*@.*wiki(m.dia|p.dia)\.(org|com)/i', $request->getEmail()) === 1) {
186
            $errorList[ValidationError::EMAIL_WIKIMEDIA] = new ValidationError(ValidationError::EMAIL_WIKIMEDIA);
187
        }
188
189
        return $errorList;
190
    }
191
192
    /**
193
     * Summary of validateOther
194
     *
195
     * @param Request $request
196
     *
197
     * @return ValidationError[]
198
     */
199
    public function validateOther(Request $request)
200
    {
201
        $errorList = array();
202
203
        $trustedIp = $this->xffTrustProvider->getTrustedClientIp($request->getIp(),
204
            $request->getForwardedIp());
205
206
        // ERRORS
207
208
        // TOR nodes
209
        if ($this->torExitProvider->isTorExit($trustedIp)) {
210
            $errorList[ValidationError::BANNED] = new ValidationError(ValidationError::BANNED_TOR);
211
        }
212
213
        // Bans
214
        if ($this->banHelper->isBlockBanned($request)) {
215
            $errorList[ValidationError::BANNED] = new ValidationError(ValidationError::BANNED);
216
        }
217
218
        return $errorList;
219
    }
220
221
    public function postSaveValidations(Request $request)
222
    {
223
        // Antispoof check
224
        $this->checkAntiSpoof($request);
225
226
        // Blacklist check
227
        $this->checkTitleBlacklist($request);
228
229
        // Add comment for form override
230
        $this->formOverride($request);
231
232
        $bans = $this->banHelper->getBans($request);
233
234
        foreach ($bans as $ban) {
235
            if ($ban->getAction() == Ban::ACTION_DROP || $ban->getAction() == Ban::ACTION_DROP_PRECONFIRM) {
236
                $request->setStatus(RequestStatus::CLOSED);
237
                $request->save();
238
239
                Logger::closeRequest($request->getDatabase(), $request, 0, null);
240
241
                $comment = new Comment();
242
                $comment->setDatabase($this->database);
243
                $comment->setRequest($request->getId());
244
                $comment->setVisibility('user');
245
                $comment->setUser(null);
246
247
                $comment->setComment('Request dropped automatically due to matching rule.');
248
                $comment->save();
249
            }
250
251
            if ($ban->getAction() == Ban::ACTION_DEFER) {
252
                /** @var RequestQueue|false $targetQueue */
253
                $targetQueue = RequestQueue::getById($ban->getTargetQueue(), $this->database);
254
255
                if ($targetQueue === false ) {
256
                    $comment = new Comment();
257
                    $comment->setDatabase($this->database);
258
                    $comment->setRequest($request->getId());
259
                    $comment->setVisibility('user');
260
                    $comment->setUser(null);
261
262
                    $comment->setComment("This request would have been deferred automatically due to a matching rule, but the queue to defer to could not be found.");
263
                    $comment->save();
264
                }
265
                else {
266
                    $this->deferRequest($request, $targetQueue, 'Request deferred automatically due to matching rule.');
267
                }
268
            }
269
        }
270
    }
271
272
    /**
273
     * Pre-email confirmation validations - these are checks that should be done before the user is sent a link that
274
     * confirms their email address, and that should block the request from proceeding if they fail.
275
     *
276
     * @param Request $request
277
     *
278
     * @return bool
279
     */
280
    public function preEmailConfirmationValidations(Request $request): bool
281
    {
282
        $bans = $this->banHelper->getBans($request);
283
284
        foreach ($bans as $ban) {
285
            if ($ban->getAction() == Ban::ACTION_DROP_PRECONFIRM) {
286
                return false;
287
            }
288
        }
289
290
        return true;
291
    }
292
293
    private function checkAntiSpoof(Request $request)
294
    {
295
        try {
296
            if (count($this->antiSpoofProvider->getSpoofs($request->getName())) > 0) {
297
                // If there were spoofs an Admin should handle the request.
298
                // FIXME: domains!
299
                $defaultQueue = RequestQueue::getDefaultQueue($this->database, 1, RequestQueue::DEFAULT_ANTISPOOF);
300
                $this->deferRequest($request, $defaultQueue,
0 ignored issues
show
Bug introduced by
It seems like $defaultQueue can also be of type false; however, parameter $targetQueue of Waca\Validation\RequestV...nHelper::deferRequest() does only seem to accept Waca\DataObjects\RequestQueue, 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

300
                $this->deferRequest($request, /** @scrutinizer ignore-type */ $defaultQueue,
Loading history...
301
                    'Request automatically deferred due to AntiSpoof hit');
302
            }
303
        }
304
        catch (Exception $ex) {
305
            $skippable = [
306
                'Contains unassigned character',
307
                'Contains incompatible mixed scripts',
308
                'Does not contain any letters',
309
                'Usernames must contain one or more characters',
310
                'Usernames cannot contain characters from different writing systems',
311
                'Usernames cannot contain the character'
312
            ];
313
314
            $skip = false;
315
316
            foreach ($skippable as $s) {
317
                if (strpos($ex->getMessage(), 'Encountered error while getting result: ' . $s) !== false) {
318
                    $skip = true;
319
                    break;
320
                }
321
            }
322
323
            // Only log to disk if this *isn't* a "skippable" error.
324
            if (!$skip) {
325
                ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
326
            }
327
        }
328
    }
329
330
    private function checkTitleBlacklist(Request $request)
331
    {
332
        if ($this->titleBlacklistEnabled == 1) {
333
            try {
334
                $apiResult = $this->httpHelper->get(
335
                    $this->mediawikiApiEndpoint,
336
                    array(
337
                        'action'       => 'titleblacklist',
338
                        'tbtitle'      => $request->getName(),
339
                        'tbaction'     => 'new-account',
340
                        'tbnooverride' => true,
341
                        'format'       => 'php',
342
                    ),
343
                    [],
344
                    $this->validationRemoteTimeout
345
                );
346
347
                $data = unserialize($apiResult);
348
349
                $requestIsOk = $data['titleblacklist']['result'] == "ok";
350
            }
351
            catch (CurlException $ex) {
352
                ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
353
354
                // Don't kill the request, just assume it's fine. Humans can deal with it later.
355
                return;
356
            }
357
358
            if (!$requestIsOk) {
359
                // FIXME: domains!
360
                $defaultQueue = RequestQueue::getDefaultQueue($this->database, 1, RequestQueue::DEFAULT_TITLEBLACKLIST);
361
362
                $this->deferRequest($request, $defaultQueue,
0 ignored issues
show
Bug introduced by
It seems like $defaultQueue can also be of type false; however, parameter $targetQueue of Waca\Validation\RequestV...nHelper::deferRequest() does only seem to accept Waca\DataObjects\RequestQueue, 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

362
                $this->deferRequest($request, /** @scrutinizer ignore-type */ $defaultQueue,
Loading history...
363
                    'Request automatically deferred due to title blacklist hit');
364
            }
365
        }
366
    }
367
368
    private function userExists(Request $request)
369
    {
370
        try {
371
            $userExists = $this->httpHelper->get(
372
                $this->mediawikiApiEndpoint,
373
                array(
374
                    'action'  => 'query',
375
                    'list'    => 'users',
376
                    'ususers' => $request->getName(),
377
                    'format'  => 'php',
378
                ),
379
                [],
380
                $this->validationRemoteTimeout
381
            );
382
383
            $ue = unserialize($userExists);
384
            if (!isset ($ue['query']['users']['0']['missing']) && isset ($ue['query']['users']['0']['userid'])) {
385
                return true;
386
            }
387
        }
388
        catch (CurlException $ex) {
389
            ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
390
391
            // Don't kill the request, just assume it's fine. Humans can deal with it later.
392
            return false;
393
        }
394
395
        return false;
396
    }
397
398
    private function userSulExists(Request $request)
399
    {
400
        $requestName = $request->getName();
401
402
        try {
403
            $userExists = $this->httpHelper->get(
404
                $this->mediawikiApiEndpoint,
405
                array(
406
                    'action'  => 'query',
407
                    'meta'    => 'globaluserinfo',
408
                    'guiuser' => $requestName,
409
                    'format'  => 'php',
410
                ),
411
                [],
412
                $this->validationRemoteTimeout
413
            );
414
415
            $ue = unserialize($userExists);
416
            if (isset ($ue['query']['globaluserinfo']['id'])) {
417
                return true;
418
            }
419
        }
420
        catch (CurlException $ex) {
421
            ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
422
423
            // Don't kill the request, just assume it's fine. Humans can deal with it later.
424
            return false;
425
        }
426
427
        return false;
428
    }
429
430
    /**
431
     * Checks if a request with this name is currently open
432
     *
433
     * @param Request $request
434
     *
435
     * @return bool
436
     */
437
    private function nameRequestExists(Request $request)
438
    {
439
        $query = "SELECT COUNT(id) FROM request WHERE status != 'Closed' AND name = :name;";
440
        $statement = $this->database->prepare($query);
441
        $statement->execute(array(':name' => $request->getName()));
442
443
        if (!$statement) {
0 ignored issues
show
introduced by
$statement is of type PDOStatement, thus it always evaluated to true.
Loading history...
444
            return false;
445
        }
446
447
        return $statement->fetchColumn() > 0;
448
    }
449
450
    private function deferRequest(Request $request, RequestQueue $targetQueue, $deferComment): void
451
    {
452
        $request->setQueue($targetQueue->getId());
453
        $request->save();
454
455
        $logTarget = $targetQueue->getLogName();
0 ignored issues
show
Deprecated Code introduced by
The function Waca\DataObjects\RequestQueue::getLogName() has been deprecated. ( Ignorable by Annotation )

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

455
        $logTarget = /** @scrutinizer ignore-deprecated */ $targetQueue->getLogName();
Loading history...
456
457
        Logger::deferRequest($this->database, $request, $logTarget);
458
459
        $comment = new Comment();
460
        $comment->setDatabase($this->database);
461
        $comment->setRequest($request->getId());
462
        $comment->setVisibility('user');
463
        $comment->setUser(null);
464
465
        $comment->setComment($deferComment);
466
        $comment->save();
467
    }
468
469
    private function formOverride(Request $request)
470
    {
471
        $form = $request->getOriginFormObject();
472
        if($form === null || $form->getOverrideQueue() === null) {
473
            return;
474
        }
475
476
        /** @var RequestQueue $targetQueue */
477
        $targetQueue = RequestQueue::getById($form->getOverrideQueue(), $request->getDatabase());
478
479
        $this->deferRequest($request, $targetQueue, 'Request deferred automatically due to request submission through a request form with a default queue set.');
480
    }
481
}
482