Issues (4122)

Security Analysis    not enabled

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Header Injection
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

includes/session/SessionManager.php (10 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * MediaWiki\Session entry point
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @ingroup Session
22
 */
23
24
namespace MediaWiki\Session;
25
26
use MWException;
27
use Psr\Log\LoggerInterface;
28
use BagOStuff;
29
use CachedBagOStuff;
30
use Config;
31
use FauxRequest;
32
use User;
33
use WebRequest;
34
35
/**
36
 * This serves as the entry point to the MediaWiki session handling system.
37
 *
38
 * Most methods here are for internal use by session handling code. Other callers
39
 * should only use getGlobalSession and the methods of SessionManagerInterface;
40
 * the rest of the functionality is exposed via MediaWiki\Session\Session methods.
41
 *
42
 * To provide custom session handling, implement a MediaWiki\Session\SessionProvider.
43
 *
44
 * @ingroup Session
45
 * @since 1.27
46
 * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
47
 */
48
final class SessionManager implements SessionManagerInterface {
49
	/** @var SessionManager|null */
50
	private static $instance = null;
51
52
	/** @var Session|null */
53
	private static $globalSession = null;
54
55
	/** @var WebRequest|null */
56
	private static $globalSessionRequest = null;
57
58
	/** @var LoggerInterface */
59
	private $logger;
60
61
	/** @var Config */
62
	private $config;
63
64
	/** @var CachedBagOStuff|null */
65
	private $store;
66
67
	/** @var SessionProvider[] */
68
	private $sessionProviders = null;
69
70
	/** @var string[] */
71
	private $varyCookies = null;
72
73
	/** @var array */
74
	private $varyHeaders = null;
75
76
	/** @var SessionBackend[] */
77
	private $allSessionBackends = [];
78
79
	/** @var SessionId[] */
80
	private $allSessionIds = [];
81
82
	/** @var string[] */
83
	private $preventUsers = [];
84
85
	/**
86
	 * Get the global SessionManager
87
	 * @return SessionManagerInterface
88
	 *  (really a SessionManager, but this is to make IDEs less confused)
89
	 */
90
	public static function singleton() {
91
		if ( self::$instance === null ) {
92
			self::$instance = new self();
93
		}
94
		return self::$instance;
95
	}
96
97
	/**
98
	 * Get the "global" session
99
	 *
100
	 * If PHP's session_id() has been set, returns that session. Otherwise
101
	 * returns the session for RequestContext::getMain()->getRequest().
102
	 *
103
	 * @return Session
104
	 */
105
	public static function getGlobalSession() {
106
		if ( !PHPSessionHandler::isEnabled() ) {
107
			$id = '';
108
		} else {
109
			$id = session_id();
110
		}
111
112
		$request = \RequestContext::getMain()->getRequest();
113
		if (
114
			!self::$globalSession // No global session is set up yet
115
			|| self::$globalSessionRequest !== $request // The global WebRequest changed
116
			|| $id !== '' && self::$globalSession->getId() !== $id // Someone messed with session_id()
117
		) {
118
			self::$globalSessionRequest = $request;
119
			if ( $id === '' ) {
120
				// session_id() wasn't used, so fetch the Session from the WebRequest.
121
				// We use $request->getSession() instead of $singleton->getSessionForRequest()
122
				// because doing the latter would require a public
123
				// "$request->getSessionId()" method that would confuse end
124
				// users by returning SessionId|null where they'd expect it to
125
				// be short for $request->getSession()->getId(), and would
126
				// wind up being a duplicate of the code in
127
				// $request->getSession() anyway.
128
				self::$globalSession = $request->getSession();
129
			} else {
130
				// Someone used session_id(), so we need to follow suit.
131
				// Note this overwrites whatever session might already be
132
				// associated with $request with the one for $id.
133
				self::$globalSession = self::singleton()->getSessionById( $id, true, $request )
134
					?: $request->getSession();
135
			}
136
		}
137
		return self::$globalSession;
138
	}
139
140
	/**
141
	 * @param array $options
142
	 *  - config: Config to fetch configuration from. Defaults to the default 'main' config.
143
	 *  - logger: LoggerInterface to use for logging. Defaults to the 'session' channel.
144
	 *  - store: BagOStuff to store session data in.
145
	 */
146
	public function __construct( $options = [] ) {
147
		if ( isset( $options['config'] ) ) {
148
			$this->config = $options['config'];
149
			if ( !$this->config instanceof Config ) {
150
				throw new \InvalidArgumentException(
151
					'$options[\'config\'] must be an instance of Config'
152
				);
153
			}
154
		} else {
155
			$this->config = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
156
		}
157
158
		if ( isset( $options['logger'] ) ) {
159
			if ( !$options['logger'] instanceof LoggerInterface ) {
160
				throw new \InvalidArgumentException(
161
					'$options[\'logger\'] must be an instance of LoggerInterface'
162
				);
163
			}
164
			$this->setLogger( $options['logger'] );
165
		} else {
166
			$this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
167
		}
168
169
		if ( isset( $options['store'] ) ) {
170
			if ( !$options['store'] instanceof BagOStuff ) {
171
				throw new \InvalidArgumentException(
172
					'$options[\'store\'] must be an instance of BagOStuff'
173
				);
174
			}
175
			$store = $options['store'];
176
		} else {
177
			$store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
178
		}
179
		$this->store = $store instanceof CachedBagOStuff ? $store : new CachedBagOStuff( $store );
180
181
		register_shutdown_function( [ $this, 'shutdown' ] );
182
	}
183
184
	public function setLogger( LoggerInterface $logger ) {
185
		$this->logger = $logger;
186
	}
187
188
	public function getSessionForRequest( WebRequest $request ) {
189
		$info = $this->getSessionInfoForRequest( $request );
190
191
		if ( !$info ) {
192
			$session = $this->getEmptySession( $request );
193
		} else {
194
			$session = $this->getSessionFromInfo( $info, $request );
195
		}
196
		return $session;
197
	}
198
199
	public function getSessionById( $id, $create = false, WebRequest $request = null ) {
200
		if ( !self::validateSessionId( $id ) ) {
201
			throw new \InvalidArgumentException( 'Invalid session ID' );
202
		}
203
		if ( !$request ) {
204
			$request = new FauxRequest;
205
		}
206
207
		$session = null;
208
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => $id, 'idIsSafe' => true ] );
209
210
		// If we already have the backend loaded, use it directly
211
		if ( isset( $this->allSessionBackends[$id] ) ) {
212
			return $this->getSessionFromInfo( $info, $request );
213
		}
214
215
		// Test if the session is in storage, and if so try to load it.
216
		$key = wfMemcKey( 'MWSession', $id );
217
		if ( is_array( $this->store->get( $key ) ) ) {
218
			$create = false; // If loading fails, don't bother creating because it probably will fail too.
219
			if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
220
				$session = $this->getSessionFromInfo( $info, $request );
221
			}
222
		}
