Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.18% covered (warning)
58.18%
32 / 55
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmergencyWatcher
58.18% covered (warning)
58.18%
32 / 55
50.00% covered (danger)
50.00%
2 / 4
25.36
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
 getFiltersToThrottle
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
8.03
 run
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getEmergencyValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Watcher;
4
5use InvalidArgumentException;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\Deferred\AutoCommitUpdate;
8use MediaWiki\Deferred\DeferredUpdates;
9use MediaWiki\Extension\AbuseFilter\EchoNotifier;
10use MediaWiki\Extension\AbuseFilter\EmergencyCache;
11use MediaWiki\Extension\AbuseFilter\FilterLookup;
12use Wikimedia\Rdbms\IDatabase;
13use Wikimedia\Rdbms\LBFactory;
14
15/**
16 * Service for monitoring filters with restricted actions and preventing them
17 * from executing destructive actions ("throttling")
18 *
19 * @todo We should log throttling somewhere
20 */
21class EmergencyWatcher implements Watcher {
22    public const SERVICE_NAME = 'AbuseFilterEmergencyWatcher';
23
24    public const CONSTRUCTOR_OPTIONS = [
25        'AbuseFilterEmergencyDisableAge',
26        'AbuseFilterEmergencyDisableCount',
27        'AbuseFilterEmergencyDisableThreshold',
28    ];
29
30    public function __construct(
31        private readonly EmergencyCache $cache,
32        private readonly LBFactory $lbFactory,
33        private readonly FilterLookup $filterLookup,
34        private readonly EchoNotifier $notifier,
35        private readonly ServiceOptions $options
36    ) {
37        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
38    }
39
40    /**
41     * Determine which filters must be throttled, i.e. their potentially dangerous
42     *  actions must be disabled.
43     *
44     * @param int[] $filters The filters to check
45     * @param string $group Group the filters belong to
46     * @return int[] Array of filters to be throttled
47     */
48    public function getFiltersToThrottle( array $filters, string $group ): array {
49        $filters = array_intersect(
50            $filters,
51            $this->cache->getFiltersToCheckInGroup( $group )
52        );
53        if ( $filters === [] ) {
54            return [];
55        }
56
57        $threshold = $this->getEmergencyValue( 'threshold', $group );
58        $hitCountLimit = $this->getEmergencyValue( 'count', $group );
59        $maxAge = $this->getEmergencyValue( 'age', $group );
60
61        $time = (int)wfTimestamp( TS_UNIX );
62
63        $throttleFilters = [];
64        foreach ( $filters as $filter ) {
65            $filterObj = $this->filterLookup->getFilter( $filter, false );
66            // TODO: consider removing the filter from the group key
67            // after throttling
68            if ( $filterObj->isThrottled() ) {
69                continue;
70            }
71
72            $filterAge = (int)wfTimestamp( TS_UNIX, $filterObj->getTimestamp() );
73            $exemptTime = $filterAge + $maxAge;
74
75            // Optimize for the common case when filters are well-established
76            // This check somewhat duplicates the role of cache entry's TTL
77            // and could as well be removed
78            if ( $exemptTime <= $time ) {
79                continue;
80            }
81
82            // TODO: this value might be stale, there is no guarantee the match
83            // has actually been recorded now
84            $cacheValue = $this->cache->getForFilter( $filter );
85            if ( $cacheValue === false ) {
86                continue;
87            }
88
89            [ 'total' => $totalActions, 'matches' => $matchCount ] = $cacheValue;
90
91            if ( $matchCount > $hitCountLimit && ( $matchCount / $totalActions ) > $threshold ) {
92                // More than AbuseFilterEmergencyDisableCount matches, constituting more than
93                // AbuseFilterEmergencyDisableThreshold (a fraction) of last few edits.
94                // Disable it.
95                $throttleFilters[] = $filter;
96            }
97        }
98
99        return $throttleFilters;
100    }
101
102    /**
103     * Determine which filters must be throttled and apply the throttling
104     *
105     * @inheritDoc
106     */
107    public function run( array $localFilters, array $globalFilters, string $group ): void {
108        $throttleFilters = $this->getFiltersToThrottle( $localFilters, $group );
109        if ( !$throttleFilters ) {
110            return;
111        }
112
113        DeferredUpdates::addUpdate(
114            new AutoCommitUpdate(
115                $this->lbFactory->getPrimaryDatabase(),
116                __METHOD__,
117                static function ( IDatabase $dbw, $fname ) use ( $throttleFilters ) {
118                    $dbw->newUpdateQueryBuilder()
119                        ->update( 'abuse_filter' )
120                        ->set( [ 'af_throttled' => 1 ] )
121                        ->where( [ 'af_id' => $throttleFilters ] )
122                        ->caller( $fname )
123                        ->execute();
124                }
125            )
126        );
127        DeferredUpdates::addCallableUpdate( function () use ( $throttleFilters ) {
128            foreach ( $throttleFilters as $filter ) {
129                $this->notifier->notifyForFilter( $filter );
130            }
131        } );
132    }
133
134    /**
135     * @param string $type The value to get, either "threshold", "count" or "age"
136     * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
137     * @return mixed
138     */
139    private function getEmergencyValue( string $type, string $group ) {
140        $opt = match ( $type ) {
141            'threshold' => 'AbuseFilterEmergencyDisableThreshold',
142            'count' => 'AbuseFilterEmergencyDisableCount',
143            'age' => 'AbuseFilterEmergencyDisableAge',
144            // @codeCoverageIgnoreStart
145            default => throw new InvalidArgumentException( '$type must be either "threshold", "count" or "age"' ),
146            // @codeCoverageIgnoreEnd
147        };
148
149        $value = $this->options->get( $opt );
150        return $value[$group] ?? $value['default'];
151    }
152}