Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.75% covered (success)
93.75%
180 / 192
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewTestBatch
93.75% covered (success)
93.75%
180 / 192
50.00% covered (danger)
50.00%
2 / 4
33.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 show
97.85% covered (success)
97.85%
91 / 93
0.00% covered (danger)
0.00%
0 / 1
4
 doTest
88.10% covered (warning)
88.10%
74 / 84
0.00% covered (danger)
0.00%
0 / 1
23.89
 loadParameters
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\View;
4
5use MediaWiki\Context\IContextSource;
6use MediaWiki\Extension\AbuseFilter\AbuseFilterChangesList;
7use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
8use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
9use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
10use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxField;
11use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
12use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
13use MediaWiki\Extension\AbuseFilter\Variables\LazyLoadedVariable;
14use MediaWiki\Html\Html;
15use MediaWiki\HTMLForm\HTMLForm;
16use MediaWiki\Linker\LinkRenderer;
17use MediaWiki\Message\Message;
18use MediaWiki\RecentChanges\RecentChange;
19use MediaWiki\RecentChanges\RecentChangeFactory;
20use MediaWiki\Title\Title;
21use Wikimedia\Rdbms\LBFactory;
22use Wikimedia\Rdbms\ReadOnlyMode;
23use Wikimedia\Rdbms\SelectQueryBuilder;
24
25class AbuseFilterViewTestBatch extends AbuseFilterView {
26    /**
27     * @var int The limit of changes to test, hard coded for now
28     */
29    private static $mChangeLimit = 100;
30
31    /**
32     * @var string The text of the rule to test changes against
33     */
34    private $testPattern;
35
36    public function __construct(
37        private readonly LBFactory $lbFactory,
38        AbuseFilterPermissionManager $afPermManager,
39        private readonly EditBoxBuilderFactory $boxBuilderFactory,
40        private readonly RuleCheckerFactory $ruleCheckerFactory,
41        private readonly VariableGeneratorFactory $varGeneratorFactory,
42        private readonly AbuseLoggerFactory $abuseLoggerFactory,
43        private readonly RecentChangeFactory $recentChangeFactory,
44        private readonly ReadOnlyMode $readOnlyMode,
45        IContextSource $context,
46        LinkRenderer $linkRenderer,
47        string $basePageName,
48        array $params
49    ) {
50        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
51    }
52
53    /**
54     * Shows the page
55     */
56    public function show() {
57        $out = $this->getOutput();
58
59        if ( !$this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
60            // TODO: the message still refers to the old rights
61            $out->addWikiMsg( 'abusefilter-mustviewprivateoredit' );
62            return;
63        }
64
65        $this->loadParameters();
66
67        // Check if a loaded test pattern uses protected variables and if the user has the right
68        // to view protected variables. If they don't and protected variables are present, unset
69        // the test pattern to avoid leaking PII and notify the user.
70        // This is done as early as possible so that a filter with PII the user cannot access is
71        // never loaded.
72        if ( $this->testPattern !== '' ) {
73            $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
74            $usedVars = $ruleChecker->getUsedVars( $this->testPattern );
75            if ( $this->afPermManager->getForbiddenVariables( $this->getAuthority(), $usedVars ) ) {
76                $this->testPattern = '';
77                $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
78                $out->addHtml(
79                    Html::errorBox( $this->msg( 'abusefilter-test-protectedvarerr' )->parse() )
80                );
81            }
82        }
83
84        $out->setPageTitleMsg( $this->msg( 'abusefilter-test' ) );
85        $out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
86        $out->addWikiMsg( 'abusefilter-test-intro', Message::numParam( self::$mChangeLimit ) );
87        $out->enableOOUI();
88
89        $boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder( $this, $this->getAuthority(), $out );
90
91        $rulesFields = [
92            'rules' => [
93                'section' => 'abusefilter-test-rules-section',
94                'class' => EditBoxField::class,
95                'html' => $boxBuilder->buildEditBox(
96                    $this->testPattern,
97                    true,
98                    true,
99                    false
100                ) . $this->buildFilterLoader()
101            ]
102        ];
103
104        $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
105        $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge );
106        $max = wfTimestampNow();
107
108        $optionsFields = [
109            'TestAction' => [
110                'type' => 'select',
111                'label-message' => 'abusefilter-test-action',
112                'options-messages' => [
113                    'abusefilter-test-search-type-all' => '0',
114                    'abusefilter-test-search-type-edit' => 'edit',
115                    'abusefilter-test-search-type-move' => 'move',
116                    'abusefilter-test-search-type-delete' => 'delete',
117                    'abusefilter-test-search-type-createaccount' => 'createaccount',
118                    'abusefilter-test-search-type-upload' => 'upload'
119                ],
120            ],
121            'TestUser' => [
122                'type' => 'user',
123                'exists' => true,
124                'ipallowed' => true,
125                'required' => false,
126                'label-message' => 'abusefilter-test-user',
127            ],
128            'ExcludeBots' => [
129                'type' => 'check',
130                'label-message' => 'abusefilter-test-nobots',
131            ],
132            'TestPeriodStart' => [
133                'type' => 'datetime',
134                'label-message' => 'abusefilter-test-period-start',
135                'min' => $min,
136                'max' => $max,
137            ],
138            'TestPeriodEnd' => [
139                'type' => 'datetime',
140                'label-message' => 'abusefilter-test-period-end',
141                'min' => $min,
142                'max' => $max,
143            ],
144            'TestPage' => [
145                'type' => 'title',
146                'label-message' => 'abusefilter-test-page',
147                'creatable' => true,
148                'required' => false,
149            ],
150            'ShowNegative' => [
151                'type' => 'check',
152                'label-message' => 'abusefilter-test-shownegative',
153            ],
154        ];
155        array_walk( $optionsFields, static function ( &$el ) {
156            $el['section'] = 'abusefilter-test-options-section';
157        } );
158        $allFields = array_merge( $rulesFields, $optionsFields );
159
160        $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
161        HTMLForm::factory( 'ooui', $allFields, $this->getContext() )
162            ->setTitle( $this->getTitle( 'test' ) )
163            ->setId( 'wpFilterForm' )
164            ->setWrapperLegendMsg( 'abusefilter-test-legend' )
165            ->setSubmitTextMsg( 'abusefilter-test-submit' )
166            ->setSubmitCallback( [ $this, 'doTest' ] )
167            ->showAlways();
168    }
169
170    /**
171     * Loads the revisions and checks the given syntax against them
172     * @param array $formData
173     * @param HTMLForm $form
174     * @return bool
175     */
176    public function doTest( array $formData, HTMLForm $form ): bool {
177        // Quick syntax check.
178        $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
179
180        if ( !$ruleChecker->checkSyntax( $this->testPattern )->isValid() ) {
181            $form->addPreHtml(
182                Html::errorBox( $this->msg( 'abusefilter-test-syntaxerr' )->parse() )
183            );
184            return true;
185        }
186
187        $dbr = $this->lbFactory->getReplicaDatabase();
188        $rcQuery = RecentChange::getQueryInfo();
189        $conds = [];
190
191        // Normalise username
192        $userTitle = Title::newFromText( $formData['TestUser'], NS_USER );
193        $testUser = $userTitle ? $userTitle->getText() : '';
194        if ( $testUser !== '' ) {
195            $conds[$rcQuery['fields']['rc_user_text']] = $testUser;
196        }
197
198        $startTS = strtotime( $formData['TestPeriodStart'] );
199        if ( $startTS ) {
200            $conds[] = $dbr->expr( 'rc_timestamp', '>=', $dbr->timestamp( $startTS ) );
201        }
202        $endTS = strtotime( $formData['TestPeriodEnd'] );
203        if ( $endTS ) {
204            $conds[] = $dbr->expr( 'rc_timestamp', '<=', $dbr->timestamp( $endTS ) );
205        }
206        if ( $formData['TestPage'] !== '' ) {
207            // The form validates the input for us, so this shouldn't throw.
208            $title = Title::newFromTextThrow( $formData['TestPage'] );
209            $conds['rc_namespace'] = $title->getNamespace();
210            $conds['rc_title'] = $title->getDBkey();
211        }
212
213        if ( $formData['ExcludeBots'] ) {
214            $conds['rc_bot'] = 0;
215        }
216
217        $action = $formData['TestAction'] !== '0' ? $formData['TestAction'] : false;
218        $conds[] = $this->buildTestConditions( $dbr, $action );
219        $authority = $this->getAuthority();
220        $conds = array_merge( $conds, $this->buildVisibilityConditions( $dbr, $authority ) );
221
222        $res = $dbr->newSelectQueryBuilder()
223            ->tables( $rcQuery['tables'] )
224            ->fields( $rcQuery['fields'] )
225            ->conds( $conds )
226            ->caller( __METHOD__ )
227            ->limit( self::$mChangeLimit )
228            ->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC )
229            ->joinConds( $rcQuery['joins'] )
230            ->fetchResultSet();
231
232        // Get our ChangesList
233        $changesList = new AbuseFilterChangesList( $this->getContext(), $this->testPattern );
234        // Note, we're initializing some rows that will later be discarded. Hopefully this won't have any overhead.
235        $changesList->initChangesListRows( $res );
236        $output = $changesList->beginRecentChangesList();
237
238        $counter = 1;
239
240        $readOnlyErrorShown = false;
241        $contextUser = $this->getUser();
242        $ruleChecker->toggleConditionLimit( false );
243        foreach ( $res as $row ) {
244            $rc = $this->recentChangeFactory->newRecentChangeFromRow( $row );
245            if ( !$formData['ShowNegative'] &&
246                !$this->afPermManager::hasRCEntryAccess( $rc, $authority )
247            ) {
248                // If the RC is deleted, the user can't see it, and we're only showing matches,
249                // always skip this row. If ShowNegative is true, we can still show the row
250                // because we won't tell whether it matches the given filter.
251                continue;
252            }
253
254            $varGenerator = $this->varGeneratorFactory->newRCGenerator( $rc, $contextUser );
255            $vars = $varGenerator->getVars();
256
257            if ( !$vars ) {
258                continue;
259            }
260
261            $ruleChecker->setVariables( $vars );
262            $result = $ruleChecker->checkConditions( $this->testPattern )->getResult();
263
264            // If the test filter pattern contains protected variables and this entry had a value set for the
265            // protected variables that were in the pattern, then log that protected variables were accessed.
266            // This is to avoid a user being able to know the value of the variable if they repeatedly try values to
267            // find the actual value through trial-and-error.
268            $usedVars = $ruleChecker->getUsedVars( $this->testPattern );
269            $protectedVariableValuesShown = [];
270            foreach ( $this->afPermManager->getUsedProtectedVariables( $usedVars ) as $protectedVariable ) {
271                if ( $vars->varIsSet( $protectedVariable ) ) {
272                    $protectedVariableValue = $vars->getVarThrow( $protectedVariable );
273                    if (
274                        !( $protectedVariableValue instanceof LazyLoadedVariable ) &&
275                        $protectedVariableValue->toNative() !== null
276                    ) {
277                        $protectedVariableValuesShown[] = $protectedVariable;
278                    }
279                }
280            }
281
282            if ( count( $protectedVariableValuesShown ) ) {
283                if ( $this->readOnlyMode->isReadOnly() ) {
284                    if ( !$readOnlyErrorShown ) {
285                        $form->addPreHtml( Html::errorBox(
286                            $this->msg( 'readonlytext', $this->readOnlyMode->getReason() )->parse()
287                        ) );
288                        $readOnlyErrorShown = true;
289                    }
290                    continue;
291                }
292
293                // Either 'user_name' or 'account_name' should be set which are not lazily loaded, so get one of
294                // them to use as the target
295                if ( $vars->varIsSet( 'user_name' ) ) {
296                    $target = $vars->getComputedVariable( 'user_name' )->toNative();
297                } else {
298                    $target = $vars->getComputedVariable( 'account_name' )->toNative();
299                }
300                $logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
301                $logger->logViewProtectedVariableValue( $this->getUser(), $target, $protectedVariableValuesShown );
302            }
303
304            if ( $result || $formData['ShowNegative'] ) {
305                $changesList->setRCResult( $rc, $result );
306                $rc->counter = $counter++;
307                $output .= $changesList->recentChangesLine( $rc, false );
308            }
309        }
310
311        $output .= $changesList->endRecentChangesList();
312
313        $form->addPostHtml( $output );
314
315        return true;
316    }
317
318    /**
319     * Loads parameters from request
320     */
321    public function loadParameters() {
322        $request = $this->getRequest();
323
324        $this->testPattern = $request->getText( 'wpFilterRules' );
325
326        if ( $this->testPattern === ''
327            && count( $this->mParams ) > 1
328            && is_numeric( $this->mParams[1] )
329        ) {
330            $dbr = $this->lbFactory->getReplicaDatabase();
331            $pattern = $dbr->newSelectQueryBuilder()
332                ->select( 'af_pattern' )
333                ->from( 'abuse_filter' )
334                ->where( [ 'af_id' => intval( $this->mParams[1] ) ] )
335                ->caller( __METHOD__ )
336                ->fetchField();
337            if ( $pattern !== false ) {
338                $this->testPattern = $pattern;
339            }
340        }
341    }
342}