223
224
		if ( $create && $session === null ) {
225
			$ex = null;
226
			try {
227
				$session = $this->getEmptySessionInternal( $request, $id );
228
			} catch ( \Exception $ex ) {
229
				$this->logger->error( 'Failed to create empty session: {exception}',
230
					[
231
						'method' => __METHOD__,
232
						'exception' => $ex,
233
				] );
234
				$session = null;
235
			}
236
		}
237
238
		return $session;
239
	}
240
241
	public function getEmptySession( WebRequest $request = null ) {
242
		return $this->getEmptySessionInternal( $request );
243
	}
244
245
	/**
246
	 * @see SessionManagerInterface::getEmptySession
247
	 * @param WebRequest|null $request
248
	 * @param string|null $id ID to force on the new session
249
	 * @return Session
250
	 */
251
	private function getEmptySessionInternal( WebRequest $request = null, $id = null ) {
252
		if ( $id !== null ) {
253
			if ( !self::validateSessionId( $id ) ) {
254
				throw new \InvalidArgumentException( 'Invalid session ID' );
255
			}
256
257
			$key = wfMemcKey( 'MWSession', $id );
258
			if ( is_array( $this->store->get( $key ) ) ) {
259
				throw new \InvalidArgumentException( 'Session ID already exists' );
260
			}
261
		}
262
		if ( !$request ) {
263
			$request = new FauxRequest;
264
		}
265
266
		$infos = [];
267
		foreach ( $this->getProviders() as $provider ) {
268
			$info = $provider->newSessionInfo( $id );
0 ignored issues
show
Are you sure the assignment to $info is correct as $provider->newSessionInfo($id) (which targets MediaWiki\Session\Sessio...vider::newSessionInfo()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
269
			if ( !$info ) {
270
				continue;
271
			}
272
			if ( $info->getProvider() !== $provider ) {
273
				throw new \UnexpectedValueException(
274
					"$provider returned an empty session info for a different provider: $info"
275
				);
276
			}
277
			if ( $id !== null && $info->getId() !== $id ) {
278
				throw new \UnexpectedValueException(
279
					"$provider returned empty session info with a wrong id: " .
280
						$info->getId() . ' != ' . $id
281
				);
282
			}
283
			if ( !$info->isIdSafe() ) {
284
				throw new \UnexpectedValueException(
285
					"$provider returned empty session info with id flagged unsafe"
286
				);
287
			}
288
			$compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1;
289
			if ( $compare > 0 ) {
290
				continue;
291
			}
292
			if ( $compare === 0 ) {
293
				$infos[] = $info;
294
			} else {
295
				$infos = [ $info ];
296
			}
297
		}
298
299
		// Make sure there's exactly one
300
		if ( count( $infos ) > 1 ) {
301
			throw new \UnexpectedValueException(
302
				'Multiple empty sessions tied for top priority: ' . implode( ', ', $infos )
303
			);
304
		} elseif ( count( $infos ) < 1 ) {
305
			throw new \UnexpectedValueException( 'No provider could provide an empty session!' );
306
		}
307
308
		return $this->getSessionFromInfo( $infos[0], $request );
309
	}
310
311
	public function invalidateSessionsForUser( User $user ) {
312
		$user->setToken();
313
		$user->saveSettings();
314
315
		$authUser = \MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$user ] );
