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/MediaWiki.php (1 issue)

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
 * Helper class for the index.php 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
 */
22
23
use MediaWiki\Logger\LoggerFactory;
24
use MediaWiki\MediaWikiServices;
25
26
/**
27
 * The MediaWiki class is the helper class for the index.php entry point.
28
 */
29
class MediaWiki {
30
	/**
31
	 * @var IContextSource
32
	 */
33
	private $context;
34
35
	/**
36
	 * @var Config
37
	 */
38
	private $config;
39
40
	/**
41
	 * @var String Cache what action this request is
42
	 */
43
	private $action;
44
45
	/**
46
	 * @param IContextSource|null $context
47
	 */
48
	public function __construct( IContextSource $context = null ) {
49
		if ( !$context ) {
50
			$context = RequestContext::getMain();
51
		}
52
53
		$this->context = $context;
54
		$this->config = $context->getConfig();
55
	}
56
57
	/**
58
	 * Parse the request to get the Title object
59
	 *
60
	 * @throws MalformedTitleException If a title has been provided by the user, but is invalid.
61
	 * @return Title Title object to be $wgTitle
62
	 */
63
	private function parseTitle() {
64
		global $wgContLang;
65
66
		$request = $this->context->getRequest();
67
		$curid = $request->getInt( 'curid' );
68
		$title = $request->getVal( 'title' );
69
		$action = $request->getVal( 'action' );
70
71
		if ( $request->getCheck( 'search' ) ) {
72
			// Compatibility with old search URLs which didn't use Special:Search
73
			// Just check for presence here, so blank requests still
74
			// show the search page when using ugly URLs (bug 8054).
75
			$ret = SpecialPage::getTitleFor( 'Search' );
76
		} elseif ( $curid ) {
77
			// URLs like this are generated by RC, because rc_title isn't always accurate
78
			$ret = Title::newFromID( $curid );
79
		} else {
80
			$ret = Title::newFromURL( $title );
81
			// Alias NS_MEDIA page URLs to NS_FILE...we only use NS_MEDIA
82
			// in wikitext links to tell Parser to make a direct file link
83
			if ( !is_null( $ret ) && $ret->getNamespace() == NS_MEDIA ) {
84
				$ret = Title::makeTitle( NS_FILE, $ret->getDBkey() );
85
			}
86
			// Check variant links so that interwiki links don't have to worry
87
			// about the possible different language variants
88
			if ( count( $wgContLang->getVariants() ) > 1
89
				&& !is_null( $ret ) && $ret->getArticleID() == 0
90
			) {
91
				$wgContLang->findVariantLink( $title, $ret );
92
			}
93
		}
94
95
		// If title is not provided, always allow oldid and diff to set the title.
96
		// If title is provided, allow oldid and diff to override the title, unless
97
		// we are talking about a special page which might use these parameters for
98
		// other purposes.
99
		if ( $ret === null || !$ret->isSpecialPage() ) {
100
			// We can have urls with just ?diff=,?oldid= or even just ?diff=
101
			$oldid = $request->getInt( 'oldid' );
102
			$oldid = $oldid ? $oldid : $request->getInt( 'diff' );
103
			// Allow oldid to override a changed or missing title
104
			if ( $oldid ) {
105
				$rev = Revision::newFromId( $oldid );
106
				$ret = $rev ? $rev->getTitle() : $ret;
107
			}
108
		}
109
110
		// Use the main page as default title if nothing else has been provided
111
		if ( $ret === null
112
			&& strval( $title ) === ''
113
			&& !$request->getCheck( 'curid' )
114
			&& $action !== 'delete'
115
		) {
116
			$ret = Title::newMainPage();
117
		}
118
119
		if ( $ret === null || ( $ret->getDBkey() == '' && !$ret->isExternal() ) ) {
120
			// If we get here, we definitely don't have a valid title; throw an exception.
121
			// Try to get detailed invalid title exception first, fall back to MalformedTitleException.
122
			Title::newFromTextThrow( $title );
123
			throw new MalformedTitleException( 'badtitletext', $title );
124
		}
125
126
		return $ret;
127
	}
128
129
	/**
130
	 * Get the Title object that we'll be acting on, as specified in the WebRequest
131
	 * @return Title
132
	 */
133
	public function getTitle() {
134
		if ( !$this->context->hasTitle() ) {
135
			try {
136
				$this->context->setTitle( $this->parseTitle() );
137
			} catch ( MalformedTitleException $ex ) {
138
				$this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) );
139
			}
140
		}
141
		return $this->context->getTitle();
142
	}
143
144
	/**
145
	 * Returns the name of the action that will be executed.
146
	 *
147
	 * @return string Action
148
	 */
149
	public function getAction() {
150
		if ( $this->action === null ) {
151
			$this->action = Action::getActionName( $this->context );
152
		}
153
154
		return $this->action;
155
	}
156
157
	/**
158
	 * Performs the request.
159
	 * - bad titles
160
	 * - read restriction
161
	 * - local interwiki redirects
162
	 * - redirect loop
163
	 * - special pages
164
	 * - normal pages
165
	 *
166
	 * @throws MWException|PermissionsError|BadTitleError|HttpError
167
	 * @return void
168
	 */
