Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.70% covered (danger)
43.70%
59 / 135
25.00% covered (danger)
25.00%
3 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
DenyListManager
43.70% covered (danger)
43.70%
59 / 135
25.00% covered (danger)
25.00%
3 / 12
496.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 singleton
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 isIpDenyListed
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getCachedIpDenyList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 purgeCachedIpDenyList
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getIpDenyList
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
7
 getIpDenyListSet
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getDenyListKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchFlatDenyListHexIps
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 fetchFlatDenyListHexIpsLocal
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 fetchFlatDenyListHexIpsRemote
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
272
 fetchRemoteFile
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * https://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Extension\StopForumSpam;
22
23use BagOStuff;
24use DomainException;
25use IStoreKeyEncoder;
26use MediaWiki\Http\HttpRequestFactory;
27use MediaWiki\Logger\LoggerFactory;
28use MediaWiki\MediaWikiServices;
29use Psr\Log\LoggerInterface;
30use Psr\Log\NullLogger;
31use RuntimeException;
32use WANObjectCache;
33use Wikimedia\IPSet;
34use Wikimedia\IPUtils;
35
36/**
37 * @internal
38 */
39class DenyListManager {
40
41    private const CACHE_VERSION = 1;
42
43    /** @var HttpRequestFactory */
44    private $http;
45    /** @var BagOStuff */
46    private $srvCache;
47    /** @var WANObjectCache */
48    private $wanCache;
49    /** @var LoggerInterface */
50    private $logger;
51
52    /** @var IPSet|null */
53    private $denyListIPSet;
54
55    /** @var self */
56    private static $instance = null;
57
58    /**
59     * @param HttpRequestFactory $http
60     * @param BagOStuff $srvCache
61     * @param WANObjectCache $wanCache
62     * @param LoggerInterface|null $logger
63     */
64    public function __construct(
65        HttpRequestFactory $http,
66        BagOStuff $srvCache,
67        WANObjectCache $wanCache,
68        ?LoggerInterface $logger
69    ) {
70        $this->http = $http;
71        $this->srvCache = $srvCache;
72        $this->wanCache = $wanCache;
73        $this->logger = $logger ?: new NullLogger();
74    }
75
76    /**
77     * @todo use MediaWikiServices
78     * @return DenyListManager
79     */
80    public static function singleton() {
81        if ( self::$instance == null ) {
82            $services = MediaWikiServices::getInstance();
83
84            $srvCache = $services->getLocalServerObjectCache();
85            $wanCache = $services->getMainWANObjectCache();
86            $http = $services->getHttpRequestFactory();
87            $logger = LoggerFactory::getInstance( 'DenyList' );
88
89            self::$instance = new self( $http, $srvCache, $wanCache, $logger );
90        }
91
92        return self::$instance;
93    }
94
95    /**
96     * Check whether the IP address is deny-listed
97     *
98     * @param string $ip An IP address
99     * @return bool
100     */
101    public function isIpDenyListed( $ip ) {
102        if ( IPUtils::isIPAddress( $ip ) === null ) {
103            return false;
104        }
105
106        return $this->getIpDenyListSet()->match( $ip );
107    }
108
109    /**
110     * Get the list of deny-listed IPs from cache only
111     *
112     * @return string[]|false List of deny-listed IP addresses; false if uncached
113     */
114    public function getCachedIpDenyList() {
115        return $this->getIpDenyList();
116    }
117
118    /**
119     * Purge cache of deny-list IPs
120     *
121     * @return bool Success
122     */
123    public function purgeCachedIpDenyList() {
124        $wanCache = $this->wanCache;
125
126        return $wanCache->delete( $this->getDenyListKey( $wanCache ) );
127    }
128
129    /**
130     * Fetch the list of IPs from cache, regenerating the cache as needed
131     *
132     * @param string|null $recache Use 'recache' to force a recache
133     * @return string[] List of deny-listed IP addresses
134     */
135    public function getIpDenyList( $recache = null ): array {
136        global $wgSFSDenyListCacheDuration;
137
138        $srvCache = $this->srvCache;
139        $srvCacheKey = $this->getDenyListKey( $srvCache );
140        if ( $recache === 'recache' ) {
141            $flatIpList = false;
142        } else {
143            $flatIpList = $srvCache->get( $srvCacheKey );
144        }
145
146        if ( $flatIpList === false ) {
147            $wanCache = $this->wanCache;
148            $flatHexIpList = $wanCache->getWithSetCallback(
149                $this->getDenyListKey( $wanCache ),
150                $wgSFSDenyListCacheDuration,
151                function () {
152                    // This uses hexadecimal IP addresses to reduce network I/O
153                    return $this->fetchFlatDenyListHexIps();
154                },
155                [
156                    'lockTSE' => $wgSFSDenyListCacheDuration,
157                    'staleTTL' => $wgSFSDenyListCacheDuration,
158                    // placeholder
159                    'busyValue' => '',
160                    'minAsOf' => ( $recache === 'recache' ) ? INF : $wanCache::MIN_TIMESTAMP_NONE
161                ]
162            );
163
164            $ips = [];
165            for ( $hex = strtok( $flatHexIpList, "\n" ); $hex !== false; $hex = strtok( "\n" ) ) {
166                $ips[] = IPUtils::formatHex( $hex );
167            }
168
169            $flatIpList = implode( "\n", $ips );
170
171            // Refill the local server cache if the list is not empty nor a placeholder
172            if ( $flatIpList !== '' ) {
173                $srvCache->set(
174                    $srvCacheKey,
175                    $flatIpList,
176                    mt_rand( $srvCache::TTL_HOUR, $srvCache::TTL_DAY )
177                );
178            }
179        }
180
181        return ( $flatIpList != '' ) ? explode( "\n", $flatIpList ) : [];
182    }
183
184    /**
185     * @param string|null $recache Use 'recache' to force a recache
186     * @return IPSet
187     */
188    public function getIpDenyListSet( $recache = null ) {
189        if ( $this->denyListIPSet === null || $recache === "recache" ) {
190            $this->denyListIPSet = new IPSet( $this->getIpDenyList( $recache ) );
191        }
192
193        return $this->denyListIPSet;
194    }
195
196    /**
197     * @param IStoreKeyEncoder $cache
198     * @return string Cache key for primary deny list
199     */
200    private function getDenyListKey( IStoreKeyEncoder $cache ) {
201        return $cache->makeGlobalKey( 'sfs-denylist-set', self::CACHE_VERSION );
202    }
203
204    /**
205     * @return string Newline separated list of SFS deny-listed IP addresses
206     */
207    private function fetchFlatDenyListHexIps(): string {
208        global $wgSFSIPListLocation, $wgSFSValidateIPListLocationMD5;
209
210        if ( $wgSFSIPListLocation === false ) {
211            throw new DomainException( '$wgSFSIPListLocation has not been configured.' );
212        }
213
214        if ( is_file( $wgSFSIPListLocation ) ) {
215            $ipList = $this->fetchFlatDenyListHexIpsLocal( $wgSFSIPListLocation );
216        } else {
217            $ipList = $this->fetchFlatDenyListHexIpsRemote(
218                $wgSFSIPListLocation,
219                $wgSFSValidateIPListLocationMD5
220            );
221        }
222
223        return $ipList;
224    }
225
226    /**
227     * Fetch gunzipped/unzipped SFS deny list from local file
228     *
229     * @param string $listFilePath Local file path
230     * @return string Newline separated list of SFS deny-listed IP addresses
231     */
232    private function fetchFlatDenyListHexIpsLocal( string $listFilePath ): string {
233        global $wgSFSIPThreshold;
234
235        $fh = fopen( $listFilePath, 'rb' );
236        if ( !$fh ) {
237            throw new DomainException( "wgSFSIPListLocation file handle could not be obtained." );
238        }
239
240        $ipList = [];
241
242        while ( !feof( $fh ) ) {
243            $ipData = fgetcsv( $fh, 4096, ',', '"' );
244            if ( $ipData === false ) {
245                break;
246            }
247
248            if ( $ipData === null || $ipData === [ null ] ) {
249                continue;
250            }
251            if ( isset( $ipData[1] ) && $ipData[1] < $wgSFSIPThreshold ) {
252                continue;
253            }
254
255            $ip = (string)$ipData[0];
256            $hex = IPUtils::toHex( $ip );
257            if ( $hex === false ) {
258                // invalid address
259                continue;
260            }
261
262            $ipList[] = $hex;
263        }
264
265        return implode( "\n", $ipList );
266    }
267
268    /**
269     * Fetch SFS IP deny list file from SFS site and returns an array of IPs
270     * (https://www.stopforumspam.com/downloads - use gz files)
271     *
272     * @param string $uri SFS vendor or third-party URL to the list
273     * @param string|null $md5uri SFS vendor URL to the MD5 of the list
274     * @return string Newline-separated list of SFS deny-listed IP addresses
275     */
276    private function fetchFlatDenyListHexIpsRemote( string $uri, ?string $md5uri ): string {
277        global $wgSFSProxy, $wgSFSIPThreshold;
278
279        // Hacky, but needed to keep a sensible default value of $wgSFSIPListLocation for
280        // users, whilst also preventing HTTP requests for other extension when they call
281        // permission related hooks that mean the code here gets executed too...
282        // So, if we have a URL, and try and do a HTTP request whilst in MW_PHPUNIT_TEST,
283        // just fallback to loading sample_denylist_all.txt as a file...
284        // See also: T262443, T265628.
285        if ( defined( 'MW_PHPUNIT_TEST' ) ) {
286            $filePath = dirname( __DIR__ ) . '/tests/phpunit/sample_denylist_all.txt';
287            return $this->fetchFlatDenyListHexIpsLocal( $filePath );
288        }
289
290        if ( !filter_var( $uri, FILTER_VALIDATE_URL ) ) {
291            throw new DomainException( "wgSFSIPListLocation does not appear to be a valid URL." );
292        }
293
294        // check for zlib function for later processing
295        if ( !function_exists( 'gzdecode' ) ) {
296            throw new RuntimeException( "Zlib does not appear to be configured for php!" );
297        }
298
299        $options = [ 'followRedirects' => true ];
300        if ( $wgSFSProxy !== false ) {
301            $options['proxy'] = $wgSFSProxy;
302        }
303
304        $fileData = $this->fetchRemoteFile( $uri, $options );
305        if ( $fileData === '' ) {
306            $this->logger->error( __METHOD__ . ": SFS IP list could not be fetched!" );
307
308            return '';
309        }
310
311        if ( is_string( $md5uri ) && $md5uri !== '' ) {
312            // check vendor-provided md5
313            $fileDataMD5 = $this->fetchRemoteFile( $md5uri, $options );
314            if ( $fileDataMD5 === '' ) {
315                $this->logger->error( __METHOD__ . ": SFS IP list MD5 could not be fetched!" );
316                return '';
317            }
318
319            if ( md5( $fileData ) !== $fileDataMD5 ) {
320                $this->logger->error( __METHOD__ . ": SFS IP list has an unexpected MD5!" );
321                return '';
322            }
323        }
324
325        // ungzip and process vendor file
326        $csvTable = gzdecode( $fileData );
327        if ( $csvTable === false ) {
328            $this->logger->error( __METHOD__ . ": SFS IP file contents could not be decoded!" );
329            return '';
330        }
331
332        $ipList = [];
333        $scoreSkipped = 0;
334        $rows = 0;
335
336        for ( $line = strtok( $csvTable, "\n" ); $line !== false; $line = strtok( "\n" ) ) {
337
338            $rows++;
339
340            $ipData = str_getcsv( $line );
341            $ip = (string)$ipData[0];
342            $score = (int)$ipData[1];
343
344            if ( $score && ( $score < $wgSFSIPThreshold ) ) {
345                $scoreSkipped++;
346                continue;
347            }
348
349            $hex = IPUtils::toHex( $ip );
350            if ( $hex === false ) {
351                // invalid address
352                continue;
353            }
354
355            $ipList[] = $hex;
356        }
357
358        if ( $scoreSkipped > 0 ) {
359            $this->logger->info(
360                __METHOD__ . "{$rows} rows were processed. "
361                . "{$scoreSkipped} were skipped because their score was less than {$wgSFSIPThreshold}."
362            );
363        }
364
365        return implode( "\n", $ipList );
366    }
367
368    /**
369     * Fetch a network file's contents via HttpRequestFactory
370     *
371     * @param string $fileUrl
372     * @param array $httpOptions
373     * @return string
374     */
375    private function fetchRemoteFile( string $fileUrl, array $httpOptions ): string {
376        $req = $this->http->create( $fileUrl, $httpOptions );
377
378        $status = $req->execute();
379        if ( !$status->isOK() ) {
380            throw new RuntimeException( "Failed to download resource at {$fileUrl}" );
381        }
382
383        $code = $req->getStatus();
384        if ( $code !== 200 ) {
385            throw new RuntimeException( "Unexpected HTTP {$code} response from {$fileUrl}" );
386        }
387
388        return (string)$req->getContent();
389    }
390}