316
		if ( $authUser ) {
317
			$authUser->resetAuthToken();
318
		}
319
320
		foreach ( $this->getProviders() as $provider ) {
321
			$provider->invalidateSessionsForUser( $user );
322
		}
323
	}
324
325
	public function getVaryHeaders() {
326
		// @codeCoverageIgnoreStart
327
		if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison !== seems to always evaluate to true as the types of MW_NO_SESSION (integer) and 'warn' (string) can never be identical. Maybe you want to use a loose comparison != instead?
Loading history...
328
			return [];
329
		}
330
		// @codeCoverageIgnoreEnd
331
		if ( $this->varyHeaders === null ) {
332
			$headers = [];
333
			foreach ( $this->getProviders() as $provider ) {
334
				foreach ( $provider->getVaryHeaders() as $header => $options ) {
335
					if ( !isset( $headers[$header] ) ) {
336
						$headers[$header] = [];
337
					}
338
					if ( is_array( $options ) ) {
339
						$headers[$header] = array_unique( array_merge( $headers[$header], $options ) );
340
					}
341
				}
342
			}
343
			$this->varyHeaders = $headers;
344
		}
345
		return $this->varyHeaders;
346
	}
347
348
	public function getVaryCookies() {
349
		// @codeCoverageIgnoreStart
350
		if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison !== seems to always evaluate to true as the types of MW_NO_SESSION (integer) and 'warn' (string) can never be identical. Maybe you want to use a loose comparison != instead?
Loading history...
351
			return [];
352
		}
353
		// @codeCoverageIgnoreEnd
354
		if ( $this->varyCookies === null ) {
355
			$cookies = [];
356
			foreach ( $this->getProviders() as $provider ) {
357
				$cookies = array_merge( $cookies, $provider->getVaryCookies() );
358
			}
359
			$this->varyCookies = array_values( array_unique( $cookies ) );
0 ignored issues
show
Documentation Bug introduced by
It seems like array_values(array_unique($cookies)) of type array<integer,?> is incompatible with the declared type array<integer,string> of property $varyCookies.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
360
		}
361
		return $this->varyCookies;
362
	}
363
364
	/**
365
	 * Validate a session ID
366
	 * @param string $id
367
	 * @return bool
368
	 */
369
	public static function validateSessionId( $id ) {
370
		return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
371
	}