169
	private function performRequest() {
170
		global $wgTitle;
171
172
		$request = $this->context->getRequest();
173
		$requestTitle = $title = $this->context->getTitle();
174
		$output = $this->context->getOutput();
175
		$user = $this->context->getUser();
176
177
		if ( $request->getVal( 'printable' ) === 'yes' ) {
178
			$output->setPrintable();
179
		}
180
181
		$unused = null; // To pass it by reference
182
		Hooks::run( 'BeforeInitialize', [ &$title, &$unused, &$output, &$user, $request, $this ] );
183
184
		// Invalid titles. Bug 21776: The interwikis must redirect even if the page name is empty.
185
		if ( is_null( $title ) || ( $title->getDBkey() == '' && !$title->isExternal() )
186
			|| $title->isSpecial( 'Badtitle' )
187
		) {
188
			$this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) );
189
			try {
190
				$this->parseTitle();
191
			} catch ( MalformedTitleException $ex ) {
192
				throw new BadTitleError( $ex );
193
			}
194
			throw new BadTitleError();
195
		}
196
197
		// Check user's permissions to read this page.
198
		// We have to check here to catch special pages etc.
199
		// We will check again in Article::view().
200
		$permErrors = $title->isSpecial( 'RunJobs' )
201
			? [] // relies on HMAC key signature alone
202
			: $title->getUserPermissionsErrors( 'read', $user );
203
		if ( count( $permErrors ) ) {
204
			// Bug 32276: allowing the skin to generate output with $wgTitle or
205
			// $this->context->title set to the input title would allow anonymous users to
206
			// determine whether a page exists, potentially leaking private data. In fact, the
207
			// curid and oldid request  parameters would allow page titles to be enumerated even
208
			// when they are not guessable. So we reset the title to Special:Badtitle before the
209
			// permissions error is displayed.
210
211
			// The skin mostly uses $this->context->getTitle() these days, but some extensions
212
			// still use $wgTitle.
213
			$badTitle = SpecialPage::getTitleFor( 'Badtitle' );
214
			$this->context->setTitle( $badTitle );
215
			$wgTitle = $badTitle;
216
217
			throw new PermissionsError( 'read', $permErrors );
218
		}
219
220
		// Interwiki redirects
221
		if ( $title->isExternal() ) {
222
			$rdfrom = $request->getVal( 'rdfrom' );
223
			if ( $rdfrom ) {
224
				$url = $title->getFullURL( [ 'rdfrom' => $rdfrom ] );
225
			} else {
226
				$query = $request->getValues();
227
				unset( $query['title'] );
228
				$url = $title->getFullURL( $query );
229
			}
230
			// Check for a redirect loop
231
			if ( !preg_match( '/^' . preg_quote( $this->config->get( 'Server' ), '/' ) . '/', $url )
232
				&& $title->isLocal()
233
			) {
234
				// 301 so google et al report the target as the actual url.
235
				$output->redirect( $url, 301 );
236
			} else {
237
				$this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) );
238
				try {
239
					$this->parseTitle();
240
				} catch ( MalformedTitleException $ex ) {
241
					throw new BadTitleError( $ex );
242
				}
243
				throw new BadTitleError();
244
			}
245
		// Handle any other redirects.
246
		// Redirect loops, titleless URL, $wgUsePathInfo URLs, and URLs with a variant
247
		} elseif ( !$this->tryNormaliseRedirect( $title ) ) {
248
			// Prevent information leak via Special:MyPage et al (T109724)
249
			if ( $title->isSpecialPage() ) {
250
				$specialPage = SpecialPageFactory::getPage( $title->getDBkey() );
251
				if ( $specialPage instanceof RedirectSpecialPage ) {
252
					$specialPage->setContext( $this->context );
253
					if ( $this->config->get( 'HideIdentifiableRedirects' )
254
						&& $specialPage->personallyIdentifiableTarget()
255
					) {
256
						list( , $subpage ) = SpecialPageFactory::resolveAlias( $title->getDBkey() );
257
						$target = $specialPage->getRedirect( $subpage );
258
						// target can also be true. We let that case fall through to normal processing.
259
						if ( $target instanceof Title ) {
260
							$query = $specialPage->getRedirectQuery() ?: [];
261
							$request = new DerivativeRequest( $this->context->getRequest(), $query );
262
							$request->setRequestURL( $this->context->getRequest()->getRequestURL() );
263
							$this->context->setRequest( $request );
264
							// Do not varnish cache these. May vary even for anons
265
							$this->context->getOutput()->lowerCdnMaxage( 0 );
266
							$this->context->setTitle( $target );
267
							$wgTitle = $target;
268
							// Reset action type cache. (Special pages have only view)
269
							$this->action = null;
270
							$title = $target;
271
							$output->addJsConfigVars( [
272
								'wgInternalRedirectTargetUrl' => $target->getFullURL( $query ),
273
							] );
274
							$output->addModules( 'mediawiki.action.view.redirect' );
275
						}
276
					}
277
				}
278
			}
279
280
			// Special pages ($title may have changed since if statement above)
281
			if ( NS_SPECIAL == $title->getNamespace() ) {
282
				// Actions that need to be made when we have a special pages
283
				SpecialPageFactory::executePath( $title, $this->context );
284
			} else {
285
				// ...otherwise treat it as an article view. The article
286
				// may still be a wikipage redirect to another article or URL.
287
				$article = $this->initializeArticle();
288
				if ( is_object( $article ) ) {
289
					$this->performAction( $article, $requestTitle );
290
				} elseif ( is_string( $article ) ) {
291
					$output->redirect( $article );
292
				} else {
293
					throw new MWException( "Shouldn't happen: MediaWiki::initializeArticle()"
294
						. " returned neither an object nor a URL" );
295
				}
296
			}
297
		}
