mediawiki-extensions-Echo/includes/ForeignWikiRequest.php
Bartosz Dziewoński 18ed307c39 ForeignWikiRequest: Ensure fetching CSRF tokens uses unique CentralAuth tokens
getCsrfToken() previously did the requests to every wiki with the same
'centralauthtoken'. Luckily this wasn't causing any bugs in practice,
because all users of ForeignWikiRequest that need CSRF tokens always
use it with a single wiki.

Change-Id: Ib1c0b9c13a34e38f85faed519c46cabd3b77e61e
2023-09-04 23:39:40 +02:00

281 lines
7.9 KiB
PHP

<?php
namespace MediaWiki\Extension\Notifications;
use ApiMain;
use CentralAuthSessionProvider;
use CentralIdLookup;
use Exception;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\SessionManager;
use MediaWiki\User\UserIdentity;
use MediaWiki\WikiMap\WikiMap;
use MWExceptionHandler;
use RequestContext;
use User;
use WebRequest;
class ForeignWikiRequest {
/** @var User */
protected $user;
/** @var array */
protected $params;
/** @var array */
protected $wikis;
/** @var string|null */
protected $wikiParam;
/** @var string */
protected $method;
/** @var string|null */
protected $tokenType;
/** @var string[]|null */
protected $csrfTokens;
/**
* @param User $user
* @param array $params Request parameters
* @param array $wikis Wikis to send the request to
* @param string|null $wikiParam Parameter name to set to the name of the wiki
* @param string|null $postToken If set, use POST requests and inject a token of this type;
* if null, use GET requests.
*/
public function __construct( User $user, array $params, array $wikis, $wikiParam = null, $postToken = null ) {
$this->user = $user;
$this->params = $params;
$this->wikis = $wikis;
$this->wikiParam = $wikiParam;
$this->method = $postToken === null ? 'GET' : 'POST';
$this->tokenType = $postToken;
$this->csrfTokens = null;
}
/**
* Execute the request
* @param WebRequest|null $originalRequest Original request data to be sent with these requests
* @return array[] [ wiki => result ]
*/
public function execute( ?WebRequest $originalRequest = null ) {
if ( !$this->canUseCentralAuth() ) {
return [];
}
$reqs = $this->getRequestParams(
$this->method,
function ( string $wiki ) use ( $originalRequest ) {
return $this->getQueryParams( $wiki, $originalRequest );
},
$originalRequest
);
return $this->doRequests( $reqs );
}
/**
* @param UserIdentity $user
* @return int
*/
protected function getCentralId( $user ) {
return MediaWikiServices::getInstance()
->getCentralIdLookup()
->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW );
}
protected function canUseCentralAuth() {
global $wgFullyInitialised;
return $wgFullyInitialised &&
RequestContext::getMain()->getUser()->isSafeToLoad() &&
$this->user->isSafeToLoad() &&
SessionManager::getGlobalSession()->getProvider() instanceof CentralAuthSessionProvider &&
$this->getCentralId( $this->user ) !== 0;
}
/**
* Returns CentralAuth token, or null on failure.
*
* @param User $user
* @return string|null
*/
protected function getCentralAuthToken( User $user ) {
$context = new RequestContext;
$context->setRequest( new FauxRequest( [ 'action' => 'centralauthtoken' ] ) );
$context->setUser( $user );
$api = new ApiMain( $context );
try {
$api->execute();
return $api->getResult()->getResultData( [ 'centralauthtoken', 'centralauthtoken' ] );
} catch ( Exception $ex ) {
LoggerFactory::getInstance( 'Echo' )->debug(
'Exception when fetching CentralAuth token: wiki: {wiki}, userName: {userName}, ' .
'userId: {userId}, centralId: {centralId}, exception: {exception}',
[
'wiki' => WikiMap::getCurrentWikiId(),
'userName' => $user->getName(),
'userId' => $user->getId(),
'centralId' => $this->getCentralId( $user ),
'exception' => $ex,
]
);
MWExceptionHandler::logException( $ex );
return null;
}
}
/**
* Get the CSRF token for a given wiki.
* This method fetches the tokens for all requested wikis at once and caches the result.
*
* @param string $wiki Name of the wiki to get a token for
* @param WebRequest|null $originalRequest Original request data to be sent with these requests
* @return string Token, or empty string if an unable to retrieve the token.
*/
protected function getCsrfToken( $wiki, ?WebRequest $originalRequest ) {
if ( $this->csrfTokens === null ) {
$this->csrfTokens = [];
$reqs = $this->getRequestParams( 'GET', function ( string $wiki ) {
// This doesn't depend on the wiki, but 'centralauthtoken' must be different every time
return [
'action' => 'query',
'meta' => 'tokens',
'type' => $this->tokenType,
'format' => 'json',
'formatversion' => '1',
'errorformat' => 'bc',
'centralauthtoken' => $this->getCentralAuthToken( $this->user ),
];
}, $originalRequest );
$responses = $this->doRequests( $reqs );
foreach ( $responses as $w => $response ) {
if ( isset( $response['query']['tokens']['csrftoken'] ) ) {
$this->csrfTokens[$w] = $response['query']['tokens']['csrftoken'];
} else {
LoggerFactory::getInstance( 'Echo' )->warning(
__METHOD__ . ': Unexpected CSRF token API response from {wiki}',
[
'wiki' => $wiki,
'response' => $response,
]
);
}
}
}
return $this->csrfTokens[$wiki] ?? '';
}
/**
* @param string $method 'GET' or 'POST'
* @param callable $makeParams Callback that takes a wiki name and returns an associative array of
* query string / POST parameters
* @param WebRequest|null $originalRequest Original request data to be sent with these requests
* @return array[] Array of request parameters to pass to doRequests(), keyed by wiki name
*/
protected function getRequestParams( $method, $makeParams, ?WebRequest $originalRequest ) {
$apis = ForeignNotifications::getApiEndpoints( $this->wikis );
if ( !$apis ) {
return [];
}
$reqs = [];
foreach ( $apis as $wiki => $api ) {
$queryKey = $method === 'POST' ? 'body' : 'query';
$reqs[$wiki] = [
'method' => $method,
'url' => $api['url'],
$queryKey => $makeParams( $wiki )
];
if ( $originalRequest ) {
$reqs[$wiki]['headers'] = [
'X-Forwarded-For' => $originalRequest->getIP(),
'User-Agent' => (
$originalRequest->getHeader( 'User-Agent' )
. ' (via ForeignWikiRequest MediaWiki/' . MW_VERSION . ')'
),
];
}
}
return $reqs;
}
/**
* @param string $wiki Wiki name
* @param WebRequest|null $originalRequest Original request data to be sent with these requests
* @return array
*/
protected function getQueryParams( $wiki, ?WebRequest $originalRequest ) {
$extraParams = [];
if ( $this->wikiParam ) {
// Only request data from that specific wiki, or they'd all spawn
// cross-wiki api requests...
$extraParams[$this->wikiParam] = $wiki;
}
if ( $this->method === 'POST' ) {
$extraParams['token'] = $this->getCsrfToken( $wiki, $originalRequest );
}
return [
'centralauthtoken' => $this->getCentralAuthToken( $this->user ),
// once all the results are gathered & merged, they'll be output in the
// user requested format
// but this is going to be an internal request & we don't want those
// results in the format the user requested but in a fixed format that
// we can interpret here
'format' => 'json',
'formatversion' => '1',
'errorformat' => 'bc',
] + $extraParams + $this->params;
}
/**
* @param array $reqs API request params
* @return array[]
* @throws Exception
*/
protected function doRequests( array $reqs ) {
$http = MediaWikiServices::getInstance()->getHttpRequestFactory()->createMultiClient();
$responses = $http->runMulti( $reqs );
$results = [];
foreach ( $responses as $wiki => $response ) {
$statusCode = $response['response']['code'];
if ( $statusCode >= 200 && $statusCode <= 299 ) {
$parsed = json_decode( $response['response']['body'], true );
if ( $parsed ) {
$results[$wiki] = $parsed;
}
}
if ( !isset( $results[$wiki] ) ) {
LoggerFactory::getInstance( 'Echo' )->warning(
'Failed to fetch API response from {wiki}. Error: {error}',
[
'wiki' => $wiki,
'error' => $response['response']['error'] ?? 'unknown',
'statusCode' => $statusCode,
'response' => $response['response'],
'request' => $reqs[$wiki],
]
);
}
}
return $results;
}
}