CVE-2025-61634 Indirect redirect loops can cause a series of 307 redirects that overwhelm the servers. Caching the redirect allows this to resolve. Bug: T387478 Change-Id: Ibfde4e5ece3d58a1f573c37b46b568a5847fca01
204 lines
6.3 KiB
PHP
204 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace MediaWiki\Rest\Handler;
|
|
|
|
use LogicException;
|
|
use MediaWiki\Rest\Handler\Helper\HtmlOutputHelper;
|
|
use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper;
|
|
use MediaWiki\Rest\Handler\Helper\PageContentHelper;
|
|
use MediaWiki\Rest\Handler\Helper\PageRedirectHelper;
|
|
use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory;
|
|
use MediaWiki\Rest\LocalizedHttpException;
|
|
use MediaWiki\Rest\Response;
|
|
use MediaWiki\Rest\SimpleHandler;
|
|
use MediaWiki\Rest\StringStream;
|
|
use Wikimedia\Assert\Assert;
|
|
|
|
/**
|
|
* A handler that returns Parsoid HTML for the following routes:
|
|
* - /page/{title}/html,
|
|
* - /page/{title}/with_html
|
|
*
|
|
* @package MediaWiki\Rest\Handler
|
|
*/
|
|
class PageHTMLHandler extends SimpleHandler {
|
|
|
|
private HtmlOutputHelper $htmlHelper;
|
|
private PageContentHelper $contentHelper;
|
|
private PageRestHelperFactory $helperFactory;
|
|
|
|
public function __construct(
|
|
PageRestHelperFactory $helperFactory
|
|
) {
|
|
$this->contentHelper = $helperFactory->newPageContentHelper();
|
|
$this->helperFactory = $helperFactory;
|
|
}
|
|
|
|
private function getRedirectHelper(): PageRedirectHelper {
|
|
return $this->helperFactory->newPageRedirectHelper(
|
|
$this->getResponseFactory(),
|
|
$this->getRouter(),
|
|
$this->getPath(),
|
|
$this->getRequest()
|
|
);
|
|
}
|
|
|
|
protected function postValidationSetup() {
|
|
$authority = $this->getAuthority();
|
|
$this->contentHelper->init( $authority, $this->getValidatedParams() );
|
|
|
|
$page = $this->contentHelper->getPageIdentity();
|
|
$isSystemMessage = $this->contentHelper->useDefaultSystemMessage();
|
|
|
|
if ( $page ) {
|
|
if ( $isSystemMessage ) {
|
|
$this->htmlHelper = $this->helperFactory->newHtmlMessageOutputHelper( $page );
|
|
} else {
|
|
$revision = $this->contentHelper->getTargetRevision();
|
|
$this->htmlHelper = $this->helperFactory->newHtmlOutputRendererHelper(
|
|
$page, $this->getValidatedParams(), $authority, $revision
|
|
);
|
|
|
|
$request = $this->getRequest();
|
|
$acceptLanguage = $request->getHeaderLine( 'Accept-Language' ) ?: null;
|
|
if ( $acceptLanguage ) {
|
|
$this->htmlHelper->setVariantConversionLanguage(
|
|
$acceptLanguage
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return Response
|
|
* @throws LocalizedHttpException
|
|
*/
|
|
public function run(): Response {
|
|
$this->contentHelper->checkAccessPermission();
|
|
$page = $this->contentHelper->getPageIdentity();
|
|
$params = $this->getRequest()->getQueryParams();
|
|
|
|
if ( array_key_exists( 'redirect', $params ) ) {
|
|
$followWikiRedirects = $params['redirect'] !== 'no';
|
|
} else {
|
|
$followWikiRedirects = true;
|
|
}
|
|
|
|
// The call to $this->contentHelper->getPage() should not return null if
|
|
// $this->contentHelper->checkAccess() did not throw.
|
|
Assert::invariant( $page !== null, 'Page should be known' );
|
|
|
|
$redirectHelper = $this->getRedirectHelper();
|
|
$redirectHelper->setFollowWikiRedirects( $followWikiRedirects );
|
|
// Should treat variant redirects a special case as wiki redirects
|
|
// if ?redirect=no language variant should do nothing and fall into the 404 path
|
|
$redirectResponse = $redirectHelper->createRedirectResponseIfNeeded(
|
|
$page,
|
|
$this->contentHelper->getTitleText()
|
|
);
|
|
|
|
if ( $redirectResponse !== null ) {
|
|
$redirectResponse->setHeader( 'Cache-Control', 'max-age=60' );
|
|
return $redirectResponse;
|
|
}
|
|
|
|
// We could have a missing page at this point, check and return 404 if that's the case
|
|
$this->contentHelper->checkHasContent();
|
|
|
|
$parserOutput = $this->htmlHelper->getHtml();
|
|
$parserOutputHtml = $parserOutput->getRawText();
|
|
|
|
$outputMode = $this->getOutputMode();
|
|
switch ( $outputMode ) {
|
|
case 'html':
|
|
$response = $this->getResponseFactory()->create();
|
|
$this->contentHelper->setCacheControl( $response, $parserOutput->getCacheExpiry() );
|
|
$response->setBody( new StringStream( $parserOutputHtml ) );
|
|
break;
|
|
case 'with_html':
|
|
$body = $this->contentHelper->constructMetadata();
|
|
$body['html'] = $parserOutputHtml;
|
|
|
|
$redirectTargetUrl = $redirectHelper->getWikiRedirectTargetUrl( $page );
|
|
|
|
if ( $redirectTargetUrl ) {
|
|
$body['redirect_target'] = $redirectTargetUrl;
|
|
}
|
|
|
|
$response = $this->getResponseFactory()->createJson( $body );
|
|
$this->contentHelper->setCacheControl( $response, $parserOutput->getCacheExpiry() );
|
|
break;
|
|
default:
|
|
throw new LogicException( "Unknown HTML type $outputMode" );
|
|
}
|
|
|
|
$setContentLanguageHeader = ( $outputMode === 'html' );
|
|
$this->htmlHelper->putHeaders( $response, $setContentLanguageHeader );
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Returns an ETag representing a page's source. The ETag assumes a page's source has changed
|
|
* if the latest revision of a page has been made private, un-readable for another reason,
|
|
* or a newer revision exists.
|
|
* @return string|null
|
|
*/
|
|
protected function getETag(): ?string {
|
|
if ( !$this->contentHelper->isAccessible() || !$this->contentHelper->hasContent() ) {
|
|
return null;
|
|
}
|
|
|
|
// Vary eTag based on output mode
|
|
return $this->htmlHelper->getETag( $this->getOutputMode() );
|
|
}
|
|
|
|
/**
|
|
* @return string|null
|
|
*/
|
|
protected function getLastModified(): ?string {
|
|
if ( !$this->contentHelper->isAccessible() || !$this->contentHelper->hasContent() ) {
|
|
return null;
|
|
}
|
|
|
|
return $this->htmlHelper->getLastModified();
|
|
}
|
|
|
|
private function getOutputMode(): string {
|
|
return $this->getConfig()['format'];
|
|
}
|
|
|
|
public function needsWriteAccess(): bool {
|
|
return false;
|
|
}
|
|
|
|
public function getParamSettings(): array {
|
|
return array_merge(
|
|
$this->contentHelper->getParamSettings(),
|
|
// Note that postValidation we might end up using
|
|
// a HtmlMessageOutputHelper, but the param settings
|
|
// for that are a subset of those for HtmlOutputRendererHelper
|
|
HtmlOutputRendererHelper::getParamSettings()
|
|
);
|
|
}
|
|
|
|
protected function generateResponseSpec( string $method ): array {
|
|
$spec = parent::generateResponseSpec( $method );
|
|
|
|
// TODO: Consider if we prefer something like:
|
|
// text/html; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/HTML/2.8.0"
|
|
// That would be more specific, but fragile when the profile version changes. It could
|
|
// also be inaccurate if the page content was not in fact produced by Parsoid.
|
|
if ( $this->getOutputMode() == 'html' ) {
|
|
unset( $spec['200']['content']['application/json'] );
|
|
$spec['200']['content']['text/html']['schema']['type'] = 'string';
|
|
}
|
|
|
|
return $spec;
|
|
}
|
|
|
|
public function getResponseBodySchemaFileName( string $method ): ?string {
|
|
return 'includes/Rest/Handler/Schema/ExistingPageHtml.json';
|
|
}
|
|
}
|