298
	}
299
300
	/**
301
	 * Handle redirects for uncanonical title requests.
302
	 *
303
	 * Handles:
304
	 * - Redirect loops.
305
	 * - No title in URL.
306
	 * - $wgUsePathInfo URLs.
307
	 * - URLs with a variant.
308
	 * - Other non-standard URLs (as long as they have no extra query parameters).
309
	 *
310
	 * Behaviour:
311
	 * - Normalise title values:
312
	 *   /wiki/Foo%20Bar -> /wiki/Foo_Bar
313
	 * - Normalise empty title:
314
	 *   /wiki/ -> /wiki/Main
315
	 *   /w/index.php?title= -> /wiki/Main
316
	 * - Don't redirect anything with query parameters other than 'title' or 'action=view'.
317
	 *
318
	 * @param Title $title
319
	 * @return bool True if a redirect was set.
320
	 * @throws HttpError
321
	 */
322
	private function tryNormaliseRedirect( Title $title ) {
323
		$request = $this->context->getRequest();
324
		$output = $this->context->getOutput();
325
326
		if ( $request->getVal( 'action', 'view' ) != 'view'
327
			|| $request->wasPosted()
328
			|| ( $request->getVal( 'title' ) !== null
329
				&& $title->getPrefixedDBkey() == $request->getVal( 'title' ) )
330
			|| count( $request->getValueNames( [ 'action', 'title' ] ) )
331
			|| !Hooks::run( 'TestCanonicalRedirect', [ $request, $title, $output ] )
332
		) {
333
			return false;
334
		}
335
336
		if ( $title->isSpecialPage() ) {
337
			list( $name, $subpage ) = SpecialPageFactory::resolveAlias( $title->getDBkey() );
338
			if ( $name ) {
339
				$title = SpecialPage::getTitleFor( $name, $subpage );
340
			}
341
		}
342
		// Redirect to canonical url, make it a 301 to allow caching
343
		$targetUrl = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
344
		if ( $targetUrl == $request->getFullRequestURL() ) {
345
			$message = "Redirect loop detected!\n\n" .
346
				"This means the wiki got confused about what page was " .
347
				"requested; this sometimes happens when moving a wiki " .
348
				"to a new server or changing the server configuration.\n\n";
349
350
			if ( $this->config->get( 'UsePathInfo' ) ) {
351
				$message .= "The wiki is trying to interpret the page " .
352
					"title from the URL path portion (PATH_INFO), which " .
353
					"sometimes fails depending on the web server. Try " .
354
					"setting \"\$wgUsePathInfo = false;\" in your " .
355
					"LocalSettings.php, or check that \$wgArticlePath " .
356
					"is correct.";
357
			} else {
358
				$message .= "Your web server was detected as possibly not " .
359
					"supporting URL path components (PATH_INFO) correctly; " .
360
					"check your LocalSettings.php for a customized " .
361
					"\$wgArticlePath setting and/or toggle \$wgUsePathInfo " .
362
					"to true.";
363
			}
364
			throw new HttpError( 500, $message );
365
		}
366
		$output->setSquidMaxage( 1200 );
367
		$output->redirect( $targetUrl, '301' );
368
		return true;
369
	}
370
371
	/**
372
	 * Initialize the main Article object for "standard" actions (view, etc)
373
	 * Create an Article object for the page, following redirects if needed.
374
	 *
375
	 * @return Article|string An Article, or a string to redirect to another URL
376
	 */
