From 01f9643e6cbebf2b4408571db5c28e8a2a994c50 Mon Sep 17 00:00:00 2001 From: Lucas Werkmeister Date: Tue, 25 Aug 2020 15:29:33 +0200 Subject: [PATCH] SECURITY: Add EntityDataPurger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This subscribes to the ArticleRevisionVisibilitySet hook and purges affected Special:EntityData URLs from the web cache. For simplicity, it does this regardless of whether the visibility change affected the cached data (currently, the cached data does not include the user name or comment associated with the revision, but who knows, maybe that’ll change in the future). This is implemented as a second hook handler, independent of the already existing ArticleRevisionVisibilitySetHookHandler, because the two do very different things and would share almost no code even if they were merged into one class. Bug: T260349 Change-Id: Ief8aba38205187fe969e8d87471919b2b258c615 Co-Authored-By: Itamar Givon --- extension-repo.json | 12 ++- repo/includes/Hooks/EntityDataPurger.php | 71 ++++++++++++++ .../includes/Hooks/EntityDataPurgerTest.php | 98 +++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 repo/includes/Hooks/EntityDataPurger.php create mode 100644 repo/tests/phpunit/includes/Hooks/EntityDataPurgerTest.php diff --git a/extension-repo.json b/extension-repo.json index 7f2102c0a3..061cd1f3be 100644 --- a/extension-repo.json +++ b/extension-repo.json @@ -448,6 +448,13 @@ "class": "\\Wikibase\\Repo\\Hooks\\DeleteDispatcher", "factory": "\\Wikibase\\Repo\\Hooks\\DeleteDispatcher::factory" }, + "EntityDataPurger": { + "class": "\\Wikibase\\Repo\\Hooks\\EntityDataPurger", + "factory": "\\Wikibase\\Repo\\Hooks\\EntityDataPurger::factory", + "services": [ + "HtmlCacheUpdater" + ] + }, "FederatedPropertiesSpecialPage": { "class": "\\Wikibase\\Repo\\Hooks\\FederatedPropertiesSpecialPageHookHandler", "factory": "\\Wikibase\\Repo\\Hooks\\FederatedPropertiesSpecialPageHookHandler::factory" @@ -498,7 +505,10 @@ "DeleteDispatcher", "\\Wikibase\\Repo\\RepoHooks::onArticleDeleteComplete" ], - "ArticleRevisionVisibilitySet": "ArticleRevisionVisibilitySet", + "ArticleRevisionVisibilitySet": [ + "ArticleRevisionVisibilitySet", + "EntityDataPurger" + ], "ArticleUndelete": "\\Wikibase\\Repo\\RepoHooks::onArticleUndelete", "BeforeDisplayNoArticleText": "\\Wikibase\\Repo\\Actions\\ViewEntityAction::onBeforeDisplayNoArticleText", "BeforePageDisplay": "\\Wikibase\\Repo\\RepoHooks::onBeforePageDisplay", diff --git a/repo/includes/Hooks/EntityDataPurger.php b/repo/includes/Hooks/EntityDataPurger.php new file mode 100644 index 0000000000..aaa63ed94a --- /dev/null +++ b/repo/includes/Hooks/EntityDataPurger.php @@ -0,0 +1,71 @@ +entityIdLookup = $entityIdLookup; + $this->entityDataUriManager = $entityDataUriManager; + $this->htmlCacheUpdater = $htmlCacheUpdater; + } + + public static function factory( HtmlCacheUpdater $htmlCacheUpdater ): self { + $wikibaseRepo = WikibaseRepo::getDefaultInstance(); + return new self( + $wikibaseRepo->getEntityIdLookup(), + $wikibaseRepo->getEntityDataUriManager(), + $htmlCacheUpdater + ); + } + + /** + * @param Title $title + * @param int[] $ids + * @param int[][] $visibilityChangeMap + */ + public function onArticleRevisionVisibilitySet( $title, $ids, $visibilityChangeMap ): void { + $entityId = $this->entityIdLookup->getEntityIdForTitle( $title ); + if ( !$entityId ) { + return; + } + + $urls = []; + foreach ( $ids as $revisionId ) { + $urls = array_merge( $urls, $this->entityDataUriManager->getPotentiallyCachedUrls( + $entityId, + // $ids should be int[] but MediaWiki may call with a string[], so cast to int + (int)$revisionId + ) ); + } + if ( $urls !== [] ) { + $this->htmlCacheUpdater->purgeUrls( $urls ); + } + } + +} diff --git a/repo/tests/phpunit/includes/Hooks/EntityDataPurgerTest.php b/repo/tests/phpunit/includes/Hooks/EntityDataPurgerTest.php new file mode 100644 index 0000000000..a58766c619 --- /dev/null +++ b/repo/tests/phpunit/includes/Hooks/EntityDataPurgerTest.php @@ -0,0 +1,98 @@ +createMock( EntityIdLookup::class ); + $entityIdLookup->expects( $this->once() ) + ->method( 'getEntityIdForTitle' ) + ->with( $title ) + ->willReturn( null ); + $entityDataUriManager = $this->createMock( EntityDataUriManager::class ); + $entityDataUriManager->expects( $this->never() ) + ->method( 'getPotentiallyCachedUrls' ); + $htmlCacheUpdater = $this->createMock( HtmlCacheUpdater::class ); + $htmlCacheUpdater->expects( $this->never() ) + ->method( 'purgeUrls' ); + $purger = new EntityDataPurger( $entityIdLookup, $entityDataUriManager, $htmlCacheUpdater ); + + $purger->onArticleRevisionVisibilitySet( $title, [ 1, 2, 3 ], [] ); + } + + public function testGivenEntityIdLookupReturnsId_handlerPurgesCache() { + $title = Title::newFromText( 'Item:Q1' ); + $entityId = new ItemId( 'Q1' ); + $entityIdLookup = $this->createMock( EntityIdLookup::class ); + $entityIdLookup->expects( $this->once() ) + ->method( 'getEntityIdForTitle' ) + ->with( $title ) + ->willReturn( $entityId ); + $entityDataUriManager = $this->createMock( EntityDataUriManager::class ); + $entityDataUriManager->expects( $this->once() ) + ->method( 'getPotentiallyCachedUrls' ) + ->with( $entityId, 1 ) + ->willReturn( [ 'urlA/Q1/1', 'urlB/Q1/1' ] ); + $htmlCacheUpdater = $this->createMock( HtmlCacheUpdater::class ); + $htmlCacheUpdater->expects( $this->once() ) + ->method( 'purgeUrls' ) + ->with( [ 'urlA/Q1/1', 'urlB/Q1/1' ] ); + $purger = new EntityDataPurger( $entityIdLookup, $entityDataUriManager, $htmlCacheUpdater ); + + $purger->onArticleRevisionVisibilitySet( $title, [ 1 ], [] ); + } + + public function testGivenMultipleRevisions_handlerPurgesCacheOnce() { + $title = Title::newFromText( 'Item:Q1' ); + $entityId = new ItemId( 'Q1' ); + $entityIdLookup = $this->createMock( EntityIdLookup::class ); + $entityIdLookup->expects( $this->once() ) + ->method( 'getEntityIdForTitle' ) + ->with( $title ) + ->willReturn( $entityId ); + $entityDataUriManager = $this->createMock( EntityDataUriManager::class ); + $entityDataUriManager + ->method( 'getPotentiallyCachedUrls' ) + ->withConsecutive( + [ $entityId, 1 ], + [ $entityId, 2 ], + [ $entityId, 3 ] + ) + ->willReturnOnConsecutiveCalls( + [ 'urlA/Q1/1', 'urlB/Q1/1' ], + [ 'urlA/Q1/2', 'urlB/Q1/2' ], + [ 'urlA/Q1/3', 'urlB/Q1/3' ] + ); + $htmlCacheUpdater = $this->createMock( HtmlCacheUpdater::class ); + $htmlCacheUpdater->expects( $this->once() ) + ->method( 'purgeUrls' ) + ->with( [ + 'urlA/Q1/1', 'urlB/Q1/1', + 'urlA/Q1/2', 'urlB/Q1/2', + 'urlA/Q1/3', 'urlB/Q1/3', + ] ); + $purger = new EntityDataPurger( $entityIdLookup, $entityDataUriManager, $htmlCacheUpdater ); + + $purger->onArticleRevisionVisibilitySet( $title, [ 1, 2, 3 ], [] ); + } +} -- 2.25.1