Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.06% covered (warning)
64.06%
41 / 64
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmergencyWatcher
64.06% covered (warning)
64.06%
41 / 64
50.00% covered (danger)
50.00%
2 / 4
30.41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
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    /** @var EmergencyCache */
31    private $cache;
32
33    /** @var LBFactory */
34    private $lbFactory;
35
36    /** @var FilterLookup */
37    private $filterLookup;
38
39    /** @var EchoNotifier */
40    private $notifier;
41
42    /** @var ServiceOptions */
43    private $options;
44
45    /**
46     * @param EmergencyCache $cache
47     * @param LBFactory $lbFactory
48     * @param FilterLookup $filterLookup
49     * @param EchoNotifier $notifier
50     * @param ServiceOptions $options
51     */
52    public function __construct(
53        EmergencyCache $cache,
54        LBFactory $lbFactory,
55        FilterLookup $filterLookup,
56        EchoNotifier $notifier,
57        ServiceOptions $options
58    ) {
59        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
60        $this->cache = $cache;
61        $this->lbFactory = $lbFactory;
62        $this->filterLookup = $filterLookup;
63        $this->notifier = $notifier;
64        $this->options = $options;
65    }
66
67    /**
68     * Determine which filters must be throttled, i.e. their potentially dangerous
69     *  actions must be disabled.
70     *
71     * @param int[] $filters The filters to check
72     * @param string $group Group the filters belong to
73     * @return int[] Array of filters to be throttled
74     */
75    public function getFiltersToThrottle( array $filters, string $group ): array {
76        $filters = array_intersect(
77            $filters,
78            $this->cache->getFiltersToCheckInGroup( $group )
79        );
80        if ( $filters === [] ) {
81            return [];
82        }
83
84        $threshold = $this->getEmergencyValue( 'threshold', $group );
85        $hitCountLimit = $this->getEmergencyValue( 'count', $group );
86        $maxAge = $this->getEmergencyValue( 'age', $group );
87
88        $time = (int)wfTimestamp( TS_UNIX );
89
90        $throttleFilters = [];
91        foreach ( $filters as $filter ) {
92            $filterObj = $this->filterLookup->getFilter( $filter, false );
93            // TODO: consider removing the filter from the group key
94            // after throttling
95            if ( $filterObj->isThrottled() ) {
96                continue;
97            }
98
99            $filterAge = (int)wfTimestamp( TS_UNIX, $filterObj->getTimestamp() );
100            $exemptTime = $filterAge + $maxAge;
101
102            // Optimize for the common case when filters are well-established
103            // This check somewhat duplicates the role of cache entry's TTL
104            // and could as well be removed
105            if ( $exemptTime <= $time ) {
106                continue;
107            }
108
109            // TODO: this value might be stale, there is no guarantee the match
110            // has actually been recorded now
111            $cacheValue = $this->cache->getForFilter( $filter );
112            if ( $cacheValue === false ) {
113                continue;
114            }
115
116            [ 'total' => $totalActions, 'matches' => $matchCount ] = $cacheValue;
117
118            if ( $matchCount > $hitCountLimit && ( $matchCount / $totalActions ) > $threshold ) {
119                // More than AbuseFilterEmergencyDisableCount matches, constituting more than
120                // AbuseFilterEmergencyDisableThreshold (a fraction) of last few edits.
121                // Disable it.
122                $throttleFilters[] = $filter;
123            }
124        }
125
126        return $throttleFilters;
127    }
128
129    /**
130     * Determine which a filters must be throttled and apply the throttling
131     *
132     * @inheritDoc
133     */
134    public function run( array $localFilters, array $globalFilters, string $group ): void {
135        $throttleFilters = $this->getFiltersToThrottle( $localFilters, $group );
136        if ( !$throttleFilters ) {
137            return;
138        }
139
140        DeferredUpdates::addUpdate(
141            new AutoCommitUpdate(
142                $this->lbFactory->getPrimaryDatabase(),
143                __METHOD__,
144                static function ( IDatabase $dbw, $fname ) use ( $throttleFilters ) {
145                    $dbw->newUpdateQueryBuilder()
146                        ->update( 'abuse_filter' )
147                        ->set( [ 'af_throttled' => 1 ] )
148                        ->where( [ 'af_id' => $throttleFilters ] )
149                        ->caller( $fname )
150                        ->execute();
151                }
152            )
153        );
154        DeferredUpdates::addCallableUpdate( function () use ( $throttleFilters ) {
155            foreach ( $throttleFilters as $filter ) {
156                $this->notifier->notifyForFilter( $filter );
157            }
158        } );
159    }
160
161    /**
162     * @param string $type The value to get, either "threshold", "count" or "age"
163     * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
164     * @return mixed
165     */
166    private function getEmergencyValue( string $type, string $group ) {
167        switch ( $type ) {
168            case 'threshold':
169                $opt = 'AbuseFilterEmergencyDisableThreshold';
170                break;
171            case 'count':
172                $opt = 'AbuseFilterEmergencyDisableCount';
173                break;
174            case 'age':
175                $opt = 'AbuseFilterEmergencyDisableAge';
176                break;
177            default:
178                // @codeCoverageIgnoreStart
179                throw new InvalidArgumentException( '$type must be either "threshold", "count" or "age"' );
180                // @codeCoverageIgnoreEnd
181        }
182
183        $value = $this->options->get( $opt );
184        return $value[$group] ?? $value['default'];
185    }
186}