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