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