377
	private function initializeArticle() {
378
		$title = $this->context->getTitle();
379
		if ( $this->context->canUseWikiPage() ) {
380
			// Try to use request context wiki page, as there
381
			// is already data from db saved in per process
382
			// cache there from this->getAction() call.
383
			$page = $this->context->getWikiPage();
384
		} else {
385
			// This case should not happen, but just in case.
386
			// @TODO: remove this or use an exception
387
			$page = WikiPage::factory( $title );
388
			$this->context->setWikiPage( $page );
389
			wfWarn( "RequestContext::canUseWikiPage() returned false" );
390
		}
391
392
		// Make GUI wrapper for the WikiPage
393
		$article = Article::newFromWikiPage( $page, $this->context );
394
395
		// Skip some unnecessary code if the content model doesn't support redirects
396
		if ( !ContentHandler::getForTitle( $title )->supportsRedirects() ) {
397
			return $article;
398
		}
399
400
		$request = $this->context->getRequest();
401
402
		// Namespace might change when using redirects
403
		// Check for redirects ...
404
		$action = $request->getVal( 'action', 'view' );
405
		$file = ( $page instanceof WikiFilePage ) ? $page->getFile() : null;
406
		if ( ( $action == 'view' || $action == 'render' ) // ... for actions that show content
407
			&& !$request->getVal( 'oldid' ) // ... and are not old revisions
408
			&& !$request->getVal( 'diff' ) // ... and not when showing diff
409
			&& $request->getVal( 'redirect' ) != 'no' // ... unless explicitly told not to
410
			// ... and the article is not a non-redirect image page with associated file
411
			&& !( is_object( $file ) && $file->exists() && !$file->getRedirected() )
412
		) {
413
			// Give extensions a change to ignore/handle redirects as needed
414
			$ignoreRedirect = $target = false;
415
416
			Hooks::run( 'InitializeArticleMaybeRedirect',
417
				[ &$title, &$request, &$ignoreRedirect, &$target, &$article ] );
418
			$page = $article->getPage(); // reflect any hook changes
419
420
			// Follow redirects only for... redirects.
421
			// If $target is set, then a hook wanted to redirect.
422
			if ( !$ignoreRedirect && ( $target || $page->isRedirect() ) ) {
423
				// Is the target already set by an extension?
424
				$target = $target ? $target : $page->followRedirect();
425
				if ( is_string( $target ) ) {
426
					if ( !$this->config->get( 'DisableHardRedirects' ) ) {
427
						// we'll need to redirect
428
						return $target;
429
					}
430
				}
431
				if ( is_object( $target ) ) {
432
					// Rewrite environment to redirected article
433
					$rpage = WikiPage::factory( $target );
434
					$rpage->loadPageData();
435
					if ( $rpage->exists() || ( is_object( $file ) && !$file->isLocal() ) ) {
436
						$rarticle = Article::newFromWikiPage( $rpage, $this->context );
437
						$rarticle->setRedirectedFrom( $title );
438
439
						$article = $rarticle;
440
						$this->context->setTitle( $target );
441
						$this->context->setWikiPage( $article->getPage() );
442
					}
443
				}
444
			} else {
445
				// Article may have been changed by hook
446
				$this->context->setTitle( $article->getTitle() );
447
				$this->context->setWikiPage( $article->getPage() );
448
			}
449
		}
450
451
		return $article;
452
	}
453
454
	/**
455
	 * Perform one of the "standard" actions
456
	 *
457
	 * @param Page $page
458
	 * @param Title $requestTitle The original title, before any redirects were applied
459
	 */
460
	private function performAction( Page $page, Title $requestTitle ) {
461
		$request = $this->context->getRequest();
462
		$output = $this->context->getOutput();
463
		$title = $this->context->getTitle();
464
		$user = $this->context->getUser();
465
466
		if ( !Hooks::run( 'MediaWikiPerformAction',
467
				[ $output, $page, $title, $user, $request, $this ] )
468
		) {
469
			return;
470
		}
471
472
		$act = $this->getAction();
473
		$action = Action::factory( $act, $page, $this->context );
474
475
		if ( $action instanceof Action ) {
476
			// Narrow DB query expectations for this HTTP request
477
			$trxLimits = $this->config->get( 'TrxProfilerLimits' );
478
			$trxProfiler = Profiler::instance()->getTransactionProfiler();
479
			if ( $request->wasPosted() && !$action->doesWrites() ) {
480
				$trxProfiler->setExpectations( $trxLimits['POST-nonwrite'], __METHOD__ );
481
				$request->markAsSafeRequest();
482
			}
483
484
			# Let CDN cache things if we can purge them.
485
			if ( $this->config->get( 'UseSquid' ) &&
486
				in_array(
487
					// Use PROTO_INTERNAL because that's what getCdnUrls() uses
488
					wfExpandUrl( $request->getRequestURL(), PROTO_INTERNAL ),
489
					$requestTitle->getCdnUrls()
490
				)
491
			) {
492
				$output->setCdnMaxage( $this->config->get( 'SquidMaxage' ) );
493
			}
494
495
			$action->show();
496
			return;
497
		}
498
499
		if ( Hooks::run( 'UnknownAction', [ $request->getVal( 'action', 'view' ), $page ] ) ) {
500
			$output->setStatusCode( 404 );
501
			$output->showErrorPage( 'nosuchaction', 'nosuchactiontext' );
502
		}
503
	}
504
505
	/**
506
	 * Run the current MediaWiki instance; index.php just calls this
507
	 */
508
	public function run() {
509
		try {
510
			$this->setDBProfilingAgent();
511
			try {
512
				$this->main();
513
			} catch ( ErrorPageError $e ) {
514
				// Bug 62091: while exceptions are convenient to bubble up GUI errors,
515
				// they are not internal application faults. As with normal requests, this
516
				// should commit, print the output, do deferred updates, jobs, and profiling.
517
				$this->doPreOutputCommit();
518
				$e->report(); // display the GUI error
519
			}
520
		} catch ( Exception $e ) {
521
			$context = $this->context;
522
			$action = $context->getRequest()->getVal( 'action', 'view' );
523
			if (
524
				$e instanceof DBConnectionError &&
525
				$context->hasTitle() &&
526
				$context->getTitle()->canExist() &&
527
				in_array( $action, [ 'view', 'history' ], true ) &&
528
				HTMLFileCache::useFileCache( $this->context, HTMLFileCache::MODE_OUTAGE )
529
			) {
530
				// Try to use any (even stale) file during outages...
531
				$cache = new HTMLFileCache( $context->getTitle(), 'view' );
532
				if ( $cache->isCached() ) {
533
					$cache->loadFromFileCache( $context, HTMLFileCache::MODE_OUTAGE );
534
					print MWExceptionRenderer::getHTML( $e );
535
					exit;
536
				}
537
538
			}
539
540
			MWExceptionHandler::handleException( $e );
541
		}
542
543
		$this->doPostOutputShutdown( 'normal' );
544
	}
