Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.35% covered (success)
97.35%
110 / 113
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralAuthIpReputationPreAuthenticationProvider
97.35% covered (success)
97.35%
110 / 113
66.67% covered (warning)
66.67%
2 / 3
22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
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
14.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\CentralAuth;
4
5use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
6use MediaWiki\Api\ApiMessage;
7use MediaWiki\Auth\AbstractPreAuthenticationProvider;
8use MediaWiki\Context\RequestContext;
9use MediaWiki\Extension\CentralAuth\Config\CAMainConfigNames;
10use MediaWiki\Http\HttpRequestFactory;
11use MediaWiki\Language\FormatterFactory;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\Permissions\PermissionManager;
14use MediaWiki\User\UserIdentity;
15use StatusValue;
16use Wikimedia\IPUtils;
17use Wikimedia\ObjectCache\WANObjectCache;
18
19/**
20 * PreAuthentication provider that checks if an IP address is known to ipoid
21 * @see https://wikitech.wikimedia.org/wiki/Service/IPoid
22 */
23class CentralAuthIpReputationPreAuthenticationProvider 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    }
44
45    /** @inheritDoc */
46    public function testForAccountCreation( $user, $creator, array $reqs ) {
47        // If feature flag is off, don't do any checks, let the user proceed.
48        if ( !$this->config->get( CAMainConfigNames::CentralAuthIpoidCheckAtAccountCreation ) ) {
49            return StatusValue::newGood();
50        }
51
52        if (
53            $this->permissionManager->userHasAnyRight(
54                $creator,
55                'ipblock-exempt',
56            )
57        ) {
58            return StatusValue::newGood();
59        }
60
61        $this->logger = LoggerFactory::getInstance( 'IpReputation' );
62
63        $ip = $this->manager->getRequest()->getIP();
64
65        $data = $this->getIpoidDataFor( $user, $ip );
66        if ( !$data ) {
67            // ipoid doesn't know anything about this IP, so let the authentication request proceed.
68            return StatusValue::newGood();
69        }
70
71        $shouldLogOnly = $this->config->get( CAMainConfigNames::CentralAuthIpoidCheckAtAccountCreationLogOnly );
72
73        if ( !isset( $data['risks'] ) || !$data['risks'] ) {
74            // 'risks' should always be set and populated, but if not set to 'UNKNOWN'.
75            $data['risks'] = [ 'UNKNOWN' ];
76        }
77
78        if ( !isset( $data['tunnels'] ) ) {
79            // 'tunnels' should always be set, but if not set to empty list.
80            $data['tunnels'] = [];
81        }
82
83        $risksToBlock = $this->config->get( CAMainConfigNames::CentralAuthIpoidDenyAccountCreationRiskTypes );
84        $tunnelTypesToBlock = $this->config->get( CAMainConfigNames::CentralAuthIpoidDenyAccountCreationTunnelTypes );
85
86        $risks = $data['risks'];
87        sort( $risks );
88
89        $tunnels = $data['tunnels'];
90        sort( $tunnels );
91
92        // Allow for the possibility to exclude VPN users from having account
93        // creation denied, if the only risk type known for the IP is that it's a VPN,
94        // and if config is set up to allow VPN tunnel types.
95        // That would be done with:
96        // $wgCentralAuthIpoidDenyAccountCreationRiskTypes = [ 'TUNNEL', 'CALLBACK_PROXY', ... ];
97        // $wgCentralAuthIpoidDenyAccountCreationTunnelTypes = [ 'PROXY', 'UNKNOWN' ];
98        // If the only risk type is a TUNNEL...
99        if (
100            $risks === [ 'TUNNEL' ]
101            // and there are tunnels listed for the IP
102            && count( $tunnels )
103            // and we have configured TUNNEL as a risk type to block
104            && in_array( 'TUNNEL', $risksToBlock )
105            // and the configured tunnel types to block are *not* present in the data
106            && !array_intersect( $tunnelTypesToBlock, $tunnels )
107        ) {
108            $this->logger->debug(
109                'Allowing account creation for user {user} as IP {ip} is known to iPoid '
110                . 'with only non-blocked tunnels ({tunnelTypes})',
111                [
112                    'user' => $user->getName(),
113                    'ip' => $ip,
114                    'tunnelTypes' => implode( ' ', $tunnels ),
115                    'ipoidData' => json_encode( $data ),
116                ]
117            );
118            return StatusValue::newGood();
119        }
120
121        // Otherwise, check for other risks.
122        $blockedRisks = array_intersect( $risksToBlock, $risks );
123        if ( $blockedRisks ) {
124            $this->logger->notice(
125                'Blocking 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 ? 'DenyCreationLogOnly' : 'DenyCreation';
137            $this->statsDataFactory->increment( "CentralAuth.IpReputation.$statsdKey." . implode( '_', $risks ) );
138
139            if ( $shouldLogOnly ) {
140                return StatusValue::newGood();
141            }
142
143            return StatusValue::newFatal( ApiMessage::create( 'centralauth-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        $fname = __METHOD__;
157        $data = $this->cache->getWithSetCallback(
158            $this->cache->makeGlobalKey( 'centralauth-ipoid', $ip ),
159            // ipoid data is refreshed every 24 hours and roughly 10% of its IPs drop out
160            // of the database each 24-hour cycle. A one hour TTL seems reasonable to allow
161            // no longer problematic IPs to get evicted from the cache relatively quickly,
162            // and also means that IPs for e.g. residential proxies are updated in our cache
163            // relatively quickly.
164            $this->cache::TTL_HOUR,
165            function () use ( $ip, $user, $fname ) {
166                // If ipoid URL isn't configured, don't do any checks, let the user proceed.
167                $baseUrl = $this->config->get( CAMainConfigNames::CentralAuthIpoidUrl );
168                if ( !$baseUrl ) {
169                    $this->logger->warning(
170                        'Configured to check IP reputation on signup, but no iPoid URL configured'
171                    );
172                    // Don't cache this.
173                    return false;
174                }
175
176                $timeout = $this->config->get( CAMainConfigNames::CentralAuthIpoidRequestTimeoutSeconds );
177                // Convert IPv6 to lowercase, to match ipoid storage format.
178                $url = $baseUrl . '/feed/v1/ip/' . IPUtils::prettifyIP( $ip );
179                $request = $this->httpRequestFactory->create( $url, [
180                    'method' => 'GET',
181                    'timeout' => $timeout,
182                    'connectTimeout' => 1,
183                ], $fname );
184                $response = $request->execute();
185                if ( !$response->isOK() ) {
186                    // Probably a 404, which means ipoid doesn't know about the IP.
187                    // If not a 404, log it, so we can figure out what happened.
188                    if ( $request->getStatus() !== 404 ) {
189                        $statusFormatter = $this->formatterFactory->getStatusFormatter( RequestContext::getMain() );
190                        $this->logger->error( ...$statusFormatter->getPsr3MessageAndContext( $response ) );
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}