Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
34.12% covered (danger)
34.12%
29 / 85
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
TorExitNodes
34.12% covered (danger)
34.12%
29 / 85
16.67% covered (danger)
16.67%
1 / 6
219.31
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
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 loadExitNodes
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 fetchExitNodes
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 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 / 32
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 CachedBagOStuff;
32use FormatJson;
33use MediaWiki\MediaWikiServices;
34use RequestContext;
35use Wikimedia\IPUtils;
36
37/**
38 * Collection of functions maintaining the list of Tor exit nodes.
39 */
40class TorExitNodes {
41    /**
42     * Determine if a given IP is a Tor exit node
43     *
44     * @param string|null $ip The IP address to check, or null to use the request IP
45     * @return bool True if an exit node, false otherwise
46     */
47    public static function isExitNode( $ip = null ) {
48        if ( $ip == null ) {
49            $ip = RequestContext::getMain()->getRequest()->getIP();
50        }
51
52        return in_array( IPUtils::sanitizeIP( $ip ), self::getExitNodes() );
53    }
54
55    /**
56     * Get the array of Tor exit nodes using caching
57     *
58     * @return string[] List of Tor exit node addresses
59     */
60    public static function getExitNodes() {
61        static $srvCache;
62        if ( $srvCache === null ) {
63            $srvCache = new CachedBagOStuff(
64                MediaWikiServices::getInstance()->getLocalServerObjectCache()
65            );
66        }
67
68        return $srvCache->getWithSetCallback(
69            $srvCache->makeGlobalKey( 'tor-exit-nodes' ),
70            $srvCache::TTL_HOUR,
71            function () {
72                $wanCache = MediaWikiServices::getInstance()->getMainWANObjectCache();
73
74                return $wanCache->getWithSetCallback(
75                    $wanCache->makeGlobalKey( 'tor-exit-nodes' ),
76                    $wanCache::TTL_DAY,
77                    function () {
78                        return self::fetchExitNodes();
79                    },
80                    [
81                        // Avoid stampedes on TOR list servers due to cache expiration
82                        'lockTSE' => $wanCache::TTL_DAY,
83                        'staleTTL' => $wanCache::TTL_DAY,
84                        // Avoid stampedes on TOR list servers due to cache eviction
85                        'busyValue' => []
86                    ]
87                );
88            }
89        );
90    }
91
92    /**
93     * Load the list of Tor exit nodes from the source and cache it for future use
94     *
95     * Do not call this method during HTTP GET/HEAD requests
96     *
97     * @return string[] List of Tor exit node addresses
98     */
99    public static function loadExitNodes() {
100        $nodes = self::fetchExitNodes();
101
102        $wanCache = MediaWikiServices::getInstance()->getMainWANObjectCache();
103        $wanCache->set( $wanCache->makeGlobalKey( 'tor-exit-nodes' ), $nodes, $wanCache::TTL_DAY );
104
105        return $nodes;
106    }
107
108    /**
109     * Get the list of Tor exit nodes from the configured source
110     *
111     * @return string[] List of Tor exit node addresses
112     */
113    private static function fetchExitNodes() {
114        wfDebugLog( 'torblock', "Loading Tor exit node list cold." );
115
116        if ( defined( 'MW_PHPUNIT_TEST' ) ) {
117            // TEST-NET-1, RFC 5737
118            return [ '192.0.2.111', '192.0.2.222' ];
119        }
120
121        return self::fetchExitNodesFromOnionooServer() ?: self::fetchExitNodesFromTorProject();
122    }
123
124    /**
125     * Get the list of Tor exit nodes from the Tor Project's website.
126     *
127     * @return string[] List of Tor exit node addresses
128     */
129    private static function fetchExitNodesFromTorProject() {
130        global $wgTorIPs, $wgTorProjectCA, $wgTorBlockProxy;
131
132        $options = [
133            'caInfo' => is_readable( $wgTorProjectCA ) ? $wgTorProjectCA : null
134        ];
135        if ( $wgTorBlockProxy ) {
136            $options['proxy'] = $wgTorBlockProxy;
137        }
138
139        $httpRequestFactory = MediaWikiServices::getInstance()->getHttpRequestFactory();
140        $nodes = [];
141        foreach ( $wgTorIPs as $ip ) {
142            $url = 'https://check.torproject.org/torbulkexitlist?ip=' . $ip;
143            $data = $httpRequestFactory->get( $url, $options, __METHOD__ );
144
145            if ( $data === null ) {
146                wfDebugLog( 'torblock', "Got no reply or an invalid reply from $url.\n" );
147                continue;
148            }
149
150            $lines = explode( "\n", $data );
151
152            foreach ( $lines as $line ) {
153                if ( strpos( $line, '#' ) === false ) {
154                    $nodes[trim( $line )] = true;
155                }
156            }
157        }
158
159        return array_keys( $nodes );
160    }
161
162    /**
163     * Get the list of Tor exit nodes using the Onionoo protocol with the
164     * server specified in the configuration.
165     *
166     * @return string[] List of Tor exit node addresses
167     */
168    private static function fetchExitNodesFromOnionooServer() {
169        global $wgTorOnionooServer, $wgTorOnionooCA, $wgTorBlockProxy;
170
171        $url = wfExpandUrl( "$wgTorOnionooServer/details?type=relay&running=true&flag=Exit",
172            PROTO_HTTPS );
173        $options = [
174            'caInfo' => is_readable( $wgTorOnionooCA ) ? $wgTorOnionooCA : null
175        ];
176        if ( $wgTorBlockProxy ) {
177            $options['proxy'] = $wgTorBlockProxy;
178        }
179        $raw = MediaWikiServices::getInstance()->getHttpRequestFactory()->get( $url, $options, __METHOD__ );
180
181        if ( $raw === null ) {
182            wfDebugLog( 'torblock', "Got no reply or an invalid reply from $url.\n" );
183            return [];
184        }
185
186        $data = FormatJson::decode( $raw, true );
187
188        if ( !isset( $data['relays'] ) ) {
189            wfDebugLog( 'torblock', "Got no reply or an invalid reply from Onionoo.\n" );
190            return [];
191        }
192
193        $nodes = [];
194        foreach ( $data['relays'] as $relay ) {
195            $addresses = $relay['or_addresses'];
196            if ( isset( $relay['exit_addresses'] ) ) {
197                $addresses = array_merge( $addresses, $relay['exit_addresses'] );
198            }
199
200            foreach ( $addresses as $ip ) {
201                // Trim the port if it has one.
202                $portPosition = strrpos( $ip, ':' );
203                if ( $portPosition !== false ) {
204                    $ip = substr( $ip, 0, $portPosition );
205                }
206
207                // Trim surrounding brackets for IPv6 addresses.
208                $hasBrackets = $ip[0] == '[';
209                if ( $hasBrackets ) {
210                    $ip = substr( $ip, 1, -1 );
211                }
212
213                if ( !IPUtils::isValid( $ip ) ) {
214                    wfDebug( 'Invalid IP address in Onionoo response.' );
215                    continue;
216                }
217
218                $nodes[IPUtils::sanitizeIP( $ip )] = true;
219            }
220        }
221
222        return array_keys( $nodes );
223    }
224}