Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.68% covered (success)
98.68%
75 / 76
92.31% covered (success)
92.31%
12 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
98.68% covered (success)
98.68%
75 / 76
92.31% covered (success)
92.31%
12 / 13
39
0.00% covered (danger)
0.00%
0 / 1
 registerExtension
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkUserCan
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 onGetUserPermissionsErrorsExpensive
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 onUserCanSendEmail
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 onGetUserBlock
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 onAbortAutoblock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onGetAutoPromoteGroups
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 onUserRequirementsCondition
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 onRecentChange_save
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 onListDefinedTags
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 onChangeTagsListActive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onOtherBlockLogLink
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Hooks for the Extension:TorBlock for MediaWiki
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\Block\AbstractBlock;
32use MediaWiki\Block\AutoblockExemptionList;
33use MediaWiki\Block\DatabaseBlock;
34use MediaWiki\Block\Hook\AbortAutoblockHook;
35use MediaWiki\Block\Hook\GetUserBlockHook;
36use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
37use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
38use MediaWiki\Context\RequestContext;
39use MediaWiki\Extension\TorBlock\Hooks\HookRunner;
40use MediaWiki\HookContainer\HookContainer;
41use MediaWiki\Html\Html;
42use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsExpensiveHook;
43use MediaWiki\RecentChanges\Hook\RecentChange_saveHook;
44use MediaWiki\RecentChanges\RecentChange;
45use MediaWiki\Specials\Hook\OtherBlockLogLinkHook;
46use MediaWiki\Title\Title;
47use MediaWiki\User\Hook\GetAutoPromoteGroupsHook;
48use MediaWiki\User\Hook\UserCanSendEmailHook;
49use MediaWiki\User\Hook\UserRequirementsConditionHook;
50use MediaWiki\User\User;
51use MediaWiki\User\UserIdentity;
52use Wikimedia\IPUtils;
53
54class Hooks implements
55    AbortAutoblockHook,
56    UserRequirementsConditionHook,
57    GetUserPermissionsErrorsExpensiveHook,
58    GetAutoPromoteGroupsHook,
59    GetUserBlockHook,
60    RecentChange_saveHook,
61    ListDefinedTagsHook,
62    ChangeTagsListActiveHook,
63    UserCanSendEmailHook,
64    OtherBlockLogLinkHook
65{
66
67    private readonly HookRunner $hookRunner;
68
69    public static function registerExtension() {
70        // Define new autopromote condition
71        // Numbers won't work, we'll get collisions
72        define( 'APCOND_TOR', 'tor' );
73    }
74
75    public function __construct(
76        private readonly AutoblockExemptionList $autoblockExemptionList,
77        HookContainer $hookContainer,
78    ) {
79        $this->hookRunner = new HookRunner( $hookContainer );
80    }
81
82    /**
83     * Whether the given user is allowed to perform $action from its current IP
84     *
85     * @param User $user
86     * @param string|null $action
87     * @return bool
88     */
89    private function checkUserCan( User $user, $action = null ) {
90        global $wgTorAllowedActions;
91
92        if ( ( $action !== null && in_array( $action, $wgTorAllowedActions ) )
93            || !TorExitNodes::isExitNode()
94        ) {
95            return true;
96        }
97
98        wfDebugLog( 'torblock', "User detected as editing through tor." );
99
100        global $wgTorBypassPermissions;
101        foreach ( $wgTorBypassPermissions as $perm ) {
102            if ( $user->isAllowed( $perm ) ) {
103                wfDebugLog( 'torblock', "User has $perm permission. Exempting from Tor Blocks." );
104
105                return true;
106            }
107        }
108
109        $ip = RequestContext::getMain()->getRequest()->getIP();
110
111        if ( $this->autoblockExemptionList->isExempt( $ip ) ) {
112            wfDebugLog( 'torblock', "IP is excluded from autoblocks. Exempting from Tor Blocks." );
113
114            return true;
115        }
116
117        return false;
118    }
119
120    /**
121     * Check if a user is a Tor node and not excluded from autoblocks or allowed
122     * to bypass tor blocks.
123     *
124     * @param Title $title Title being acted upon
125     * @param User $user User performing the action
126     * @param string $action Action being performed
127     * @param array &$result Will be filled with block status if blocked
128     * @return bool
129     */
130    public function onGetUserPermissionsErrorsExpensive(
131        $title,
132        $user,
133        $action,
134        &$result
135    ) {
136        if ( !$this->checkUserCan( $user, $action ) ) {
137            wfDebugLog( 'torblock', "User detected as editing from Tor node. " .
138                "Adding Tor block to permissions errors." );
139
140            // Allow site customization of blocked message.
141            $blockedMsg = 'torblock-blocked';
142            $this->hookRunner->onTorBlockBlockedMsg( $blockedMsg );
143            $result = [ $blockedMsg, RequestContext::getMain()->getRequest()->getIP() ];
144
145            return false;
146        }
147
148        return true;
149    }
150
151    /**
152     * Check if the user is logged in from a Tor exit node but is not exempt.
153     * If so, block the user.
154     *
155     * @param User $user
156     * @param array &$hookErr
157     * @return bool
158     */
159    public function onUserCanSendEmail( $user, &$hookErr ) {
160        if ( !$this->checkUserCan( $user ) ) {
161            wfDebugLog( 'torblock', "User detected as trying to send an email from Tor node. Preventing." );
162
163            // Allow site customization of blocked message.
164            $blockedMsg = 'torblock-blocked';
165            $this->hookRunner->onTorBlockBlockedMsg( $blockedMsg );
166            $hookErr = [
167                'permissionserrors',
168                $blockedMsg,
169                [ RequestContext::getMain()->getRequest()->getIP() ],
170            ];
171            return false;
172        }
173
174        return true;
175    }
176
177    /**
178     * Remove a block if it only targets a Tor node. A composite block comprises
179     * multiple blocks, and if any of these target the user, then do not remove the
180     * block.
181     *
182     * @param User $user
183     * @param string|null $ip
184     * @param AbstractBlock|null &$block
185     * @return bool
186     */
187    public function onGetUserBlock( $user, $ip, &$block ) {
188        global $wgTorDisableAdminBlocks;
189        if ( !$block || !$wgTorDisableAdminBlocks || !TorExitNodes::isExitNode() ) {
190            return true;
191        }
192
193        $blocks = $block->toArray();
194
195        $removeBlock = true;
196        foreach ( $blocks as $singleBlock ) {
197            if ( $singleBlock->getType() === AbstractBlock::TYPE_USER ) {
198                $removeBlock = false;
199                break;
200            }
201        }
202
203        if ( $removeBlock ) {
204            wfDebugLog( 'torblock', "User using Tor node. Disabling IP block as it was " .
205                "probably targeted at the Tor node." );
206            // Node is probably blocked for being a Tor node. Remove block.
207            $block = null;
208        }
209
210        return true;
211    }
212
213    /**
214     * If an IP address is an exit node, stop it from being autoblocked.
215     *
216     * @param string $autoblockip IP address being blocked
217     * @param DatabaseBlock $block Block being applied
218     * @return bool
219     */
220    public function onAbortAutoblock( $autoblockip, $block ) {
221        return !TorExitNodes::isExitNode( $autoblockip );
222    }
223
224    /**
225     * When the user is a Tor exit node, make sure they meet configured
226     * age/edit count requirements before allowing promotions.
227     *
228     * @param User $user User being promoted
229     * @param array &$promote Groups being added
230     * @return bool
231     */
232    public function onGetAutoPromoteGroups( $user, &$promote ) {
233        global $wgTorAutoConfirmAge, $wgTorAutoConfirmCount;
234
235        // Check against stricter requirements for tor nodes.
236        // Counterintuitively, we do the requirement checks first.
237        // This is so that we don't have to hit memcached to get the
238        // exit list, unnecessarily.
239
240        if ( !count( $promote ) ) {
241            // No groups to promote to anyway
242            return true;
243        }
244
245        $age = time() - (int)wfTimestampOrNull( TS_UNIX, $user->getRegistration() );
246
247        if ( $age >= $wgTorAutoConfirmAge && $user->getEditCount() >= $wgTorAutoConfirmCount ) {
248            // Does match requirements. Don't bother checking if we're an exit node.
249            return true;
250        }
251
252        if ( TorExitNodes::isExitNode() ) {
253            // Tor user, doesn't match the expanded requirements.
254            $promote = [];
255        }
256
257        return true;
258    }
259
260    /**
261     * @inheritDoc
262     */
263    public function onUserRequirementsCondition(
264        string|int $type,
265        array $args,
266        UserIdentity $user,
267        bool $isPerformingRequest,
268        ?bool &$result
269    ): void {
270        if ( $type == APCOND_TOR ) {
271            if (
272                $user->getWikiId() !== UserIdentity::LOCAL ||
273                !$isPerformingRequest
274            ) {
275                $result = false;
276                return;
277            }
278            $result = TorExitNodes::isExitNode();
279        }
280    }
281
282    /**
283     * If enabled, tag recent changes made by a Tor exit node.
284     *
285     * @param RecentChange $recentChange The change being saved
286     * @return bool true
287     */
288    public function onRecentChange_save( $recentChange ) {
289        global $wgTorTagChanges;
290
291        if ( $wgTorTagChanges && TorExitNodes::isExitNode() ) {
292            $recentChange->addTags( 'tor' );
293        }
294        return true;
295    }
296
297    /**
298     * If enabled, add a new tag type for recent changes made by Tor exit nodes.
299     *
300     * @param array &$emptyTags List of defined tags (for ListDefinedTags hook) or
301     * list of active tags (for ChangeTagsListActive hook)
302     * @return bool true
303     */
304    public function onListDefinedTags( &$emptyTags ) {
305        global $wgTorTagChanges;
306
307        if ( $wgTorTagChanges ) {
308            $emptyTags[] = 'tor';
309        }
310        return true;
311    }
312
313    /**
314     * @param string[] &$tags
315     *
316     * @return bool
317     */
318    public function onChangeTagsListActive( &$tags ) {
319        return $this->onListDefinedTags( $tags );
320    }
321
322    /**
323     * Creates a message with the Tor blocking status if applicable.
324     *
325     * @param array &$msg Message with the status
326     * @param string $ip The IP address to be checked
327     * @return bool true
328     */
329    public function onOtherBlockLogLink( &$msg, $ip ) {
330        // IP addresses can be blocked only
331        // Fast return if IP is not an exit node
332        if ( IPUtils::isIPAddress( $ip ) && TorExitNodes::isExitNode( $ip ) ) {
333            $msg[] = Html::rawElement(
334                'span',
335                [ 'class' => 'mw-torblock-isexitnode' ],
336                wfMessage( 'torblock-isexitnode', $ip )->parse()
337            );
338        }
339        return true;
340    }
341}