Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.37% covered (success)
97.37%
111 / 114
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
PreAuthenticationProvider
97.37% covered (success)
97.37%
111 / 114
66.67% covered (warning)
66.67%
2 / 3
23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 testForAccountCreation
94.74% covered (success)
94.74%
54 / 57
0.00% covered (danger)
0.00%
0 / 1
15.03
 getIPoidDataFor
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3namespace MediaWiki\Extension\IPReputation;
4
5use ApiMessage;
6use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
7use MediaWiki\Auth\AbstractPreAuthenticationProvider;
8use MediaWiki\Http\HttpRequestFactory;
9use MediaWiki\Language\FormatterFactory;
10use MediaWiki\Logger\LoggerFactory;
11use MediaWiki\Permissions\PermissionManager;
12use MediaWiki\User\UserIdentity;
13use RequestContext;
14use StatusValue;
15use WANObjectCache;
16use Wikimedia\IPUtils;
17
18/**
19 * PreAuthentication provider that checks if an IP address is known to IPoid
20 *
21 * @see https://wikitech.wikimedia.org/wiki/Service/IPoid
22 */
23class PreAuthenticationProvider extends AbstractPreAuthenticationProvider {
24
25    private HttpRequestFactory $httpRequestFactory;
26    private StatsdDataFactoryInterface $statsDataFactory;
27    private WANObjectCache $cache;
28    private FormatterFactory $formatterFactory;
29    private PermissionManager $permissionManager;
30
31    public function __construct(
32        FormatterFactory $formatterFactory,
33        HttpRequestFactory $httpRequestFactory,
34        WANObjectCache $cache,
35        StatsdDataFactoryInterface $statsDataFactory,
36        PermissionManager $permissionManager
37    ) {
38        $this->formatterFactory = $formatterFactory;
39        $this->httpRequestFactory = $httpRequestFactory;
40        $this->cache = $cache;
41        $this->statsDataFactory = $statsDataFactory;
42        $this->permissionManager = $permissionManager;
43        $this->logger = LoggerFactory::getInstance( 'IPReputation' );
44    }
45
46    /** @inheritDoc */
47    public function testForAccountCreation( $user, $creator, array $reqs ) {
48        // If feature flag is off, don't do any checks, let the user proceed.
49        if ( !$this->config->get( 'IPReputationIPoidCheckAtAccountCreation' ) ) {
50            return StatusValue::newGood();
51        }
52
53        if (
54            $this->permissionManager->userHasAnyRight(
55                $creator,
56                'ipblock-exempt',
57            )
58        ) {
59            return StatusValue::newGood();
60        }
61
62        $ip = $this->manager->getRequest()->getIP();
63
64        $data = $this->getIPoidDataFor( $user, $ip );
65        if ( !$data ) {
66            // IPoid doesn't know anything about this IP; let the authentication request proceed.
67            return StatusValue::newGood();
68        }
69
70        $shouldLogOnly = $this->config->get( 'IPReputationIPoidCheckAtAccountCreationLogOnly' );
71
72        if ( !isset( $data['risks'] ) || !$data['risks'] ) {
73            // 'risks' should always be set and populated, but if not set to 'UNKNOWN'.
74            $data['risks'] = [ 'UNKNOWN' ];
75        }
76
77        if ( !isset( $data['tunnels'] ) ) {
78            // 'tunnels' should always be set, but if not set to empty list.
79            $data['tunnels'] = [];
80        }
81
82        $risksToBlock = $this->config->get( 'IPReputationIPoidDenyAccountCreationRiskTypes' );
83        $tunnelTypesToBlock = $this->config->get( 'IPReputationIPoidDenyAccountCreationTunnelTypes' );
84
85        $risks = $data['risks'];
86        sort( $risks );
87
88        $tunnels = $data['tunnels'];
89        sort( $tunnels );
90
91        // Allow for the possibility to exclude VPN users from having account
92        // creation denied, if the only risk type known for the IP is that it's a VPN,
93        // and if config is set up to allow VPN tunnel types.
94        // That would be done with:
95        // $wgIPReputationDenyAccountCreationRiskTypes = [ 'TUNNEL', 'CALLBACK_PROXY', ... ];
96        // $wgIPReputationDenyAccountCreationTunnelTypes = [ 'PROXY', 'UNKNOWN' ];
97        // If the only risk type is a TUNNEL...
98        if (
99            $risks === [ 'TUNNEL' ]
100            // and there are tunnels listed for the IP
101            && count( $tunnels )
102            // and we have configured TUNNEL as a risk type to block
103            && in_array( 'TUNNEL', $risksToBlock )
104            // and the configured tunnel types to block are *not* present in the data
105            && !array_intersect( $tunnelTypesToBlock, $tunnels )
106        ) {
107            $this->logger->debug(
108                'Allowing account creation for user {user} as IP {ip} is known to IPoid '
109                . 'with only non-blocked tunnels ({tunnelTypes})',
110                [
111                    'user' => $user->getName(),
112                    'ip' => $ip,
113                    'tunnelTypes' => implode( ' ', $tunnels ),
114                    'IPoidData' => json_encode( $data ),
115                ]
116            );
117            return StatusValue::newGood();
118        }
119
120        // Otherwise, check for other risks.
121        $blockedRisks = array_intersect( $risksToBlock, $risks );
122        if ( $blockedRisks ) {
123            $prefixText = $shouldLogOnly ? '[log only] Would have blocked ' : 'Blocking ';
124            $this->logger->notice(
125                $prefixText . 'account creation for user {user} as IP {ip} is known to IPoid '
126                . 'with risks {riskTypes} (blocked due to {blockedRiskTypes})',
127                [
128                    'user' => $user->getName(),
129                    'ip' => $ip,
130                    'riskTypes' => implode( ' ', $risks ),
131                    'blockedRiskTypes' => implode( ' ', $blockedRisks ),
132                    'IPoidData' => json_encode( $data ),
133                ]
134            );
135
136            $statsdKey = $shouldLogOnly ? 'DenyAccountCreationLogOnly' : 'DenyAccountCreation';
137            $this->statsDataFactory->increment( "IPReputation.$statsdKey." . implode( '_', $risks ) );
138
139            if ( $shouldLogOnly ) {
140                return StatusValue::newGood();
141            }
142
143            return StatusValue::newFatal( ApiMessage::create( 'ipreputation-blocked-ip-reputation', 'autoblocked' ) );
144        }
145
146        return StatusValue::newGood();
147    }
148
149    /**
150     * @param UserIdentity $user
151     * @param string $ip
152     *
153     * @return array|null IPoid data for the specific address, or null if there is no data
154     */
155    private function getIPoidDataFor( UserIdentity $user, string $ip ): ?array {
156        $data = $this->cache->getWithSetCallback(
157            $this->cache->makeGlobalKey( 'ipreputation-ipoid', $ip ),
158            // IPoid data is refreshed every 24 hours and roughly 10% of its IPs drop out
159            // of the database each 24-hour cycle. A one hour TTL seems reasonable to allow
160            // no longer problematic IPs to get evicted from the cache relatively quickly,
161            // and also means that IPs for e.g. residential proxies are updated in our cache
162            // relatively quickly.
163            $this->cache::TTL_HOUR,
164            function () use ( $ip, $user ) {
165                // If IPoid URL isn't configured, don't do any checks, let the user proceed.
166                $baseUrl = $this->config->get( 'IPReputationIPoidUrl' );
167                if ( !$baseUrl ) {
168                    $this->logger->warning(
169                        'Configured to check IP reputation on signup, but no IPoid URL configured'
170                    );
171                    // Don't cache this.
172                    return false;
173                }
174
175                $timeout = $this->config->get( 'IPReputationIPoidRequestTimeoutSeconds' );
176                // Convert IPv6 to lowercase, to match IPoid storage format.
177                $url = $baseUrl . '/feed/v1/ip/' . IPUtils::prettifyIP( $ip );
178                $request = $this->httpRequestFactory->create( $url, [
179                    'method' => 'GET',
180                    'timeout' => $timeout,
181                    'connectTimeout' => 1,
182                ] );
183                $response = $request->execute();
184                if ( !$response->isOK() ) {
185                    // Probably a 404, which means IPoid doesn't know about the IP.
186                    // If not a 404, log it, so we can figure out what happened.
187                    if ( $request->getStatus() !== 404 ) {
188                        $statusFormatter = $this->formatterFactory->getStatusFormatter( RequestContext::getMain() );
189                        [ $errorText, $context ] = $statusFormatter->getPsr3MessageAndContext( $response );
190                        $this->logger->error( $errorText, $context );
191                    }
192
193                    return null;
194                }
195
196                $data = json_decode( $request->getContent(), true );
197
198                if ( !$data ) {
199                    // Malformed data.
200                    $this->logger->error(
201                        'Got invalid JSON data while checking user {user} with IP {ip}',
202                        [
203                            'ip' => $ip,
204                            'user' => $user->getName(),
205                            'response' => $request->getContent()
206                        ]
207                    );
208                    return null;
209                }
210
211                // IPoid will return the IP in lower case format, and we are searching for the
212                // indexed value in the returned array.
213                if ( !isset( $data[IPUtils::prettifyIP( $ip )] ) ) {
214                    // IP should always be set in the data array, but just to be safe.
215                    $this->logger->error(
216                        'Got JSON data with no IP {ip} present while checking user {user}',
217                        [
218                            'ip' => $ip,
219                            'user' => $user->getName(),
220                            'response' => $request->getContent()
221                        ]
222                    );
223                    return null;
224                }
225
226                // We have a match and valid data structure;
227                // return the values for this IP for storage in the cache.
228                return $data[$ip];
229            }
230        );
231
232        // Unlike null, false tells cache not to cache something. Normalize both to null before returning.
233        if ( $data === false ) {
234            return null;
235        }
236
237        return $data;
238    }
239}