545
546
	private function setDBProfilingAgent() {
547
		$services = MediaWikiServices::getInstance();
548
		// Add a comment for easy SHOW PROCESSLIST interpretation
549
		$name = $this->context->getUser()->getName();
550
		$services->getDBLoadBalancerFactory()->setAgentName(
551
			mb_strlen( $name ) > 15 ? mb_substr( $name, 0, 15 ) . '...' : $name
552
		);
553
	}
554
555
	/**
556
	 * @see MediaWiki::preOutputCommit()
557
	 * @param callable $postCommitWork [default: null]
558
	 * @since 1.26
559
	 */
560
	public function doPreOutputCommit( callable $postCommitWork = null ) {
561
		self::preOutputCommit( $this->context, $postCommitWork );
562
	}
563
564
	/**
565
	 * This function commits all DB changes as needed before
566
	 * the user can receive a response (in case commit fails)
567
	 *
568
	 * @param IContextSource $context
569
	 * @param callable $postCommitWork [default: null]
570
	 * @since 1.27
571
	 */
572
	public static function preOutputCommit(
573
		IContextSource $context, callable $postCommitWork = null
574
	) {
575
		// Either all DBs should commit or none
576
		ignore_user_abort( true );
577
578
		$config = $context->getConfig();
579
		$request = $context->getRequest();
580
		$output = $context->getOutput();
581
		$lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
582
583
		// Commit all changes
584
		$lbFactory->commitMasterChanges(
585
			__METHOD__,
586
			// Abort if any transaction was too big
587
			[ 'maxWriteDuration' => $config->get( 'MaxUserDBWriteDuration' ) ]
588
		);
589
		wfDebug( __METHOD__ . ': primary transaction round committed' );
590
591
		// Run updates that need to block the user or affect output (this is the last chance)
592
		DeferredUpdates::doUpdates( 'enqueue', DeferredUpdates::PRESEND );
593
		wfDebug( __METHOD__ . ': pre-send deferred updates completed' );
594
595
		// Decide when clients block on ChronologyProtector DB position writes
596
		$urlDomainDistance = (
597
			$request->wasPosted() &&
598
			$output->getRedirect() &&
599
			$lbFactory->hasOrMadeRecentMasterChanges( INF )
600
		) ? self::getUrlDomainDistance( $output->getRedirect(), $context ) : false;
601
602
		if ( $urlDomainDistance === 'local' || $urlDomainDistance === 'remote' ) {
603
			// OutputPage::output() will be fast; $postCommitWork will not be useful for
604
			// masking the latency of syncing DB positions accross all datacenters synchronously.
605
			// Instead, make use of the RTT time of the client follow redirects.
606
			$flags = $lbFactory::SHUTDOWN_CHRONPROT_ASYNC;
607
			$cpPosTime = microtime( true );
608
			// Client's next request should see 1+ positions with this DBMasterPos::asOf() time
609
			if ( $urlDomainDistance === 'local' ) {
610
				// Client will stay on this domain, so set an unobtrusive cookie
611
				$expires = time() + ChronologyProtector::POSITION_TTL;
612
				$options = [ 'prefix' => '' ];
613
				$request->response()->setCookie( 'cpPosTime', $cpPosTime, $expires, $options );
614
			} else {
615
				// Cookies may not work across wiki domains, so use a URL parameter
616
				$safeUrl = $lbFactory->appendPreShutdownTimeAsQuery(
617
					$output->getRedirect(),
618
					$cpPosTime
619
				);
620
				$output->redirect( $safeUrl );
621
			}
622
		} else {
623
			// OutputPage::output() is fairly slow; run it in $postCommitWork to mask
624
			// the latency of syncing DB positions accross all datacenters synchronously
625
			$flags = $lbFactory::SHUTDOWN_CHRONPROT_SYNC;
626
			if ( $lbFactory->hasOrMadeRecentMasterChanges( INF ) ) {
627
				$cpPosTime = microtime( true );
628
				// Set a cookie in case the DB position store cannot sync accross datacenters.
629
				// This will at least cover the common case of the user staying on the domain.
630
				$expires = time() + ChronologyProtector::POSITION_TTL;
631
				$options = [ 'prefix' => '' ];
632
				$request->response()->setCookie( 'cpPosTime', $cpPosTime, $expires, $options );
633
			}
634
		}
635
		// Record ChronologyProtector positions for DBs affected in this request at this point
636
		$lbFactory->shutdown( $flags, $postCommitWork );
637
		wfDebug( __METHOD__ . ': LBFactory shutdown completed' );
638
639
		// Set a cookie to tell all CDN edge nodes to "stick" the user to the DC that handles this
640
		// POST request (e.g. the "master" data center). Also have the user briefly bypass CDN so
641
		// ChronologyProtector works for cacheable URLs.
642
		if ( $request->wasPosted() && $lbFactory->hasOrMadeRecentMasterChanges() ) {
643
			$expires = time() + $config->get( 'DataCenterUpdateStickTTL' );
644
			$options = [ 'prefix' => '' ];
645
			$request->response()->setCookie( 'UseDC', 'master', $expires, $options );
646
			$request->response()->setCookie( 'UseCDNCache', 'false', $expires, $options );
647
		}
648
649
		// Avoid letting a few seconds of replica DB lag cause a month of stale data. This logic is
650
		// also intimately related to the value of $wgCdnReboundPurgeDelay.
651
		if ( $lbFactory->laggedReplicaUsed() ) {
652
			$maxAge = $config->get( 'CdnMaxageLagged' );
653
			$output->lowerCdnMaxage( $maxAge );
654
			$request->response()->header( "X-Database-Lagged: true" );
655
			wfDebugLog( 'replication', "Lagged DB used; CDN cache TTL limited to $maxAge seconds" );
656
		}
657
658
		// Avoid long-term cache pollution due to message cache rebuild timeouts (T133069)
659
		if ( MessageCache::singleton()->isDisabled() ) {
660
			$maxAge = $config->get( 'CdnMaxageSubstitute' );
661
			$output->lowerCdnMaxage( $maxAge );
662
			$request->response()->header( "X-Response-Substitute: true" );
663
		}
664
	}
