Passed
Push — db-cleanup ( 50d5f5...4b272b )
by Simon
08:27 queued 05:49
created

RequestValidationHelper::checkAntiSpoof()   B

Complexity

Conditions 6
Paths 20

Size

Total Lines 33
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

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

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

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

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