Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
11.36% covered (danger)
11.36%
10 / 88
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
TorExitNodes
11.36% covered (danger)
11.36%
10 / 88
0.00% covered (danger)
0.00%
0 / 6
534.65
0.00% covered (danger)
0.00%
0 / 1
 isExitNode
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getExitNodes
33.33% covered (danger)
33.33%
8 / 24
0.00% covered (danger)
0.00%
0 / 1
3.19
 loadExitNodes
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 fetchExitNodes
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 fetchExitNodesFromTorProject
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 fetchExitNodesFromOnionooServer
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3/**
4 * Prevents Tor exit nodes from editing a wiki.
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 * @ingroup Extensions
23 * @link https://www.mediawiki.org/wiki/Extension:TorBlock Documentation
24 *
25 * @author Andrew Garrett <andrew@epstone.net>
26 * @license GPL-2.0-or-later
27 */
28
29namespace MediaWiki\Extension\TorBlock;
30
31use MediaWiki\Context\RequestContext;
32use MediaWiki\Json\FormatJson;
33use MediaWiki\MediaWikiServices;
34use Wikimedia\IPUtils;
35use Wikimedia\LightweightObjectStore\ExpirationAwareness;
36use Wikimedia\ObjectCache\CachedBagOStuff;
37
38/**
39 * Collection of functions maintaining the list of Tor exit nodes.
40 */
41class TorExitNodes {
42    private const CACHE_KEY = 'tor-exit-nodes';
43    private const CACHE_TTL = ExpirationAwareness::TTL_DAY;
44
45    /**
46     * Determine if a given IP is a Tor exit node
47     *
48     * @param string|null $ip The IP address to check, or null to use the request IP
49     * @return bool True if an exit node, false otherwise
50     */
51    public static function isExitNode( $ip = null ) {
52        if ( $ip == null ) {
53            $ip = RequestContext::getMain()->getRequest()->getIP();
54        }
55
56        return in_array( IPUtils::sanitizeIP( $ip ), self::getExitNodes() );
57    }
58
59    /**
60     * Get the array of Tor exit nodes using caching
61     *
62     * @return string[] List of Tor exit node addresses
63     */
64    public static function getExitNodes() {
65        static $srvCache;
66        if ( $srvCache === null ) {
67            $srvCache = new CachedBagOStuff(
68                MediaWikiServices::getInstance()->getLocalServerObjectCache()
69            );
70        }
71
72        return $srvCache->getWithSetCallback(
73            $srvCache->makeGlobalKey( self::CACHE_KEY ),
74            $srvCache::TTL_HOUR,
75            function () {
76                $wanCache = MediaWikiServices::getInstance()->getMainWANObjectCache();
77
78                return $wanCache->getWithSetCallback(
79                    $wanCache->makeGlobalKey( self::CACHE_KEY ),
80                    self::CACHE_TTL,
81                    function () {
82                        return self::fetchExitNodes();
83                    },
84                    [
85                        // Avoid stampedes on TOR list servers due to cache expiration
86                        'lockTSE' => self::CACHE_TTL,
87                        'staleTTL' => self::CACHE_TTL,
88                        // Avoid stampedes on TOR list servers due to cache eviction
89                        'busyValue' => []
90                    ]
91                );
92            }
93        );
94    }
95
96    /**
97     * Load the list of Tor exit nodes from the source and cache it for future use
98     *
99     * Do not call this method during HTTP GET/HEAD requests
100     *
101     * @return string[] List of Tor exit node addresses
102     */
103    public static function loadExitNodes() {
104        $nodes = self::fetchExitNodes();
105
106        $wanCache = MediaWikiServices::getInstance()->getMainWANObjectCache();
107        $wanCache->set( $wanCache->makeGlobalKey( self::CACHE_KEY ), $nodes, self::CACHE_TTL );
108
109        return $nodes;
110    }
111
112    /**
113     * Get the list of Tor exit nodes from the configured source
114     *
115     * @return string[] List of Tor exit node addresses
116     */
117    private static function fetchExitNodes() {
118        wfDebugLog( 'torblock', "Loading Tor exit node list cold." );
119
120        if ( defined( 'MW_PHPUNIT_TEST' ) || defined( 'MW_QUIBBLE_CI' ) ) {
121            // Avoid HTTP requests, see T265628 & T390865
122            // TEST-NET-1, RFC 5737
123            return [ '192.0.2.111', '192.0.2.222' ];
124        }
125
126        return self::fetchExitNodesFromOnionooServer() ?: self::fetchExitNodesFromTorProject();
127    }
128
129    /**
130     * Get the list of Tor exit nodes from the Tor Project's website.
131     *
132     * @return string[] List of Tor exit node addresses
133     */
134    private static function fetchExitNodesFromTorProject() {
135        global $wgTorIPs, $wgTorProjectCA, $wgTorBlockProxy;
136
137        $options = [
138            'caInfo' => is_readable( $wgTorProjectCA ) ? $wgTorProjectCA : null
139        ];
140        if ( $wgTorBlockProxy ) {
141            $options['proxy'] = $wgTorBlockProxy;
142        }
143
144        $httpRequestFactory = MediaWikiServices::getInstance()->getHttpRequestFactory();
145        $nodes = [];
146        foreach ( $wgTorIPs as $ip ) {
147            $url = 'https://check.torproject.org/torbulkexitlist?ip=' . $ip;
148            $data = $httpRequestFactory->get( $url, $options, __METHOD__ );
149
150            if ( $data === null ) {
151                wfDebugLog( 'torblock', "Got no reply or an invalid reply from $url.\n" );
152                continue;
153            }
154
155            $lines = explode( "\n", $data );
156
157            foreach ( $lines as $line ) {
158                if ( strpos( $line, '#' ) === false ) {
159                    $nodes[trim( $line )] = true;
160                }
161            }
162        }
163
164        return array_keys( $nodes );
165    }
166
167    /**
168     * Get the list of Tor exit nodes using the Onionoo protocol with the
169     * server specified in the configuration.
170     *
171     * @return string[] List of Tor exit node addresses
172     */
173    private static function fetchExitNodesFromOnionooServer() {
174        global $wgTorOnionooServer, $wgTorOnionooCA, $wgTorBlockProxy;
175
176        $services = MediaWikiServices::getInstance();
177
178        $url = $services->getUrlUtils()->expand(
179            "$wgTorOnionooServer/details?type=relay&running=true&flag=Exit&fields=or_addresses,exit_addresses",
180            PROTO_HTTPS
181        );
182
183        $options = [
184            'caInfo' => is_readable( $wgTorOnionooCA ) ? $wgTorOnionooCA : null
185        ];
186        if ( $wgTorBlockProxy ) {
187            $options['proxy'] = $wgTorBlockProxy;
188        }
189        $raw = $services->getHttpRequestFactory()->get( (string)$url, $options, __METHOD__ );
190
191        if ( $raw === null ) {
192            wfDebugLog( 'torblock', "Got no reply or an invalid reply from $url.\n" );
193            return [];
194        }
195
196        $data = FormatJson::decode( $raw, true );
197
198        if ( !isset( $data['relays'] ) ) {
199            wfDebugLog( 'torblock', "Got no reply or an invalid reply from Onionoo.\n" );
200            return [];
201        }
202
203        $nodes = [];
204        foreach ( $data['relays'] as $relay ) {
205            $addresses = $relay['or_addresses'];
206            if ( isset( $relay['exit_addresses'] ) ) {
207                $addresses = array_merge( $addresses, $relay['exit_addresses'] );
208            }
209
210            foreach ( $addresses as $ip ) {
211                // Trim the port if it has one.
212                $portPosition = strrpos( $ip, ':' );
213                if ( $portPosition !== false ) {
214                    $ip = substr( $ip, 0, $portPosition );
215                }
216
217                // Trim surrounding brackets for IPv6 addresses.
218                $hasBrackets = $ip[0] == '[';
219                if ( $hasBrackets ) {
220                    $ip = substr( $ip, 1, -1 );
221                }
222
223                if ( !IPUtils::isValid( $ip ) ) {
224                    wfDebug( 'Invalid IP address in Onionoo response.' );
225                    continue;
226                }
227
228                $nodes[IPUtils::sanitizeIP( $ip )] = true;
229            }
230        }
231
232        return array_keys( $nodes );
233    }
234}