Issues (186)

Branch: master

includes/Validation/RequestValidationHelper.php (4 issues)

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) {
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
    private function checkAntiSpoof(Request $request)
273
    {
274
        try {
275
            if (count($this->antiSpoofProvider->getSpoofs($request->getName())) > 0) {
276
                // If there were spoofs an Admin should handle the request.
277
                // FIXME: domains!
278
                $defaultQueue = RequestQueue::getDefaultQueue($this->database, 1, RequestQueue::DEFAULT_ANTISPOOF);
279
                $this->deferRequest($request, $defaultQueue,
0 ignored issues
show
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

279
                $this->deferRequest($request, /** @scrutinizer ignore-type */ $defaultQueue,
Loading history...
280
                    'Request automatically deferred due to AntiSpoof hit');
281
            }
282
        }
283
        catch (Exception $ex) {
284
            $skippable = [
285
                'Contains unassigned character',
286
                'Contains incompatible mixed scripts',
287
                'Does not contain any letters',
288
                'Usernames must contain one or more characters',
289
                'Usernames cannot contain characters from different writing systems',
290
                'Usernames cannot contain the character'
291
            ];
292
293
            $skip = false;
294
295
            foreach ($skippable as $s) {
296
                if (strpos($ex->getMessage(), 'Encountered error while getting result: ' . $s) !== false) {
297
                    $skip = true;
298
                    break;
299
                }
300
            }
301
302
            // Only log to disk if this *isn't* a "skippable" error.
303
            if (!$skip) {
304
                ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
305
            }
306
        }
307
    }
308
309
    private function checkTitleBlacklist(Request $request)
310
    {
311
        if ($this->titleBlacklistEnabled == 1) {
312
            try {
313
                $apiResult = $this->httpHelper->get(
314
                    $this->mediawikiApiEndpoint,
315
                    array(
316
                        'action'       => 'titleblacklist',
317
                        'tbtitle'      => $request->getName(),
318
                        'tbaction'     => 'new-account',
319
                        'tbnooverride' => true,
320
                        'format'       => 'php',
321
                    ),
322
                    [],
323
                    $this->validationRemoteTimeout
324
                );
325
326
                $data = unserialize($apiResult);
327
328
                $requestIsOk = $data['titleblacklist']['result'] == "ok";
329
            }
330
            catch (CurlException $ex) {
331
                ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
332
333
                // Don't kill the request, just assume it's fine. Humans can deal with it later.
334
                return;
335
            }
336
337
            if (!$requestIsOk) {
338
                // FIXME: domains!
339
                $defaultQueue = RequestQueue::getDefaultQueue($this->database, 1, RequestQueue::DEFAULT_TITLEBLACKLIST);
340
341
                $this->deferRequest($request, $defaultQueue,
0 ignored issues
show
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

341
                $this->deferRequest($request, /** @scrutinizer ignore-type */ $defaultQueue,
Loading history...
342
                    'Request automatically deferred due to title blacklist hit');
343
            }
344
        }
345
    }
346
347
    private function userExists(Request $request)
348
    {
349
        try {
350
            $userExists = $this->httpHelper->get(
351
                $this->mediawikiApiEndpoint,
352
                array(
353
                    'action'  => 'query',
354
                    'list'    => 'users',
355
                    'ususers' => $request->getName(),
356
                    'format'  => 'php',
357
                ),
358
                [],
359
                $this->validationRemoteTimeout
360
            );
361
362
            $ue = unserialize($userExists);
363
            if (!isset ($ue['query']['users']['0']['missing']) && isset ($ue['query']['users']['0']['userid'])) {
364
                return true;
365
            }
366
        }
367
        catch (CurlException $ex) {
368
            ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
369
370
            // Don't kill the request, just assume it's fine. Humans can deal with it later.
371
            return false;
372
        }
373
374
        return false;
375
    }
376
377
    private function userSulExists(Request $request)
378
    {
379
        $requestName = $request->getName();
380
381
        try {
382
            $userExists = $this->httpHelper->get(
383
                $this->mediawikiApiEndpoint,
384
                array(
385
                    'action'  => 'query',
386
                    'meta'    => 'globaluserinfo',
387
                    'guiuser' => $requestName,
388
                    'format'  => 'php',
389
                ),
390
                [],
391
                $this->validationRemoteTimeout
392
            );
393
394
            $ue = unserialize($userExists);
395
            if (isset ($ue['query']['globaluserinfo']['id'])) {
396
                return true;
397
            }
398
        }
399
        catch (CurlException $ex) {
400
            ExceptionHandler::logExceptionToDisk($ex, $this->siteConfiguration);
401
402
            // Don't kill the request, just assume it's fine. Humans can deal with it later.
403
            return false;
404
        }
405
406
        return false;
407
    }
408
409
    /**
410
     * Checks if a request with this name is currently open
411
     *
412
     * @param Request $request
413
     *
414
     * @return bool
415
     */
416
    private function nameRequestExists(Request $request)
417
    {
418
        $query = "SELECT COUNT(id) FROM request WHERE status != 'Closed' AND name = :name;";
419
        $statement = $this->database->prepare($query);
420
        $statement->execute(array(':name' => $request->getName()));
421
422
        if (!$statement) {
0 ignored issues
show
$statement is of type PDOStatement, thus it always evaluated to true.
Loading history...
423
            return false;
424
        }
425
426
        return $statement->fetchColumn() > 0;
427
    }
428
429
    private function deferRequest(Request $request, RequestQueue $targetQueue, $deferComment): void
430
    {
431
        $request->setQueue($targetQueue->getId());
432
        $request->save();
433
434
        $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

434
        $logTarget = /** @scrutinizer ignore-deprecated */ $targetQueue->getLogName();
Loading history...
435
436
        Logger::deferRequest($this->database, $request, $logTarget);
437
438
        $comment = new Comment();
439
        $comment->setDatabase($this->database);
440
        $comment->setRequest($request->getId());
441
        $comment->setVisibility('user');
442
        $comment->setUser(null);
443
444
        $comment->setComment($deferComment);
445
        $comment->save();
446
    }
447
448
    private function formOverride(Request $request)
449
    {
450
        $form = $request->getOriginFormObject();
451
        if($form === null || $form->getOverrideQueue() === null) {
452
            return;
453
        }
454
455
        /** @var RequestQueue $targetQueue */
456
        $targetQueue = RequestQueue::getById($form->getOverrideQueue(), $request->getDatabase());
457
458
        $this->deferRequest($request, $targetQueue, 'Request deferred automatically due to request submission through a request form with a default queue set.');
459
    }
460
}
461