372
373
	/**
374
	 * @name Internal methods
375
	 * @{
376
	 */
377
378
	/**
379
	 * Auto-create the given user, if necessary
380
	 * @private Don't call this yourself. Let Setup.php do it for you at the right time.
381
	 * @deprecated since 1.27, use MediaWiki\Auth\AuthManager::autoCreateUser instead
382
	 * @param User $user User to auto-create
383
	 * @return bool Success
384
	 * @codeCoverageIgnore
385
	 */
386
	public static function autoCreateUser( User $user ) {
387
		wfDeprecated( __METHOD__, '1.27' );
388
		return \MediaWiki\Auth\AuthManager::singleton()->autoCreateUser(
389
			$user,
390
			\MediaWiki\Auth\AuthManager::AUTOCREATE_SOURCE_SESSION,
391
			false
392
		)->isGood();
393
	}
394
395
	/**
396
	 * Prevent future sessions for the user
397
	 *
398
	 * The intention is that the named account will never again be usable for
399
	 * normal login (i.e. there is no way to undo the prevention of access).
400
	 *
401
	 * @private For use from \User::newSystemUser only
402
	 * @param string $username
403
	 */
404
	public function preventSessionsForUser( $username ) {
405
		$this->preventUsers[$username] = true;
406
407
		// Instruct the session providers to kill any other sessions too.
408
		foreach ( $this->getProviders() as $provider ) {
409
			$provider->preventSessionsForUser( $username );
410
		}
411
	}
412
413
	/**
414
	 * Test if a user is prevented
415
	 * @private For use from SessionBackend only
416
	 * @param string $username
417
	 * @return bool
418
	 */
419
	public function isUserSessionPrevented( $username ) {
420
		return !empty( $this->preventUsers[$username] );
421
	}
422
423
	/**
424
	 * Get the available SessionProviders
425
	 * @return SessionProvider[]
426
	 */
427
	protected function getProviders() {
428
		if ( $this->sessionProviders === null ) {
429
			$this->sessionProviders = [];
430
			foreach ( $this->config->get( 'SessionProviders' ) as $spec ) {
431
				$provider = \ObjectFactory::getObjectFromSpec( $spec );
432
				$provider->setLogger( $this->logger );
433
				$provider->setConfig( $this->config );
434
				$provider->setManager( $this );
435
				if ( isset( $this->sessionProviders[(string)$provider] ) ) {
436
					throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
437
				}
438
				$this->sessionProviders[(string)$provider] = $provider;
439
			}
440
		}
441
		return $this->sessionProviders;
442
	}
443
444
	/**
445
	 * Get a session provider by name
446
	 *
447
	 * Generally, this will only be used by internal implementation of some
448
	 * special session-providing mechanism. General purpose code, if it needs
449
	 * to access a SessionProvider at all, will use Session::getProvider().
450
	 *
451
	 * @param string $name
452
	 * @return SessionProvider|null
453
	 */
454
	public function getProvider( $name ) {
455
		$providers = $this->getProviders();
456
		return isset( $providers[$name] ) ? $providers[$name] : null;
457
	}
458
459
	/**
460
	 * Save all active sessions on shutdown
461
	 * @private For internal use with register_shutdown_function()
462
	 */
463
	public function shutdown() {
464
		if ( $this->allSessionBackends ) {
465
			$this->logger->debug( 'Saving all sessions on shutdown' );
466
			if ( session_id() !== '' ) {
467
				// @codeCoverageIgnoreStart
468
				session_write_close();
469
			}
470
			// @codeCoverageIgnoreEnd
471
			foreach ( $this->allSessionBackends as $backend ) {
472
				$backend->shutdown();
473
			}
474
		}
475
	}
476
477
	/**
478
	 * Fetch the SessionInfo(s) for a request
479
	 * @param WebRequest $request
480
	 * @return SessionInfo|null
481
	 */