665
666
	/**
667
	 * @param string $url
668
	 * @param IContextSource $context
669
	 * @return string Either "local", "remote" if in the farm, "external" otherwise
670
	 */
671
	private static function getUrlDomainDistance( $url, IContextSource $context ) {
672
		static $relevantKeys = [ 'host' => true, 'port' => true ];
673
674
		$infoCandidate = wfParseUrl( $url );
675
		if ( $infoCandidate === false ) {
676
			return 'external';
677
		}
678
679
		$infoCandidate = array_intersect_key( $infoCandidate, $relevantKeys );
680
		$clusterHosts = array_merge(
681
			// Local wiki host (the most common case)
682
			[ $context->getConfig()->get( 'CanonicalServer' ) ],
683
			// Any local/remote wiki virtual hosts for this wiki farm
684
			$context->getConfig()->get( 'LocalVirtualHosts' )
685
		);
686
687
		foreach ( $clusterHosts as $i => $clusterHost ) {
688
			$parseUrl = wfParseUrl( $clusterHost );
689
			if ( !$parseUrl ) {
690
				continue;
691
			}
692
			$infoHost = array_intersect_key( $parseUrl, $relevantKeys );
693
			if ( $infoCandidate === $infoHost ) {
694
				return ( $i === 0 ) ? 'local' : 'remote';
695
			}
696
		}
697
698
		return 'external';
699
	}
700
701
	/**
702
	 * This function does work that can be done *after* the
703
	 * user gets the HTTP response so they don't block on it
704
	 *
705
	 * This manages deferred updates, job insertion,
706
	 * final commit, and the logging of profiling data
707
	 *
708
	 * @param string $mode Use 'fast' to always skip job running
709
	 * @since 1.26
710
	 */
711
	public function doPostOutputShutdown( $mode = 'normal' ) {
712
		$timing = $this->context->getTiming();
713
		$timing->mark( 'requestShutdown' );
714
715
		// Show visible profiling data if enabled (which cannot be post-send)
716
		Profiler::instance()->logDataPageOutputOnly();
717
718
		$callback = function () use ( $mode ) {
719
			try {
720
				$this->restInPeace( $mode );
721
			} catch ( Exception $e ) {
722
				MWExceptionHandler::handleException( $e );
723
			}
724
		};
725
726
		// Defer everything else...
727
		if ( function_exists( 'register_postsend_function' ) ) {
728
			// https://github.com/facebook/hhvm/issues/1230
729
			register_postsend_function( $callback );
730
		} else {
731
			if ( function_exists( 'fastcgi_finish_request' ) ) {
732
				fastcgi_finish_request();
733
			} else {
734
				// Either all DB and deferred updates should happen or none.
735
				// The latter should not be cancelled due to client disconnect.
736
				ignore_user_abort( true );
737
			}
738
739
			$callback();
740
		}
741
	}
