Issues (5)

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.

src/MediawikiApi.php (4 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
namespace Mediawiki\Api;
4
5
use DOMDocument;
6
use DOMXPath;
7
use GuzzleHttp\Client;
8
use GuzzleHttp\ClientInterface;
9
use GuzzleHttp\Exception\RequestException;
10
use GuzzleHttp\Promise\PromiseInterface;
11
use InvalidArgumentException;
12
use Mediawiki\Api\Guzzle\ClientFactory;
13
use Psr\Http\Message\ResponseInterface;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerInterface;
16
use Psr\Log\LogLevel;
17
use Psr\Log\NullLogger;
18
use SimpleXMLElement;
19
20
/**
21
 * Main class for this library
22
 *
23
 * @since 0.1
24
 *
25
 * @author Addshore
26
 */
27
class MediawikiApi implements MediawikiApiInterface, LoggerAwareInterface {
28
29
	/**
30
	 * @var ClientInterface|null Should be accessed through getClient
31
	 */
32
	private $client = null;
33
34
	/**
35
	 * @var bool|string
36
	 */
37
	private $isLoggedIn;
38
39
	/**
40
	 * @var MediawikiSession
41
	 */
42
	private $session;
43
44
	/**
45
	 * @var string
46
	 */
47
	private $version;
48
49
	/**
50
	 * @var LoggerInterface
51
	 */
52
	private $logger;
53
54
	/**
55
	 * @var string
56
	 */
57
	private $apiUrl;
58
59
	/**
60
	 * @since 2.0
61
	 *
62
	 * @param string $apiEndpoint e.g. https://en.wikipedia.org/w/api.php
63
	 *
64
	 * @return self returns a MediawikiApi instance using $apiEndpoint
65
	 */
66
	public static function newFromApiEndpoint( $apiEndpoint ) {
67
		return new self( $apiEndpoint );
68
	}
69
70
	/**
71
	 * Create a new MediawikiApi object from a URL to any page in a MediaWiki website.
72
	 *
73
	 * @since 2.0
74
	 * @see https://en.wikipedia.org/wiki/Really_Simple_Discovery
75
	 *
76
	 * @param string $url e.g. https://en.wikipedia.org OR https://de.wikipedia.org/wiki/Berlin
77
	 * @return self returns a MediawikiApi instance using the apiEndpoint provided by the RSD
78
	 *              file accessible on all Mediawiki pages
79
	 * @throws RsdException If the RSD URL could not be found in the page's HTML.
80
	 */
81 3
	public static function newFromPage( $url ) {
82
		// Set up HTTP client and HTML document.
83 3
		$tempClient = new Client( [ 'headers' => [ 'User-Agent' => 'addwiki-mediawiki-client' ] ] );
84 3
		$pageHtml = $tempClient->get( $url )->getBody();
85 3
		$pageDoc = new DOMDocument();
86
87
		// Try to load the HTML (turn off errors temporarily; most don't matter, and if they do get
88
		// in the way of finding the API URL, will be reported in the RsdException below).
89 3
		$internalErrors = libxml_use_internal_errors( true );
90 3
		$pageDoc->loadHTML( $pageHtml );
91 3
		$libXmlErrors = libxml_get_errors();
92 3
		libxml_use_internal_errors( $internalErrors );
93
94
		// Extract the RSD link.
95 3
		$xpath = 'head/link[@type="application/rsd+xml"][@href]';
96 3
		$link = ( new DOMXpath( $pageDoc ) )->query( $xpath );
97 3
		if ( $link->length === 0 ) {
98
			// Format libxml errors for display.
99
			$libXmlErrorStr = array_reduce( $libXmlErrors, function ( $prevErr, $err ) {
100
				return $prevErr . ', ' . $err->message . ' (line ' . $err->line . ')';
101 1
			} );
102 1
			if ( $libXmlErrorStr ) {
103
				$libXmlErrorStr = "In addition, libxml had the following errors: $libXmlErrorStr";
104
			}
105 1
			throw new RsdException( "Unable to find RSD URL in page: $url $libXmlErrorStr" );
106
		}
107 2
		$rsdUrl = $link->item( 0 )->attributes->getnamedItem( 'href' )->nodeValue;
108
109
		// Then get the RSD XML, and return the API link.
110 2
		$rsdXml = new SimpleXMLElement( $tempClient->get( $rsdUrl )->getBody() );
111 2
		return self::newFromApiEndpoint( (string)$rsdXml->service->apis->api->attributes()->apiLink );
112
	}
113
114
	/**
115
	 * @param string $apiUrl The API Url
116
	 * @param ClientInterface|null $client Guzzle Client
117
	 * @param MediawikiSession|null $session Inject a custom session here
118
	 */
119 25
	public function __construct( $apiUrl, ClientInterface $client = null,
120
								 MediawikiSession $session = null ) {
121 25
		if ( !is_string( $apiUrl ) ) {
122 4
			throw new InvalidArgumentException( '$apiUrl must be a string' );
123
		}
124 21
		if ( $session === null ) {
125 21
			$session = new MediawikiSession( $this );
126
		}
127
128 21
		$this->apiUrl = $apiUrl;
129 21
		$this->client = $client;
130 21
		$this->session = $session;
131
132 21
		$this->logger = new NullLogger();
133 21
	}
134
135
	/**
136
	 * Get the API URL (the URL to which API requests are sent, usually ending in api.php).
137
	 * This is useful if you've created this object via MediawikiApi::newFromPage().
138
	 *
139
	 * @since 2.3
140
	 *
141
	 * @return string The API URL.
142
	 */
143
	public function getApiUrl() {
144
		return $this->apiUrl;
145
	}
146
147
	/**
148
	 * @return ClientInterface
149
	 */
150 21
	private function getClient() {
151 21
		if ( $this->client === null ) {
152 4
			$clientFactory = new ClientFactory();
153 4
			$clientFactory->setLogger( $this->logger );
154 4
			$this->client = $clientFactory->getClient();
155
		}
156 21
		return $this->client;
157
	}
158
159
	/**
160
	 * Sets a logger instance on the object
161
	 *
162
	 * @since 1.1
163
	 *
164
	 * @param LoggerInterface $logger The new Logger object.
165
	 *
166
	 * @return null
167
	 */
168 1
	public function setLogger( LoggerInterface $logger ) {
169 1
		$this->logger = $logger;
170 1
		$this->session->setLogger( $logger );
171 1
	}
172
173
	/**
174
	 * @since 2.0
175
	 *
176
	 * @param Request $request The GET request to send.
177
	 *
178
	 * @return PromiseInterface
179
	 *         Normally promising an array, though can be mixed (json_decode result)
180
	 *         Can throw UsageExceptions or RejectionExceptions
181
	 */
182 1 View Code Duplication
	public function getRequestAsync( Request $request ) {
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
183 1
		$promise = $this->getClient()->requestAsync(
184 1
			'GET',
185 1
			$this->apiUrl,
186 1
			$this->getClientRequestOptions( $request, 'query' )
187
		);
188
189
		return $promise->then( function ( ResponseInterface $response ) {
190 1
			return call_user_func( [ $this, 'decodeResponse' ], $response );
191 1
		} );
192
	}
193
194
	/**
195
	 * @since 2.0
196
	 *
197
	 * @param Request $request The POST request to send.
198
	 *
199
	 * @return PromiseInterface
200
	 *         Normally promising an array, though can be mixed (json_decode result)
201
	 *         Can throw UsageExceptions or RejectionExceptions
202
	 */
203 1 View Code Duplication
	public function postRequestAsync( Request $request ) {
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
204 1
		$promise = $this->getClient()->requestAsync(
205 1
			'POST',
206 1
			$this->apiUrl,
207 1
			$this->getClientRequestOptions( $request, $this->getPostRequestEncoding( $request ) )
208
		);
209
210
		return $promise->then( function ( ResponseInterface $response ) {
211 1
			return call_user_func( [ $this, 'decodeResponse' ], $response );
212 1
		} );
213
	}
214
215
	/**
216
	 * @since 0.2
217
	 *
218
	 * @param Request $request The GET request to send.
219
	 *
220
	 * @return mixed Normally an array
221
	 */
222 9 View Code Duplication
	public function getRequest( Request $request ) {
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
223 9
		$response = $this->getClient()->request(
224 9
			'GET',
225 9
			$this->apiUrl,
226 9
			$this->getClientRequestOptions( $request, 'query' )
227
		);
228
229 9
		return $this->decodeResponse( $response );
230
	}
231
232
	/**
233
	 * @since 0.2
234
	 *
235
	 * @param Request $request The POST request to send.
236
	 *
237
	 * @return mixed Normally an array
238
	 */
239 10 View Code Duplication
	public function postRequest( Request $request ) {
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
240 10
		$response = $this->getClient()->request(
241 10
			'POST',
242 10
			$this->apiUrl,
243 10
			$this->getClientRequestOptions( $request, $this->getPostRequestEncoding( $request ) )
244
		);
245
246 10
		return $this->decodeResponse( $response );
247
	}
248
249
	/**
250
	 * @param ResponseInterface $response
251
	 *
252
	 * @return mixed
253
	 * @throws UsageException
254
	 */
255 21
	private function decodeResponse( ResponseInterface $response ) {
256 21
		$resultArray = json_decode( $response->getBody(), true );
257
258 21
		$this->logWarnings( $resultArray );
259 21
		$this->throwUsageExceptions( $resultArray );
260
261 19
		return $resultArray;
262
	}
263
264
	/**
265
	 * @param Request $request
266
	 *
267
	 * @return string
268
	 */
269 9
	private function getPostRequestEncoding( Request $request ) {
270 9
		if ( $request instanceof MultipartRequest ) {
271
			return 'multipart';
272
		}
273 9
		foreach ( $request->getParams() as $value ) {
274 9
			if ( is_resource( $value ) ) {
275 9
				return 'multipart';
276
			}
277
		}
278 8
		return 'form_params';
279
	}
280
281
	/**
282
	 * @param Request $request
283
	 * @param string $paramsKey either 'query' or 'multipart'
284
	 *
285
	 * @throws RequestException
286
	 *
287
	 * @return array as needed by ClientInterface::get and ClientInterface::post
288
	 */
289 21
	private function getClientRequestOptions( Request $request, $paramsKey ) {
290 21
		$params = array_merge( $request->getParams(), [ 'format' => 'json' ] );
291 21
		if ( $paramsKey === 'multipart' ) {
292 1
			$params = $this->encodeMultipartParams( $request, $params );
293
		}
294
295
		return [
296 21
			$paramsKey => $params,
297 21
			'headers' => array_merge( $this->getDefaultHeaders(), $request->getHeaders() ),
298
		];
299
	}
300
301
	/**
302
	 * Turn the normal key-value array of request parameters into a multipart array where each
303
	 * parameter is a new array with a 'name' and 'contents' elements (and optionally more, if the
304
	 * request is a MultipartRequest).
305
	 *
306
	 * @param Request $request The request to which the parameters belong.
307
	 * @param string[] $params The existing parameters. Not the same as $request->getParams().
308
	 *
309
	 * @return array
310
	 */
311 1
	private function encodeMultipartParams( Request $request, $params ) {
312
		// See if there are any multipart parameters in this request.
313 1
		$multipartParams = ( $request instanceof MultipartRequest )
314
			? $request->getMultipartParams()
315 1
			: [];
316 1
		return array_map(
317 1
			function ( $name, $value ) use ( $multipartParams ) {
318
				$partParams = [
319 1
					'name' => $name,
320 1
					'contents' => $value,
321
				];
322 1
				if ( isset( $multipartParams[ $name ] ) ) {
323
					// If extra parameters have been set for this part, use them.
324
					$partParams = array_merge( $multipartParams[ $name ], $partParams );
325
				}
326 1
				return $partParams;
327 1
			},
328 1
			array_keys( $params ),
329 1
			$params
330
		);
331
	}
332
333
	/**
334
	 * @return array
335
	 */
336 17
	private function getDefaultHeaders() {
337
		return [
338 17
			'User-Agent' => $this->getUserAgent(),
339
		];
340
	}
341
342 17
	private function getUserAgent() {
343 17
		$loggedIn = $this->isLoggedin();
344 17
		if ( $loggedIn ) {
345
			return 'addwiki-mediawiki-client/' . $loggedIn;
346
		}
347 17
		return 'addwiki-mediawiki-client';
348
	}
349
350
	/**
351
	 * @param array $result
352
	 */
353 18
	private function logWarnings( $result ) {
354 18
		if ( is_array( $result ) ) {
355
			// Let's see if there is 'warnings' key on the first level of the array...
356 17
			if ( $this->logWarning( $result ) ) {
357 1
				return;
358
			}
359
360
			// ...if no then go one level deeper and check there for it.
361 16
			foreach ( $result as $value ) {
362 15
				if ( !is_array( $value ) ) {
363 7
					continue;
364
				}
365
366 10
				$this->logWarning( $value );
367
			}
368
		}
369 17
	}
370
371
	/**
372
	 * @param array $array Array response to look for warning in.
373
	 *
374
	 * @return bool Whether any warning has been logged or not.
375
	 */
376 17
	protected function logWarning( $array ) {
377 17
		$found = false;
378
379 17
		if ( !array_key_exists( 'warnings', $array ) ) {
380 16
			return false;
381
		}
382
383 1
		foreach ( $array['warnings'] as $module => $warningData ) {
384
			// Accommodate both formatversion=2 and old-style API results
385 1
			$logPrefix = $module . ': ';
386 1
			if ( isset( $warningData['*'] ) ) {
387
				$this->logger->warning( $logPrefix . $warningData['*'], [ 'data' => $warningData ] );
388 1
			} elseif ( isset( $warningData['warnings'] ) ) {
389
				$this->logger->warning( $logPrefix . $warningData['warnings'], [ 'data' => $warningData ] );
390
			} else {
391 1
				$this->logger->warning( $logPrefix, [ 'data' => $warningData ] );
392
			}
393
394 1
			$found = true;
395
		}
396
397 1
		return $found;
398
	}
399
400
	/**
401
	 * @param array $result
402
	 *
403
	 * @throws UsageException
404
	 */
405 17
	private function throwUsageExceptions( $result ) {
406 17
		if ( is_array( $result ) && array_key_exists( 'error', $result ) ) {
407 2
			throw new UsageException(
408 2
				$result['error']['code'],
409 2
				$result['error']['info'],
410 2
				$result
411
			);
412
		}
413 15
	}
414
415
	/**
416
	 * @since 0.1
417
	 *
418
	 * @return bool|string false or the name of the current user
419
	 */
420 17
	public function isLoggedin() {
421 17
		return $this->isLoggedIn;
422
	}
423
424
	/**
425
	 * @since 0.1
426
	 *
427
	 * @param ApiUser $apiUser The ApiUser to log in as.
428
	 *
429
	 * @throws UsageException
430
	 * @return bool success
431
	 */
432 2
	public function login( ApiUser $apiUser ) {
433 2
		$this->logger->log( LogLevel::DEBUG, 'Logging in' );
434 2
		$credentials = $this->getLoginParams( $apiUser );
435 2
		$result = $this->postRequest( new SimpleRequest( 'login', $credentials ) );
436 2
		if ( $result['login']['result'] == "NeedToken" ) {
437 2
			$params = array_merge( [ 'lgtoken' => $result['login']['token'] ], $credentials );
438 2
			$result = $this->postRequest( new SimpleRequest( 'login', $params ) );
439
		}
440 2
		if ( $result['login']['result'] == "Success" ) {
441 1
			$this->isLoggedIn = $apiUser->getUsername();
442 1
			return true;
443
		}
444
445 1
		$this->isLoggedIn = false;
446 1
		$this->logger->log( LogLevel::DEBUG, 'Login failed.', $result );
447 1
		$this->throwLoginUsageException( $result );
448
		return false;
449
	}
450
451
	/**
452
	 * @param ApiUser $apiUser
453
	 *
454
	 * @return string[]
455
	 */
456 2
	private function getLoginParams( ApiUser $apiUser ) {
457
		$params = [
458 2
			'lgname' => $apiUser->getUsername(),
459 2
			'lgpassword' => $apiUser->getPassword(),
460
		];
461
462 2
		if ( $apiUser->getDomain() !== null ) {
463
			$params['lgdomain'] = $apiUser->getDomain();
464
		}
465 2
		return $params;
466
	}
467
468
	/**
469
	 * @param array $result
470
	 *
471
	 * @throws UsageException
472
	 */
473 1
	private function throwLoginUsageException( $result ) {
474 1
		$loginResult = $result['login']['result'];
475
476 1
		throw new UsageException(
477 1
			'login-' . $loginResult,
478 1
			array_key_exists( 'reason', $result['login'] )
479
				? $result['login']['reason']
480 1
				: 'No Reason given',
481 1
			$result
482
		);
483
	}
484
485
	/**
486
	 * @since 0.1
487
	 *
488
	 * @return bool success
489
	 */
490 2
	public function logout() {
491 2
		$this->logger->log( LogLevel::DEBUG, 'Logging out' );
492 2
		$result = $this->postRequest( new SimpleRequest( 'logout', [
493 2
			'token' => $this->getToken()
494 1
		] ) );
495 1
		if ( $result === [] ) {
496 1
			$this->isLoggedIn = false;
497
			$this->clearTokens();
498 1
			return true;
499
		}
500
		return false;
501
	}
502
503
	/**
504
	 * @since 0.1
505
	 *
506
	 * @param string $type The token type to get.
507
	 *
508 2
	 * @return string
509 2
	 */
510
	public function getToken( $type = 'csrf' ) {
511
		return $this->session->getToken( $type );
512
	}
513
514
	/**
515
	 * Clear all tokens stored by the API.
516
	 *
517 1
	 * @since 0.1
518 1
	 */
519 1
	public function clearTokens() {
520
		$this->session->clearTokens();
521
	}
522
523
	/**
524 4
	 * @return string
525 4
	 */
526 4
	public function getVersion() {
527 4
		if ( !isset( $this->version ) ) {
528
			$result = $this->getRequest( new SimpleRequest( 'query', [
529
				'meta' => 'siteinfo',
530 4
				'continue' => '',
531 4
			] ) );
532 4
			preg_match(
533 4
				'/\d+(?:\.\d+)+/',
534
				$result['query']['general']['generator'],
535 4
				$versionParts
536
			);
537 4
			$this->version = $versionParts[0];
538
		}
539
		return $this->version;
540
	}
541
542
}
543