482
	private function getSessionInfoForRequest( WebRequest $request ) {
483
		// Call all providers to fetch "the" session
484
		$infos = [];
485
		foreach ( $this->getProviders() as $provider ) {
486
			$info = $provider->provideSessionInfo( $request );
0 ignored issues
show
Are you sure the assignment to $info is correct as $provider->provideSessionInfo($request) (which targets MediaWiki\Session\Sessio...r::provideSessionInfo()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
487
			if ( !$info ) {
488
				continue;
489
			}
490
			if ( $info->getProvider() !== $provider ) {
491
				throw new \UnexpectedValueException(
492
					"$provider returned session info for a different provider: $info"
493
				);
494
			}
495
			$infos[] = $info;
496
		}
497
498
		// Sort the SessionInfos. Then find the first one that can be
499
		// successfully loaded, and then all the ones after it with the same
500
		// priority.
501
		usort( $infos, 'MediaWiki\\Session\\SessionInfo::compare' );
502
		$retInfos = [];
503
		while ( $infos ) {
504
			$info = array_pop( $infos );
505
			if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
506
				$retInfos[] = $info;
507
				while ( $infos ) {
508
					$info = array_pop( $infos );
509
					if ( SessionInfo::compare( $retInfos[0], $info ) ) {
510
						// We hit a lower priority, stop checking.
511
						break;
512
					}
513
					if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
514
						// This is going to error out below, but we want to
515
						// provide a complete list.
516
						$retInfos[] = $info;
517
					} else {
518
						// Session load failed, so unpersist it from this request
519
						$info->getProvider()->unpersistSession( $request );
520
					}
521
				}
522
			} else {
523
				// Session load failed, so unpersist it from this request
524
				$info->getProvider()->unpersistSession( $request );
525
			}
526
		}
527
528
		if ( count( $retInfos ) > 1 ) {
529
			$ex = new \OverflowException(
530
				'Multiple sessions for this request tied for top priority: ' . implode( ', ', $retInfos )
531
			);
532
			$ex->sessionInfos = $retInfos;
0 ignored issues
show
The property sessionInfos does not seem to exist in OverflowException.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
533
			throw $ex;
534
		}
535
536
		return $retInfos ? $retInfos[0] : null;
537
	}
538
539
	/**
540
	 * Load and verify the session info against the store
541
	 *
542
	 * @param SessionInfo &$info Will likely be replaced with an updated SessionInfo instance
543
	 * @param WebRequest $request
544
	 * @return bool Whether the session info matches the stored data (if any)
545
	 */
546
	private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
547
		$key = wfMemcKey( 'MWSession', $info->getId() );
548
		$blob = $this->store->get( $key );
549
550
		// If we got data from the store and the SessionInfo says to force use,
551
		// "fail" means to delete the data from the store and retry. Otherwise,
552
		// "fail" is just return false.
553
		if ( $info->forceUse() && $blob !== false ) {
554
			$failHandler = function () use ( $key, &$info, $request ) {
555
				$this->store->delete( $key );
556
				return $this->loadSessionInfoFromStore( $info, $request );
557
			};
558
		} else {
559
			$failHandler = function () {
560
				return false;
561
			};
562
		}
563
564
		$newParams = [];
565
566
		if ( $blob !== false ) {
567
			// Sanity check: blob must be an array, if it's saved at all
568
			if ( !is_array( $blob ) ) {
569
				$this->logger->warning( 'Session "{session}": Bad data', [
570
					'session' => $info,
571
				] );
572
				$this->store->delete( $key );
573
				return $failHandler();
574
			}
575
576
			// Sanity check: blob has data and metadata arrays
577
			if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
578
				!isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
579
			) {
580
				$this->logger->warning( 'Session "{session}": Bad data structure', [
581
					'session' => $info,
582
				] );
583
				$this->store->delete( $key );
584
				return $failHandler();
585
			}
586
587
			$data = $blob['data'];
588
			$metadata = $blob['metadata'];
589
590
			// Sanity check: metadata must be an array and must contain certain
591
			// keys, if it's saved at all
592
			if ( !array_key_exists( 'userId', $metadata ) ||
593
				!array_key_exists( 'userName', $metadata ) ||
594
				!array_key_exists( 'userToken', $metadata ) ||
595
				!array_key_exists( 'provider', $metadata )
596
			) {
597
				$this->logger->warning( 'Session "{session}": Bad metadata', [
598
					'session' => $info,
599
				] );
600
				$this->store->delete( $key );
601
				return $failHandler();
602
			}
603
604
			// First, load the provider from metadata, or validate it against the metadata.
