Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.66% covered (warning)
60.66%
37 / 61
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SharedDomainHookHandler
60.66% covered (warning)
60.66%
37 / 61
33.33% covered (danger)
33.33%
3 / 9
99.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 onSetupAfterCache
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onGetUserPermissionsErrors
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
 onApiCheckCanExecute
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 onBeforePageDisplay
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 onResourceLoaderModifyEmbeddedSourceUrls
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onAuthManagerFilterProviders
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 onAuthManagerVerifyAuthentication
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 onGetLocalURL
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\Hooks\Handlers;
4
5use LogicException;
6use MediaWiki\Api\Hook\ApiCheckCanExecuteHook;
7use MediaWiki\Auth\AuthenticationResponse;
8use MediaWiki\Auth\AuthManager;
9use MediaWiki\Auth\Hook\AuthManagerFilterProvidersHook;
10use MediaWiki\Auth\Hook\AuthManagerVerifyAuthenticationHook;
11use MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider;
12use MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider;
13use MediaWiki\Context\RequestContext;
14use MediaWiki\Extension\CentralAuth\CentralAuthRedirectingPrimaryAuthenticationProvider;
15use MediaWiki\Extension\CentralAuth\FilteredRequestTracker;
16use MediaWiki\Extension\CentralAuth\SharedDomainUtils;
17use MediaWiki\Hook\GetLocalURLHook;
18use MediaWiki\Hook\SetupAfterCacheHook;
19use MediaWiki\Output\Hook\BeforePageDisplayHook;
20use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook;
21use MediaWiki\ResourceLoader\Hook\ResourceLoaderModifyEmbeddedSourceUrlsHook;
22use MediaWiki\User\UserIdentity;
23use MediaWiki\Utils\UrlUtils;
24use MediaWiki\WikiMap\WikiMap;
25use MobileContext;
26use MWExceptionHandler;
27use Wikimedia\NormalizedException\NormalizedException;
28
29/**
30 * Ensure that the shared domain cannot be used for anything that is unrelated to its purpose.
31 */
32class SharedDomainHookHandler implements
33    ApiCheckCanExecuteHook,
34    AuthManagerFilterProvidersHook,
35    AuthManagerVerifyAuthenticationHook,
36    BeforePageDisplayHook,
37    GetLocalURLHook,
38    GetUserPermissionsErrorsHook,
39    ResourceLoaderModifyEmbeddedSourceUrlsHook,
40    SetupAfterCacheHook
41{
42
43    // Allowlists of things a user can do on the shared domain.
44    // FIXME these should be configurable and/or come from extension attributes
45    // 'static' is WMF's custom static.php entry point, serving some files on the shared domain (T374286)
46    private const ALLOWED_ENTRY_POINTS = [ 'index', 'api', 'static', 'cli' ];
47    private const ALLOWED_SPECIAL_PAGES = [ 'Userlogin', 'Userlogout', 'CreateAccount',
48        'PasswordReset', 'Captcha' ];
49    private const ALLOWED_API_MODULES = [
50        // needed for allowing any query API, even if we only want meta modules; it can be
51        // used to check page existence (which is unwanted functionality on the shared domain),
52        // which is unfortunate but permissions will still be checked, so it's not a risk.
53        'query',
54        // allow login/signup directly via the API + help for those APIs
55        'clientlogin', 'createaccount', 'authmanagerinfo', 'paraminfo', 'help',
56        // APIs used during web login
57        'validatepassword', 'userinfo', 'webauthn', 'fancycaptchareload',
58        // generic meta APIs, there's a good chance something somewhere will use them
59        'siteinfo', 'globaluserinfo', 'tokens',
60    ];
61
62    // List of authentication providers which should be skipped on the local login page in
63    // SUL3 mode, because they will be done on the shared domain instead.
64    // This is somewhat fragile, e.g. in case of class renamespacing. We inherit that from
65    // AuthManager and can't do much about it. It fails in the safe direction, though - on
66    // provider key mismatch there will be unnecessary extra checks.
67    private const DISALLOWED_LOCAL_PROVIDERS = [
68        // FIXME what about preauth providers like AbuseFilter which don't generate a form
69        //   but might prevent login and then the user ends up on a confusing login page?
70        // FIXME what about providers (if any) which generate a form but also do something else?
71        'preauth' => [
72            'CaptchaPreAuthenticationProvider',
73        ],
74        'primaryauth' => [
75            TemporaryPasswordPrimaryAuthenticationProvider::class,
76            LocalPasswordPrimaryAuthenticationProvider::class,
77        ],
78        'secondaryauth' => [
79            'OATHSecondaryAuthenticationProvider',
80        ],
81    ];
82
83    private UrlUtils $urlUtils;
84    private FilteredRequestTracker $filteredRequestTracker;
85    private SharedDomainUtils $sharedDomainUtils;
86    private ?MobileContext $mobileContext;
87
88    public function __construct(
89        UrlUtils $urlUtils,
90        FilteredRequestTracker $filteredRequestTracker,
91        SharedDomainUtils $sharedDomainUtils,
92        ?MobileContext $mobileContext = null
93    ) {
94        $this->urlUtils = $urlUtils;
95        $this->filteredRequestTracker = $filteredRequestTracker;
96        $this->sharedDomainUtils = $sharedDomainUtils;
97        $this->mobileContext = $mobileContext;
98    }
99
100    /** @inheritDoc */
101    public function onSetupAfterCache() {
102        if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) {
103            // FIXME The REST API does not provide a hook for disabling APIs. No rest APIs
104            //   should be needed for login and signup so we can just throw unconditionally,
105            //   but this should be improved in the future.
106            // FIXME should not log a production error
107            if ( !in_array( MW_ENTRY_POINT, self::ALLOWED_ENTRY_POINTS, true ) ) {
108                throw new \RuntimeException(
109                    MW_ENTRY_POINT . ' endpoint is not allowed on the shared domain'
110                );
111            }
112        }
113    }
114
115    /** @inheritDoc */
116    public function onGetUserPermissionsErrors( $title, $user, $action, &$result ) {
117        if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) {
118            if ( !$title->isSpecialPage() ) {
119                $result = wfMessage( 'badaccess-group0' );
120                return false;
121            }
122            foreach ( self::ALLOWED_SPECIAL_PAGES as $name ) {
123                if ( $title->isSpecial( $name ) ) {
124                    return true;
125                }
126            }
127            $result = wfMessage( 'badaccess-group0' );
128            return false;
129        }
130    }
131
132    /** @inheritDoc */
133    public function onApiCheckCanExecute( $module, $user, &$message ) {
134        if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) {
135            if ( !in_array( $module->getModuleName(), self::ALLOWED_API_MODULES ) ) {
136                $message = [ 'apierror-moduledisabled', $module->getModuleName() ];
137                return false;
138            }
139        }
140    }
141
142    /** @inheritDoc */
143    public function onBeforePageDisplay( $out, $skin ): void {
144        if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) {
145            $out->disallowUserJs();
146        }
147    }
148
149    /**
150     * @inheritDoc
151     * @phan-param array{local:string} $urls
152     */
153    public function onResourceLoaderModifyEmbeddedSourceUrls( array &$urls ): void {
154        $local = $urls['local'];
155        $local = $this->urlUtils->expand( $local, PROTO_CURRENT );
156        // reassure Phan that expand() won't return null
157        '@phan-var string $local';
158        if ( $this->mobileContext && $this->mobileContext->usingMobileDomain() ) {
159            $local = $this->mobileContext->getMobileUrl( $local );
160        }
161        $urls['local'] = $local;
162    }
163
164    /**
165     * If we are not on the shared domain and SUL3 is enabled, remove some authentication
166     * providers. They will run after the redirect on shared domain, so it's not necessary to
167     * run them locally, and on the local domain they would generate a login form, and we
168     * don't want that.
169     * @inheritDoc
170     * @note
171     */
172    public function onAuthManagerFilterProviders( array &$providers ): void {
173        $request = RequestContext::getMain()->getRequest();
174        if ( $this->sharedDomainUtils->isSul3Enabled( $request )
175             && !$this->sharedDomainUtils->isSharedDomain()
176        ) {
177            // We'll rely on CentralAuthSharedDomainPreAuthenticationProvider to make sure filtering does not
178            // happen at the wrong time so make sure it's in place.
179            if ( !isset( $providers['preauth']['CentralAuthSharedDomainPreAuthenticationProvider'] ) ) {
180                throw new LogicException(
181                    'CentralAuthSharedDomainPreAuthenticationProvider not found during SUL3 login'
182                );
183            }
184
185            foreach ( self::DISALLOWED_LOCAL_PROVIDERS as $stage => $disallowedProviders ) {
186                foreach ( $disallowedProviders as $disallowedProvider ) {
187                    unset( $providers[$stage][$disallowedProvider] );
188                }
189            }
190            // This is security-critical code. If these providers are removed but some
191            // non-redirect-based login is still possible, or the providers are erroneously
192            // removed on the shared domain as well, that would circumvent important security
193            // checks. To prevent mistakes, we sync with the behavior of the
194            // AuthManagerVerifyAuthentication hook.
195            $this->filteredRequestTracker->markRequestAsFiltered( $request );
196        }
197    }
198
199    /** @inheritDoc */
200    public function onAuthManagerVerifyAuthentication(
201        ?UserIdentity $user,
202        AuthenticationResponse &$response,
203        AuthManager $authManager,
204        array $info
205    ) {
206        if ( $this->filteredRequestTracker->isCurrentAuthenticationFlowFiltered( $authManager )
207            && $info['primaryId'] !== CentralAuthRedirectingPrimaryAuthenticationProvider::class
208        ) {
209            // If providers were filtered, but then authentication wasn't handled by redirecting,
210            // report and interrupt.
211            MWExceptionHandler::logException( new NormalizedException(
212                'Providers were filtered but redirecting provider was not the primary',
213                [
214                    'user' => $user->getName(),
215                    'result' => $response->status,
216                ] + $info
217            ) );
218            $response = AuthenticationResponse::newFail( wfMessage( 'internalerror' ) );
219            return false;
220        }
221    }
222
223    /** @inheritDoc */
224    public function onGetLocalURL( $title, &$url, $query ) {
225        if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) {
226            // Only allow links to auth-related special pages on the shared domain.
227            // Point all other links to the normal wiki domain.
228            foreach ( self::ALLOWED_SPECIAL_PAGES as $name ) {
229                if ( $title->isSpecial( $name ) ) {
230                    return;
231                }
232            }
233            if ( $title->getInterwiki() !== '' ) {
234                return;
235            }
236
237            // WikiMap entry for the current wiki points to the normal wiki domain,
238            // even when $wgServer etc. were overridden for the shared domain.
239            $currentWiki = WikiMap::getWiki( WikiMap::getCurrentWikiId() );
240            $url = wfAppendQuery( $currentWiki->getCanonicalUrl( $title->getPrefixedText() ), $query );
241
242            if ( $this->mobileContext && $this->mobileContext->shouldDisplayMobileView() ) {
243                $url = $this->mobileContext->getMobileUrl( $url );
244            }
245        }
246    }
247}