Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 131
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
TitleBlacklist
0.00% covered (danger)
0.00%
0 / 131
0.00% covered (danger)
0.00%
0 / 15
4422
0.00% covered (danger)
0.00%
0 / 1
 singleton
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 destroySingleton
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 load
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 loadWhitelist
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getBlacklistText
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
272
 parseBlacklist
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 userCannot
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 isBlacklisted
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 isWhitelisted
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getBlacklist
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getWhitelist
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getHttp
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 invalidate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 validate
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 userCanOverride
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Title Blacklist class
4 * @author Victor Vasiliev
5 * @copyright © 2007-2010 Victor Vasiliev et al
6 * @license GPL-2.0-or-later
7 * @file
8 */
9
10namespace MediaWiki\Extension\TitleBlacklist;
11
12use BadMethodCallException;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Title\Title;
15use MediaWiki\User\User;
16use ObjectCache;
17use TextContent;
18use Wikimedia\AtEase\AtEase;
19
20/**
21 * @ingroup Extensions
22 */
23
24/**
25 * Implements a title blacklist for MediaWiki
26 */
27class TitleBlacklist {
28    /** @var TitleBlacklistEntry[]|null */
29    private $mBlacklist = null;
30
31    /** @var TitleBlacklistEntry[]|null */
32    private $mWhitelist = null;
33
34    /** @var TitleBlacklist|null */
35    protected static $instance = null;
36
37    /** Blacklist format */
38    public const VERSION = 4;
39
40    /**
41     * Get an instance of this class
42     *
43     * @return TitleBlacklist
44     */
45    public static function singleton() {
46        if ( self::$instance === null ) {
47            self::$instance = new self;
48        }
49        return self::$instance;
50    }
51
52    /**
53     * Destroy/reset the current singleton instance.
54     *
55     * This is solely for testing and will fail unless MW_PHPUNIT_TEST is
56     * defined.
57     */
58    public static function destroySingleton() {
59        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
60            throw new BadMethodCallException(
61                'Can not invoke ' . __METHOD__ . '() ' .
62                'out of tests (MW_PHPUNIT_TEST not set).'
63            );
64        }
65
66        self::$instance = null;
67    }
68
69    /**
70     * Load all configured blacklist sources
71     */
72    public function load() {
73        global $wgTitleBlacklistSources, $wgTitleBlacklistCaching;
74
75        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
76        // Try to find something in the cache
77        $cachedBlacklist = $cache->get( $cache->makeKey( 'title_blacklist_entries' ) );
78        if ( is_array( $cachedBlacklist ) && count( $cachedBlacklist ) > 0
79            && ( $cachedBlacklist[0]->getFormatVersion() == self::VERSION )
80        ) {
81            $this->mBlacklist = $cachedBlacklist;
82            return;
83        }
84
85        $sources = $wgTitleBlacklistSources;
86        $sources['local'] = [ 'type' => 'message' ];
87        $this->mBlacklist = [];
88        foreach ( $sources as $sourceName => $source ) {
89            $this->mBlacklist = array_merge(
90                $this->mBlacklist,
91                self::parseBlacklist( self::getBlacklistText( $source ), $sourceName )
92            );
93        }
94        $cache->set( $cache->makeKey( 'title_blacklist_entries' ),
95            $this->mBlacklist, $wgTitleBlacklistCaching['expiry'] );
96        wfDebugLog( 'TitleBlacklist-cache', 'Updated ' . $cache->makeKey( 'title_blacklist_entries' )
97            . ' with ' . count( $this->mBlacklist ) . ' entries.' );
98    }
99
100    /**
101     * Load local whitelist
102     */
103    public function loadWhitelist() {
104        global $wgTitleBlacklistCaching;
105
106        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
107        $cachedWhitelist = $cache->get( $cache->makeKey( 'title_whitelist_entries' ) );
108        if ( is_array( $cachedWhitelist ) && count( $cachedWhitelist ) > 0
109            && ( $cachedWhitelist[0]->getFormatVersion() != self::VERSION )
110        ) {
111            $this->mWhitelist = $cachedWhitelist;
112            return;
113        }
114        $this->mWhitelist = self::parseBlacklist( wfMessage( 'titlewhitelist' )
115                ->inContentLanguage()->text(), 'whitelist' );
116        $cache->set( $cache->makeKey( 'title_whitelist_entries' ),
117            $this->mWhitelist, $wgTitleBlacklistCaching['expiry'] );
118    }
119
120    /**
121     * Get the text of a blacklist from a specified source
122     *
123     * @param array $source A blacklist source from $wgTitleBlacklistSources
124     * @return string The content of the blacklist source as a string
125     */
126    private static function getBlacklistText( $source ) {
127        if ( !is_array( $source ) || count( $source ) <= 0 ) {
128            // Return empty string in error case
129            return '';
130        }
131
132        if ( $source['type'] == 'message' ) {
133            return wfMessage( 'titleblacklist' )->inContentLanguage()->text();
134        } elseif ( $source['type'] == 'localpage' && count( $source ) >= 2 ) {
135            $title = Title::newFromText( $source['src'] );
136            if ( $title === null ) {
137                return '';
138            }
139            if ( $title->getNamespace() == NS_MEDIAWIKI ) {
140                $msg = wfMessage( $title->getText() )->inContentLanguage();
141                return $msg->isDisabled() ? '' : $msg->text();
142            } else {
143                $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
144                if ( $page->exists() ) {
145                    $content = $page->getContent();
146                    return ( $content instanceof TextContent ) ? $content->getText() : "";
147                }
148            }
149        } elseif ( $source['type'] == 'url' && count( $source ) >= 2 ) {
150            return self::getHttp( $source['src'] );
151        } elseif ( $source['type'] == 'file' && count( $source ) >= 2 ) {
152            if ( !file_exists( $source['src'] ) ) {
153                return '';
154            }
155            return file_get_contents( $source['src'] );
156        }
157
158        return '';
159    }
160
161    /**
162     * Parse blacklist from a string
163     *
164     * @param string $list Text of a blacklist source
165     * @param string $sourceName
166     * @return TitleBlacklistEntry[]
167     */
168    public static function parseBlacklist( $list, $sourceName ) {
169        $lines = preg_split( "/\r?\n/", $list );
170        $result = [];
171        foreach ( $lines as $line ) {
172            $entry = TitleBlacklistEntry::newFromString( $line, $sourceName );
173            if ( $entry ) {
174                $result[] = $entry;
175            }
176        }
177
178        return $result;
179    }
180
181    /**
182     * Check whether the blacklist restricts given user
183     * performing a specific action on the given Title
184     *
185     * @param Title $title Title to check
186     * @param User $user User to check
187     * @param string $action Action to check; 'edit' if unspecified
188     * @param bool $override If set to true, overrides work
189     * @return TitleBlacklistEntry|bool The corresponding TitleBlacklistEntry if
190     * blacklisted; otherwise false
191     */
192    public function userCannot( $title, $user, $action = 'edit', $override = true ) {
193        $entry = $this->isBlacklisted( $title, $action );
194        if ( !$entry ) {
195            return false;
196        }
197        $params = $entry->getParams();
198        if ( isset( $params['autoconfirmed'] ) && $user->isAllowed( 'autoconfirmed' ) ) {
199            return false;
200        }
201        if ( $override && self::userCanOverride( $user, $action ) ) {
202            return false;
203        }
204        return $entry;
205    }
206
207    /**
208     * Check whether the blacklist restricts
209     * performing a specific action on the given Title
210     *
211     * @param Title $title Title to check
212     * @param string $action Action to check; 'edit' if unspecified
213     * @return TitleBlacklistEntry|bool The corresponding TitleBlacklistEntry if blacklisted;
214     *         otherwise FALSE
215     */
216    public function isBlacklisted( $title, $action = 'edit' ) {
217        if ( !( $title instanceof Title ) ) {
218            $title = Title::newFromText( $title );
219            if ( !( $title instanceof Title ) ) {
220                // The fact that the page name is invalid will stop whatever
221                // action is going through. No sense in doing more work here.
222                return false;
223            }
224        }
225        $blacklist = $this->getBlacklist();
226        $autoconfirmedItem = false;
227        foreach ( $blacklist as $item ) {
228            if ( $item->matches( $title->getFullText(), $action ) ) {
229                if ( $this->isWhitelisted( $title, $action ) ) {
230                    return false;
231                }
232                $params = $item->getParams();
233                if ( !isset( $params['autoconfirmed'] ) ) {
234                    return $item;
235                }
236                if ( !$autoconfirmedItem ) {
237                    $autoconfirmedItem = $item;
238                }
239            }
240        }
241        return $autoconfirmedItem;
242    }
243
244    /**
245     * Check whether it has been explicitly whitelisted that the
246     * current User may perform a specific action on the given Title
247     *
248     * @param Title $title Title to check
249     * @param string $action Action to check; 'edit' if unspecified
250     * @return bool True if whitelisted; otherwise false
251     */
252    public function isWhitelisted( $title, $action = 'edit' ) {
253        if ( !( $title instanceof Title ) ) {
254            $title = Title::newFromText( $title );
255        }
256        $whitelist = $this->getWhitelist();
257        foreach ( $whitelist as $item ) {
258            if ( $item->matches( $title->getFullText(), $action ) ) {
259                return true;
260            }
261        }
262        return false;
263    }
264
265    /**
266     * Get the current blacklist
267     *
268     * @return TitleBlacklistEntry[]
269     */
270    public function getBlacklist() {
271        if ( $this->mBlacklist === null ) {
272            $this->load();
273        }
274        return $this->mBlacklist;
275    }
276
277    /**
278     * Get the current whitelist
279     *
280     * @return TitleBlacklistEntry[]
281     */
282    public function getWhitelist() {
283        if ( $this->mWhitelist === null ) {
284            $this->loadWhitelist();
285        }
286        return $this->mWhitelist;
287    }
288
289    /**
290     * Get the text of a blacklist source via HTTP
291     *
292     * @param string $url URL of the blacklist source
293     * @return string The content of the blacklist source as a string
294     */
295    private static function getHttp( $url ) {
296        global $wgTitleBlacklistCaching, $wgMessageCacheType;
297        // FIXME: This is a hack to use Memcached where possible (incl. WMF),
298        // but have CACHE_DB as fallback (instead of no cache).
299        // This might be a good candidate for T248005.
300        $cache = ObjectCache::getInstance( $wgMessageCacheType );
301
302        // Globally shared
303        $key = $cache->makeGlobalKey( 'title_blacklist_source', md5( $url ) );
304        // Per-wiki
305        $warnkey = $cache->makeKey( 'titleblacklistwarning', md5( $url ) );
306
307        $result = $cache->get( $key );
308        $warn = $cache->get( $warnkey );
309
310        if ( !is_string( $result )
311            || ( !$warn && !mt_rand( 0, $wgTitleBlacklistCaching['warningchance'] ) )
312        ) {
313            $result = MediaWikiServices::getInstance()->getHttpRequestFactory()
314                ->get( $url, [], __METHOD__ );
315            $cache->set( $warnkey, 1, $wgTitleBlacklistCaching['warningexpiry'] );
316            $cache->set( $key, $result, $wgTitleBlacklistCaching['expiry'] );
317            if ( !$result ) {
318                wfDebugLog( 'TitleBlacklist-cache', "Error loading title blacklist from $url\n" );
319                $result = '';
320            }
321        }
322
323        return $result;
324    }
325
326    /**
327     * Invalidate the blacklist cache
328     */
329    public function invalidate() {
330        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
331        $cache->delete( $cache->makeKey( 'title_blacklist_entries' ) );
332    }
333
334    /**
335     * Validate a new blacklist
336     *
337     * @suppress PhanParamSuspiciousOrder The preg_match() params are in the correct order
338     * @param TitleBlacklistEntry[] $blacklist
339     * @return string[] List of invalid entries; empty array means blacklist is valid
340     */
341    public function validate( array $blacklist ) {
342        $badEntries = [];
343        foreach ( $blacklist as $e ) {
344            AtEase::suppressWarnings();
345            $regex = $e->getRegex();
346            // @phan-suppress-next-line SecurityCheck-ReDoS
347            if ( preg_match( "/{$regex}/u", '' ) === false ) {
348                $badEntries[] = $e->getRaw();
349            }
350            AtEase::restoreWarnings();
351        }
352        return $badEntries;
353    }
354
355    /**
356     * Indicates whether user can override blacklist on certain action.
357     *
358     * @param User $user
359     * @param string $action
360     *
361     * @return bool
362     */
363    public static function userCanOverride( $user, $action ) {
364        return $user->isAllowed( 'tboverride' ) ||
365            ( $action == 'new-account' && $user->isAllowed( 'tboverride-account' ) );
366    }
367}