605
			$provider = $info->getProvider();
606
			if ( $provider === null ) {
607
				$newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
608
				if ( !$provider ) {
609
					$this->logger->warning(
610
						'Session "{session}": Unknown provider ' . $metadata['provider'],
611
						[
612
							'session' => $info,
613
						]
614
					);
615
					$this->store->delete( $key );
616
					return $failHandler();
617
				}
618
			} elseif ( $metadata['provider'] !== (string)$provider ) {
619
				$this->logger->warning( 'Session "{session}": Wrong provider ' .
620
					$metadata['provider'] . ' !== ' . $provider,
621
					[
622
						'session' => $info,
623
				] );
624
				return $failHandler();
625
			}
626
627
			// Load provider metadata from metadata, or validate it against the metadata
628
			$providerMetadata = $info->getProviderMetadata();
629
			if ( isset( $metadata['providerMetadata'] ) ) {
630
				if ( $providerMetadata === null ) {
631
					$newParams['metadata'] = $metadata['providerMetadata'];
632
				} else {
633
					try {
634
						$newProviderMetadata = $provider->mergeMetadata(
635
							$metadata['providerMetadata'], $providerMetadata
636
						);
637
						if ( $newProviderMetadata !== $providerMetadata ) {
638
							$newParams['metadata'] = $newProviderMetadata;
639
						}
640
					} catch ( MetadataMergeException $ex ) {
641
						$this->logger->warning(
642
							'Session "{session}": Metadata merge failed: {exception}',
643
							[
644
								'session' => $info,
645
								'exception' => $ex,
646
							] + $ex->getContext()
647
						);
648
						return $failHandler();
649
					}
650
				}
651
			}
652
653
			// Next, load the user from metadata, or validate it against the metadata.
654
			$userInfo = $info->getUserInfo();
655
			if ( !$userInfo ) {
656
				// For loading, id is preferred to name.
657
				try {
658
					if ( $metadata['userId'] ) {
659
						$userInfo = UserInfo::newFromId( $metadata['userId'] );
660
					} elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
661
						$userInfo = UserInfo::newFromName( $metadata['userName'] );
662
					} else {
663
						$userInfo = UserInfo::newAnonymous();
664
					}
665
				} catch ( \InvalidArgumentException $ex ) {
666
					$this->logger->error( 'Session "{session}": {exception}', [
667
						'session' => $info,
668
						'exception' => $ex,
669
					] );
670
					return $failHandler();
671
				}
672
				$newParams['userInfo'] = $userInfo;
673
			} else {
674
				// User validation passes if user ID matches, or if there
675
				// is no saved ID and the names match.
676
				if ( $metadata['userId'] ) {
677
					if ( $metadata['userId'] !== $userInfo->getId() ) {
678
						$this->logger->warning(
679
							'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
680
							[
681
								'session' => $info,
682
								'uid_a' => $metadata['userId'],
683
								'uid_b' => $userInfo->getId(),
684
						] );
685
						return $failHandler();
686
					}
687
688
					// If the user was renamed, probably best to fail here.
689 View Code Duplication
					if ( $metadata['userName'] !== null &&
690
						$userInfo->getName() !== $metadata['userName']
691
					) {
692
						$this->logger->warning(
693
							'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
694
							[
695
								'session' => $info,
696
								'uname_a' => $metadata['userName'],
697
								'uname_b' => $userInfo->getName(),
698
						] );
699
						return $failHandler();
700
					}
701
702 View Code Duplication
				} elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
703
					if ( $metadata['userName'] !== $userInfo->getName() ) {
704
						$this->logger->warning(
705
							'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
706
							[
707
								'session' => $info,
708
								'uname_a' => $metadata['userName'],
709
								'uname_b' => $userInfo->getName(),
710
						] );
711
						return $failHandler();
712
					}
713
				} elseif ( !$userInfo->isAnon() ) {
714
					// Metadata specifies an anonymous user, but the passed-in
715
					// user isn't anonymous.
716
					$this->logger->warning(
717
						'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
718
						[
719
							'session' => $info,
720
					] );
721
					return $failHandler();
722
				}
723
			}
724
725
			// And if we have a token in the metadata, it must match the loaded/provided user.