742
743
	private function main() {
744
		global $wgTitle;
745
746
		$output = $this->context->getOutput();
747
		$request = $this->context->getRequest();
748
749
		// Send Ajax requests to the Ajax dispatcher.
750
		if ( $this->config->get( 'UseAjax' ) && $request->getVal( 'action' ) === 'ajax' ) {
751
			// Set a dummy title, because $wgTitle == null might break things
752
			$title = Title::makeTitle( NS_SPECIAL, 'Badtitle/performing an AJAX call in '
753
				. __METHOD__
754
			);
755
			$this->context->setTitle( $title );
756
			$wgTitle = $title;
757
758
			$dispatcher = new AjaxDispatcher( $this->config );
759
			$dispatcher->performAction( $this->context->getUser() );
760
761
			return;
762
		}
763
764
		// Get title from request parameters,
765
		// is set on the fly by parseTitle the first time.
766
		$title = $this->getTitle();
767
		$action = $this->getAction();
768
		$wgTitle = $title;
769
770
		// Set DB query expectations for this HTTP request
771
		$trxLimits = $this->config->get( 'TrxProfilerLimits' );
772
		$trxProfiler = Profiler::instance()->getTransactionProfiler();
773
		$trxProfiler->setLogger( LoggerFactory::getInstance( 'DBPerformance' ) );
774
		if ( $request->hasSafeMethod() ) {
775
			$trxProfiler->setExpectations( $trxLimits['GET'], __METHOD__ );
776
		} else {
777
			$trxProfiler->setExpectations( $trxLimits['POST'], __METHOD__ );
778
		}
779
780
		// If the user has forceHTTPS set to true, or if the user
781
		// is in a group requiring HTTPS, or if they have the HTTPS
782
		// preference set, redirect them to HTTPS.
783
		// Note: Do this after $wgTitle is setup, otherwise the hooks run from
784
		// isLoggedIn() will do all sorts of weird stuff.
785
		if (
786
			$request->getProtocol() == 'http' &&
787
			// switch to HTTPS only when supported by the server
788
			preg_match( '#^https://#', wfExpandUrl( $request->getRequestURL(), PROTO_HTTPS ) ) &&
789
			(
790
				$request->getSession()->shouldForceHTTPS() ||
791
				// Check the cookie manually, for paranoia
792
				$request->getCookie( 'forceHTTPS', '' ) ||
793
				// check for prefixed version that was used for a time in older MW versions
794
				$request->getCookie( 'forceHTTPS' ) ||
795
				// Avoid checking the user and groups unless it's enabled.
796
				(
797
					$this->context->getUser()->isLoggedIn()
798
					&& $this->context->getUser()->requiresHTTPS()
799
				)
800
			)
801
		) {
802
			$oldUrl = $request->getFullRequestURL();
803
			$redirUrl = preg_replace( '#^http://#', 'https://', $oldUrl );
804
805
			// ATTENTION: This hook is likely to be removed soon due to overall design of the system.
806
			if ( Hooks::run( 'BeforeHttpsRedirect', [ $this->context, &$redirUrl ] ) ) {
807
808
				if ( $request->wasPosted() ) {
809
					// This is weird and we'd hope it almost never happens. This
810
					// means that a POST came in via HTTP and policy requires us
811
					// redirecting to HTTPS. It's likely such a request is going
812
					// to fail due to post data being lost, but let's try anyway
813
					// and just log the instance.
814
815
					// @todo FIXME: See if we could issue a 307 or 308 here, need
816
					// to see how clients (automated & browser) behave when we do
817
					wfDebugLog( 'RedirectedPosts', "Redirected from HTTP to HTTPS: $oldUrl" );
818
				}
819
				// Setup dummy Title, otherwise OutputPage::redirect will fail
820
				$title = Title::newFromText( 'REDIR', NS_MAIN );
821
				$this->context->setTitle( $title );
822
				// Since we only do this redir to change proto, always send a vary header
823
				$output->addVaryHeader( 'X-Forwarded-Proto' );
824
				$output->redirect( $redirUrl );
825
				$output->output();
826
827
				return;
828
			}
829
		}
830
831
		if ( $title->canExist() && HTMLFileCache::useFileCache( $this->context ) ) {
832
			// Try low-level file cache hit
833
			$cache = new HTMLFileCache( $title, $action );
0 ignored issues
show
It seems like $title defined by $this->getTitle() on line 766 can be null; however, HTMLFileCache::__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...
834
			if ( $cache->isCacheGood( /* Assume up to date */ ) ) {
835
				// Check incoming headers to see if client has this cached
836
				$timestamp = $cache->cacheTimestamp();
837
				if ( !$output->checkLastModified( $timestamp ) ) {
838
					$cache->loadFromFileCache( $this->context );
839
				}
840
				// Do any stats increment/watchlist stuff, assuming user is viewing the
841
				// latest revision (which should always be the case for file cache)
842
				$this->context->getWikiPage()->doViewUpdates( $this->context->getUser() );
843
				// Tell OutputPage that output is taken care of
844
				$output->disable();
845
846
				return;
847
			}
848
		}
849
850
		// Actually do the work of the request and build up any output
851
		$this->performRequest();
852
853
		// GUI-ify and stash the page output in MediaWiki::doPreOutputCommit() while
854
		// ChronologyProtector synchronizes DB positions or slaves accross all datacenters.
855
		$buffer = null;
856
		$outputWork = function () use ( $output, &$buffer ) {
857
			if ( $buffer === null ) {
858
				$buffer = $output->output( true );
859
			}
860
861
			return $buffer;
862
		};
863
864
		// Now commit any transactions, so that unreported errors after
865
		// output() don't roll back the whole DB transaction and so that
866
		// we avoid having both success and error text in the response
867
		$this->doPreOutputCommit( $outputWork );
868
869
		// Now send the actual output
870
		print $outputWork();
871
	}
872
873
	/**
874
	 * Ends this task peacefully
875
	 * @param string $mode Use 'fast' to always skip job running
876
	 */
877
	public function restInPeace( $mode = 'fast' ) {
878
		$lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
879
		// Assure deferred updates are not in the main transaction
880
		$lbFactory->commitMasterChanges( __METHOD__ );
881
882
		// Loosen DB query expectations since the HTTP client is unblocked
883
		$trxProfiler = Profiler::instance()->getTransactionProfiler();
884
		$trxProfiler->resetExpectations();
885
		$trxProfiler->setExpectations(
886
			$this->config->get( 'TrxProfilerLimits' )['PostSend'],
887
			__METHOD__
888
		);
889
890
		// Do any deferred jobs
891
		DeferredUpdates::doUpdates( 'enqueue' );
892
		DeferredUpdates::setImmediateMode( true );
893
894
		// Make sure any lazy jobs are pushed
895
		JobQueueGroup::pushLazyJobs();
896
897
		// Now that everything specific to this request is done,
898
		// try to occasionally run jobs (if enabled) from the queues
899
		if ( $mode === 'normal' ) {
900
			$this->triggerJobs();
901
		}
902
903
		// Log profiling data, e.g. in the database or UDP
904
		wfLogProfilingData();
905
906
		// Commit and close up!
907
		$lbFactory->commitMasterChanges( __METHOD__ );
908
		$lbFactory->shutdown( LBFactory::SHUTDOWN_NO_CHRONPROT );
909
910
		wfDebug( "Request ended normally\n" );
911
	}