726
			if ( $metadata['userToken'] !== null &&
727
				$userInfo->getToken() !== $metadata['userToken']
728
			) {
729
				$this->logger->warning( 'Session "{session}": User token mismatch', [
730
					'session' => $info,
731
				] );
732
				return $failHandler();
733
			}
734
			if ( !$userInfo->isVerified() ) {
735
				$newParams['userInfo'] = $userInfo->verified();
736
			}
737
738
			if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
739
				$newParams['remembered'] = true;
740
			}
741
			if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
742
				$newParams['forceHTTPS'] = true;
743
			}
744
			if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
745
				$newParams['persisted'] = true;
746
			}
747
748
			if ( !$info->isIdSafe() ) {
749
				$newParams['idIsSafe'] = true;
750
			}
751
		} else {
752
			// No metadata, so we can't load the provider if one wasn't given.
753
			if ( $info->getProvider() === null ) {
754
				$this->logger->warning(
755
					'Session "{session}": Null provider and no metadata',
756
					[
757
						'session' => $info,
758
				] );
759
				return $failHandler();
760
			}
761
762
			// If no user was provided and no metadata, it must be anon.
763
			if ( !$info->getUserInfo() ) {
764
				if ( $info->getProvider()->canChangeUser() ) {
765
					$newParams['userInfo'] = UserInfo::newAnonymous();
766
				} else {
767
					$this->logger->info(
768
						'Session "{session}": No user provided and provider cannot set user',
769
						[
770
							'session' => $info,
771
					] );
772
					return $failHandler();
773
				}
774
			} elseif ( !$info->getUserInfo()->isVerified() ) {
775
				$this->logger->warning(
776
					'Session "{session}": Unverified user provided and no metadata to auth it',
777
					[
778
						'session' => $info,
779
				] );
780
				return $failHandler();
781
			}
782
783
			$data = false;
784
			$metadata = false;
785
786
			if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
787
				// The ID doesn't come from the user, so it should be safe
788
				// (and if not, nothing we can do about it anyway)
789
				$newParams['idIsSafe'] = true;
790
			}
791
		}
792
793
		// Construct the replacement SessionInfo, if necessary
794
		if ( $newParams ) {
795
			$newParams['copyFrom'] = $info;
796
			$info = new SessionInfo( $info->getPriority(), $newParams );
797
		}
798
799
		// Allow the provider to check the loaded SessionInfo
800
		$providerMetadata = $info->getProviderMetadata();
801
		if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
802
			return $failHandler();
803
		}
804
		if ( $providerMetadata !== $info->getProviderMetadata() ) {
805
			$info = new SessionInfo( $info->getPriority(), [
806
				'metadata' => $providerMetadata,
807
				'copyFrom' => $info,
808
			] );
809
		}
810
811
		// Give hooks a chance to abort. Combined with the SessionMetadata
812
		// hook, this can allow for tying a session to an IP address or the
813
		// like.
814
		$reason = 'Hook aborted';
815
		if ( !\Hooks::run(
816
			'SessionCheckInfo',
817
			[ &$reason, $info, $request, $metadata, $data ]
818
		) ) {
819
			$this->logger->warning( 'Session "{session}": ' . $reason, [
820
				'session' => $info,
821
			] );
822
			return $failHandler();
823
		}
824
825
		return true;
826
	}
827
828
	/**
829
	 * Create a Session corresponding to the passed SessionInfo
830
	 * @private For use by a SessionProvider that needs to specially create its
831
	 *  own Session. Most session providers won't need this.
832
	 * @param SessionInfo $info
833
	 * @param WebRequest $request
834
	 * @return Session
835
	 */
836
	public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
837
		// @codeCoverageIgnoreStart
838
		if ( defined( 'MW_NO_SESSION' ) ) {
839
			if ( MW_NO_SESSION === 'warn' ) {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison === seems to always evaluate to false as the types of MW_NO_SESSION (integer) and 'warn' (string) can never be identical. Maybe you want to use a loose comparison == instead?
Loading history...
840
				// Undocumented safety case for converting existing entry points
841
				$this->logger->error( 'Sessions are supposed to be disabled for this entry point', [
842
					'exception' => new \BadMethodCallException( 'Sessions are disabled for this entry point' ),
843
				] );
844
			} else {
845
				throw new \BadMethodCallException( 'Sessions are disabled for this entry point' );
846
			}
847
		}
848
		// @codeCoverageIgnoreEnd
849
850
		$id = $info->getId();
851
852
		if ( !isset( $this->allSessionBackends[$id] ) ) {
853
			if ( !isset( $this->allSessionIds[$id] ) ) {
854
				$this->allSessionIds[$id] = new SessionId( $id );
855
			}
856
			$backend = new SessionBackend(
857
				$this->allSessionIds[$id],
858
				$info,
859
				$this->store,
0 ignored issues
show
It seems like $this->store can be null; however, __construct() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
860
				$this->logger,
861
				$this->config->get( 'ObjectCacheSessionExpiry' )
862
			);
863
			$this->allSessionBackends[$id] = $backend;
864
			$delay = $backend->delaySave();
865
		} else {
866
			$backend = $this->allSessionBackends[$id];
867
			$delay = $backend->delaySave();
868
			if ( $info->wasPersisted() ) {
869
				$backend->persist();
870
			}
871
			if ( $info->wasRemembered() ) {
872
				$backend->setRememberUser( true );
873
			}
874
		}
875
876
		$request->setSessionId( $backend->getSessionId() );
877
		$session = $backend->getSession( $request );
878
879
		if ( !$info->isIdSafe() ) {
880
			$session->resetId();
881
		}
882
883
		\Wikimedia\ScopedCallback::consume( $delay );
884
		return $session;
885
	}
886
887
	/**
888
	 * Deregister a SessionBackend
889
	 * @private For use from \MediaWiki\Session\SessionBackend only
890
	 * @param SessionBackend $backend
891
	 */
892
	public function deregisterSessionBackend( SessionBackend $backend ) {
893
		$id = $backend->getId();
894 View Code Duplication
		if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
895
			$this->allSessionBackends[$id] !== $backend ||
896
			$this->allSessionIds[$id] !== $backend->getSessionId()
897
		) {
898
			throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
899
		}
900
901
		unset( $this->allSessionBackends[$id] );
902
		// Explicitly do not unset $this->allSessionIds[$id]
903
	}
904
905
	/**
906
	 * Change a SessionBackend's ID
907
	 * @private For use from \MediaWiki\Session\SessionBackend only
908
	 * @param SessionBackend $backend
909
	 */
910
	public function changeBackendId( SessionBackend $backend ) {
911
		$sessionId = $backend->getSessionId();
912
		$oldId = (string)$sessionId;
913 View Code Duplication
		if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
914
			$this->allSessionBackends[$oldId] !== $backend ||
915
			$this->allSessionIds[$oldId] !== $sessionId
916
		) {
917
			throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
918
		}
919
920
		$newId = $this->generateSessionId();
921
922
		unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
923
		$sessionId->setId( $newId );
0 ignored issues
show
It seems like $newId defined by $this->generateSessionId() on line 920 can also be of type false; however, MediaWiki\Session\SessionId::setId() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
924
		$this->allSessionBackends[$newId] = $backend;
925
		$this->allSessionIds[$newId] = $sessionId;
926
	}
927
928
	/**
929
	 * Generate a new random session ID
930
	 * @return string
931
	 */
932
	public function generateSessionId() {
933
		do {
934
			$id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
935
			$key = wfMemcKey( 'MWSession', $id );
936
		} while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
937
		return $id;
938
	}
939
940
	/**
941
	 * Call setters on a PHPSessionHandler
942
	 * @private Use PhpSessionHandler::install()
943
	 * @param PHPSessionHandler $handler
944
	 */
945
	public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
946
		$handler->setManager( $this, $this->store, $this->logger );
0 ignored issues
show
It seems like $this->store can be null; however, setManager() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
947
	}
948
949
	/**
950
	 * Reset the internal caching for unit testing
951
	 * @protected Unit tests only
952
	 */
953
	public static function resetCache() {
954
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
955
			// @codeCoverageIgnoreStart
956
			throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
957
			// @codeCoverageIgnoreEnd
958
		}
959
960
		self::$globalSession = null;
961
		self::$globalSessionRequest = null;
962
	}
963
964
	/**@}*/
965
966
}
967