912
913
	/**
914
	 * Potentially open a socket and sent an HTTP request back to the server
915
	 * to run a specified number of jobs. This registers a callback to cleanup
916
	 * the socket once it's done.
917
	 */
918
	public function triggerJobs() {
919
		$jobRunRate = $this->config->get( 'JobRunRate' );
920
		if ( $this->getTitle()->isSpecial( 'RunJobs' ) ) {
921
			return; // recursion guard
922
		} elseif ( $jobRunRate <= 0 || wfReadOnly() ) {
923
			return;
924
		}
925
926
		if ( $jobRunRate < 1 ) {
927
			$max = mt_getrandmax();
928
			if ( mt_rand( 0, $max ) > $max * $jobRunRate ) {
929
				return; // the higher the job run rate, the less likely we return here
930
			}
931
			$n = 1;
932
		} else {
933
			$n = intval( $jobRunRate );
934
		}
935
936
		$runJobsLogger = LoggerFactory::getInstance( 'runJobs' );
937
938
		// Fall back to running the job(s) while the user waits if needed
939
		if ( !$this->config->get( 'RunJobsAsync' ) ) {
940
			$runner = new JobRunner( $runJobsLogger );
941
			$runner->run( [ 'maxJobs' => $n ] );
942
			return;
943
		}
944
945
		// Do not send request if there are probably no jobs
946
		try {
947
			$group = JobQueueGroup::singleton();
948
			if ( !$group->queuesHaveJobs( JobQueueGroup::TYPE_DEFAULT ) ) {
949
				return;
950
			}
951
		} catch ( JobQueueError $e ) {
952
			MWExceptionHandler::logException( $e );
953
			return; // do not make the site unavailable
954
		}
955
956
		$query = [ 'title' => 'Special:RunJobs',
957
			'tasks' => 'jobs', 'maxjobs' => $n, 'sigexpiry' => time() + 5 ];
958
		$query['signature'] = SpecialRunJobs::getQuerySignature(
959
			$query, $this->config->get( 'SecretKey' ) );
960
961
		$errno = $errstr = null;
962
		$info = wfParseUrl( $this->config->get( 'CanonicalServer' ) );
963
		$host = $info ? $info['host'] : null;
964
		$port = 80;
965
		if ( isset( $info['scheme'] ) && $info['scheme'] == 'https' ) {
966
			$host = "tls://" . $host;
967
			$port = 443;
968
		}
969
		if ( isset( $info['port'] ) ) {
970
			$port = $info['port'];
971
		}
972
973
		MediaWiki\suppressWarnings();
974
		$sock = $host ? fsockopen(
975
			$host,
976
			$port,
977
			$errno,
978
			$errstr,
979
			// If it takes more than 100ms to connect to ourselves there is a problem...
980
			0.100
981
		) : false;
982
		MediaWiki\restoreWarnings();
983
984
		$invokedWithSuccess = true;
985
		if ( $sock ) {
986
			$special = SpecialPageFactory::getPage( 'RunJobs' );
987
			$url = $special->getPageTitle()->getCanonicalURL( $query );
988
			$req = (
989
				"POST $url HTTP/1.1\r\n" .
990
				"Host: {$info['host']}\r\n" .
991
				"Connection: Close\r\n" .
992
				"Content-Length: 0\r\n\r\n"
993
			);
994
995
			$runJobsLogger->info( "Running $n job(s) via '$url'" );
996
			// Send a cron API request to be performed in the background.
997
			// Give up if this takes too long to send (which should be rare).
998
			stream_set_timeout( $sock, 2 );
999
			$bytes = fwrite( $sock, $req );
1000
			if ( $bytes !== strlen( $req ) ) {
1001
				$invokedWithSuccess = false;
1002
				$runJobsLogger->error( "Failed to start cron API (socket write error)" );
1003
			} else {
1004
				// Do not wait for the response (the script should handle client aborts).
1005
				// Make sure that we don't close before that script reaches ignore_user_abort().
1006
				$start = microtime( true );
1007
				$status = fgets( $sock );
1008
				$sec = microtime( true ) - $start;
1009
				if ( !preg_match( '#^HTTP/\d\.\d 202 #', $status ) ) {
1010
					$invokedWithSuccess = false;
1011
					$runJobsLogger->error( "Failed to start cron API: received '$status' ($sec)" );
1012
				}
1013
			}
1014
			fclose( $sock );
1015
		} else {
1016
			$invokedWithSuccess = false;
1017
			$runJobsLogger->error( "Failed to start cron API (socket error $errno): $errstr" );
1018
		}
1019
1020
		// Fall back to running the job(s) while the user waits if needed
1021
		if ( !$invokedWithSuccess ) {
1022
			$runJobsLogger->warning( "Jobs switched to blocking; Special:RunJobs disabled" );
1023
1024
			$runner = new JobRunner( $runJobsLogger );
1025
			$runner->run( [ 'maxJobs'  => $n ] );
1026
		}
1027
	}
1028
}
1029