Issue #2463753 by claudiu.cristea, acbramley, gg24, anairamzap, dhirendra.mishra, andypost, dawehner, swatichouhan012, smustgrave, quietone, Hardik_Patel_12, douggreen, mohrerao, gcb, kalistos, Wim Leers, pfrenssen, dww, larowlan: [regression] Do not bypass route access with 'link to any page' permissions for menu links

(cherry picked from commit fa1d8ce3bb)
merge-requests/6796/head
catch 2024-02-26 15:49:12 +00:00
parent a16476ec5e
commit 719f1684a9
10 changed files with 183 additions and 50 deletions

View File

@ -212,22 +212,14 @@ class DefaultMenuLinkTreeManipulators {
* The access result. * The access result.
*/ */
protected function menuLinkCheckAccess(MenuLinkInterface $instance) { protected function menuLinkCheckAccess(MenuLinkInterface $instance) {
$access_result = NULL; $url = $instance->getUrlObject();
if ($this->account->hasPermission('link to any page')) {
$access_result = AccessResult::allowed();
}
else {
$url = $instance->getUrlObject();
// When no route name is specified, this must be an external link. if ($url->isRouted()) {
if (!$url->isRouted()) { return $this->accessManager->checkNamedRoute($url->getRouteName(), $url->getRouteParameters(), $this->account, TRUE);
$access_result = AccessResult::allowed();
}
else {
$access_result = $this->accessManager->checkNamedRoute($url->getRouteName(), $url->getRouteParameters(), $this->account, TRUE);
}
} }
return $access_result->cachePerPermissions();
// Must be an external link.
return AccessResult::allowed();
} }
/** /**

View File

@ -0,0 +1,4 @@
services:
menu_ui.menu_tree_manipulators:
class: Drupal\menu_ui\Menu\MenuUiMenuTreeManipulators
Drupal\menu_ui\Menu\MenuUiMenuTreeManipulators: '@menu_ui.menu_tree_manipulators'

View File

@ -0,0 +1,49 @@
<?php
namespace Drupal\menu_ui\Menu;
use Drupal\Core\Access\AccessResult;
/**
* Provides menu tree manipulators to be used when managing menu links.
*/
class MenuUiMenuTreeManipulators {
/**
* Grants access to a menu tree when used in the menu management form.
*
* This manipulator allows access to menu links with inaccessible routes.
*
* Example use cases:
* - A login menu link, using the `user.login` route, is not accessible to a
* logged-in user, but the site builder still needs to configure the menu
* link.
* - A site builder wants to create a menu item for a Views page that has not
* been created. In this case, there is no access to the route because it
* does not exist.
*
* @param \Drupal\Core\Menu\MenuLinkTreeElement[] $tree
* The menu link tree to manipulate.
*
* @return \Drupal\Core\Menu\MenuLinkTreeElement[]
* The manipulated menu link tree.
*
* @internal
* This menu tree manipulator is intended for use only in the context of
* MenuForm because the user permissions to administer links is already
* checked. Don't use this manipulator in other places.
*
* @see \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::checkAccess()
* @see \Drupal\menu_ui\MenuForm
*/
public function checkAccess(array $tree): array {
foreach ($tree as $element) {
$element->access = AccessResult::allowed();
if ($element->subtree) {
$element->subtree = $this->checkAccess($element->subtree);
}
}
return $tree;
}
}

View File

@ -231,7 +231,13 @@ class MenuForm extends EntityForm {
// We indicate that a menu administrator is running the menu access check. // We indicate that a menu administrator is running the menu access check.
$this->getRequest()->attributes->set('_menu_admin', TRUE); $this->getRequest()->attributes->set('_menu_admin', TRUE);
$manipulators = [ $manipulators = [
['callable' => 'menu.default_tree_manipulators:checkAccess'], // Use a dedicated menu tree access check manipulator as users editing
// this form, granted with 'administer menu' permission, should be able to
// access menu links with inaccessible routes. The default menu tree
// manipulator only allows the access to menu links with accessible routes.
// @see \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::checkAccess()
// @see \Drupal\menu_ui\Menu\MenuUiMenuTreeManipulators::checkAccess()
['callable' => 'menu_ui.menu_tree_manipulators:checkAccess'],
['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'], ['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'],
]; ];
$tree = $this->menuTree->transform($tree, $manipulators); $tree = $this->menuTree->transform($tree, $manipulators);

View File

@ -727,10 +727,15 @@ class MenuUiTest extends BrowserTestBase {
$this->drupalLogout(); $this->drupalLogout();
$this->drupalLogin($this->adminUser); $this->drupalLogin($this->adminUser);
$this->drupalGet('admin/structure/menu/manage/' . $item->getMenuName()); $this->drupalGet('admin/structure/menu/manage/' . $item->getMenuName());
$this->assertSession()->pageTextNotContains($item->getTitle()); $this->assertSession()->linkExists($item->getTitle());
// The cache contexts associated with the (in)accessible menu links are // The cache contexts associated with the (in)accessible menu links are
// bubbled. See DefaultMenuLinkTreeManipulators::menuLinkCheckAccess(). // bubbled. See DefaultMenuLinkTreeManipulators::menuLinkCheckAccess().
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions'); $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
// The menu link admin is able to administer the link but cannot access the
// route as is not granted with 'bypass node access' permission.
$this->clickLink($item->getTitle());
$this->assertSession()->statusCodeEquals(403);
} }
/** /**
@ -1229,4 +1234,47 @@ class MenuUiTest extends BrowserTestBase {
$this->assertSession()->elementNotExists('xpath', '//div[contains(@class, "messages--error")]'); $this->assertSession()->elementNotExists('xpath', '//div[contains(@class, "messages--error")]');
} }
/**
* Tests the user login/logout links.
*/
public function testUserLoginUserLogoutLinks() {
MenuLinkContent::create([
'menu' => 'tools',
'link' => [
'uri' => 'internal:/user/login',
],
'title' => 'Login',
])->save();
MenuLinkContent::create([
'menu' => 'tools',
'link' => [
'uri' => 'internal:/user/logout',
],
'title' => 'Logout',
])->save();
$assert = $this->assertSession();
$block = $this->drupalPlaceBlock('system_menu_block:tools');
$this->drupalGet('<front>');
$assert->linkExists('Login');
$assert->linkNotExists('Logout');
$this->drupalLogin($this->createUser());
$this->drupalGet('<front>');
$assert->linkNotExists('Login');
$assert->linkExists('Logout');
// Delete the block, we're now checking the Menu UI form.
$block->delete();
$this->drupalLogin($this->createUser(['administer menu']));
$this->drupalGet('admin/structure/menu/manage/tools');
$assert->linkExists('Logout');
// Check that the login link is accessible even the route is not.
$this->assertFalse(Url::fromRoute('user.login')->access($this->loggedInUser));
$assert->linkExists('Login');
}
} }

View File

@ -280,12 +280,10 @@ final class LinksetControllerTest extends LinksetControllerTestBase {
$this->enableEndpoint(TRUE); $this->enableEndpoint(TRUE);
$expected_cacheability = new CacheableMetadata(); $expected_cacheability = new CacheableMetadata();
$expected_cacheability->addCacheContexts([ $expected_cacheability->addCacheContexts([
'user.permissions',
'user.roles:authenticated', 'user.roles:authenticated',
]); ]);
$expected_cacheability->addCacheTags([ $expected_cacheability->addCacheTags([
'config:system.menu.account', 'config:system.menu.account',
'config:user.role.anonymous',
'http_response', 'http_response',
]); ]);
$response = $this->doRequest('GET', Url::fromUri('base:/system/menu/account/linkset')); $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/account/linkset'));

View File

@ -65,7 +65,14 @@ class AdminTest extends BrowserTestBase {
// Verify that all visible, top-level administration links are listed on // Verify that all visible, top-level administration links are listed on
// the main administration page. // the main administration page.
foreach ($this->getTopLevelMenuLinks() as $item) { foreach ($this->getTopLevelMenuLinks() as $element) {
$item = $element->link;
if (!$element->access->isAllowed()) {
// If the link is not accessible, it should not be rendered.
// @see \Drupal\Core\Menu\MenuLinkTree::buildItems().
$this->assertSession()->linkNotExists($item->getTitle());
continue;
}
$this->assertSession()->linkExists($item->getTitle()); $this->assertSession()->linkExists($item->getTitle());
$this->assertSession()->linkByHrefExists($item->getUrlObject()->toString()); $this->assertSession()->linkByHrefExists($item->getUrlObject()->toString());
// The description should appear below the link. // The description should appear below the link.
@ -124,7 +131,7 @@ class AdminTest extends BrowserTestBase {
/** /**
* Returns all top level menu links. * Returns all top level menu links.
* *
* @return \Drupal\Core\Menu\MenuLinkInterface[] * @return \Drupal\Core\Menu\MenuLinkTreeElement[]
*/ */
protected function getTopLevelMenuLinks() { protected function getTopLevelMenuLinks() {
$menu_tree = \Drupal::menuTree(); $menu_tree = \Drupal::menuTree();
@ -137,15 +144,7 @@ class AdminTest extends BrowserTestBase {
['callable' => 'menu.default_tree_manipulators:checkAccess'], ['callable' => 'menu.default_tree_manipulators:checkAccess'],
['callable' => 'menu.default_tree_manipulators:flatten'], ['callable' => 'menu.default_tree_manipulators:flatten'],
]; ];
$tree = $menu_tree->transform($tree, $manipulators); return $menu_tree->transform($tree, $manipulators);
// Transform the tree to a list of menu links.
$menu_links = [];
foreach ($tree as $element) {
$menu_links[] = $element->link;
}
return $menu_links;
} }
/** /**

View File

@ -217,6 +217,7 @@ class DisplayPathTest extends UITestBase {
'administer menu', 'administer menu',
'link to any page', 'link to any page',
'access toolbar', 'access toolbar',
'access administration pages',
]); ]);
$this->drupalLogin($admin_user); $this->drupalLogin($admin_user);

View File

@ -2,8 +2,11 @@
namespace Drupal\KernelTests\Core\Menu; namespace Drupal\KernelTests\Core\Menu;
use Drupal\Core\Menu\InaccessibleMenuLink;
use Drupal\Core\Menu\MenuLinkTreeElement; use Drupal\Core\Menu\MenuLinkTreeElement;
use Drupal\Core\Menu\MenuTreeParameters; use Drupal\Core\Menu\MenuTreeParameters;
use Drupal\Core\Session\AnonymousUserSession;
use Drupal\Core\Session\UserSession;
use Drupal\KernelTests\KernelTestBase; use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\Core\Menu\MenuLinkMock; use Drupal\Tests\Core\Menu\MenuLinkMock;
@ -133,4 +136,42 @@ class MenuLinkTreeTest extends KernelTestBase {
$this->assertEquals(3, $height); $this->assertEquals(3, $height);
} }
/**
* Tests user/login and user/logout links.
*/
public function testUserLoginAndUserLogoutLinks() {
$account_switcher = $this->container->get('account_switcher');
$login_menu_link = MenuLinkMock::create(['id' => 'user_login_example', 'route_name' => 'user.login']);
$logout_menu_link = MenuLinkMock::create(['id' => 'user_logout_example', 'route_name' => 'user.logout']);
$this->menuLinkManager->addDefinition($login_menu_link->getPluginId(), $login_menu_link->getPluginDefinition());
$this->menuLinkManager->addDefinition($logout_menu_link->getPluginId(), $logout_menu_link->getPluginDefinition());
// Returns the accessible links from transformed 'mock' menu tree.
$get_accessible_links = function () {
$parameters = new MenuTreeParameters();
$manipulators = [
['callable' => 'menu.default_tree_manipulators:checkAccess'],
];
$tree = $this->linkTree->load('mock', $parameters);
$this->linkTree->transform($tree, $manipulators);
return array_keys(
array_filter($tree, function (MenuLinkTreeElement $element) {
return !$element->link instanceof InaccessibleMenuLink;
})
);
};
// Check that anonymous can only access the login link.
$account_switcher->switchTo(new AnonymousUserSession());
$this->assertSame(['user_login_example'], $get_accessible_links());
// Ensure that also user 1 does not see the login link.
$account_switcher->switchTo(new UserSession(['uid' => 1]));
$this->assertSame(['user_logout_example'], $get_accessible_links());
}
} }

View File

@ -207,15 +207,12 @@ class DefaultMenuLinkTreeManipulatorsTest extends UnitTestCase {
$this->cacheContextManager->assertValidTokens(['user'])->shouldBeCalled()->willReturn(TRUE); $this->cacheContextManager->assertValidTokens(['user'])->shouldBeCalled()->willReturn(TRUE);
$this->originalTree[8]->access = AccessResult::allowed()->cachePerUser(); $this->originalTree[8]->access = AccessResult::allowed()->cachePerUser();
// Since \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::checkAccess()
// allows access to any link if the user has the 'link to any page'
// permission, *every* single access result is varied by permissions.
$tree = $this->defaultMenuTreeManipulators->checkAccess($this->originalTree); $tree = $this->defaultMenuTreeManipulators->checkAccess($this->originalTree);
// Menu link 1: route without parameters, access forbidden, but at level 0, // Menu link 1: route without parameters, access forbidden, but at level 0,
// hence kept. // hence kept.
$element = $tree[1]; $element = $tree[1];
$this->assertEquals(AccessResult::forbidden()->cachePerPermissions(), $element->access); $this->assertEquals(AccessResult::forbidden(), $element->access);
$this->assertInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); $this->assertInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link);
// Menu link 2: route with parameters, access granted. // Menu link 2: route with parameters, access granted.
$element = $tree[2]; $element = $tree[2];
@ -223,21 +220,19 @@ class DefaultMenuLinkTreeManipulatorsTest extends UnitTestCase {
$this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); $this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link);
// Menu link 3: route with parameters, AccessResult::neutral(), top-level // Menu link 3: route with parameters, AccessResult::neutral(), top-level
// inaccessible link, hence kept for its cacheability metadata. // inaccessible link, hence kept for its cacheability metadata.
// Note that the permissions cache context is added automatically, because
// we always check the "link to any page" permission.
$element = $tree[2]->subtree[3]; $element = $tree[2]->subtree[3];
$this->assertEquals(AccessResult::neutral()->cachePerPermissions(), $element->access); $this->assertEquals(AccessResult::neutral(), $element->access);
$this->assertInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); $this->assertInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link);
// Menu link 4: child of menu link 3, which was AccessResult::neutral(), // Menu link 4: child of menu link 3, which was AccessResult::neutral(),
// hence menu link 3's subtree is removed, of which this menu link is one. // hence menu link 3's subtree is removed, of which this menu link is one.
$this->assertArrayNotHasKey(4, $tree[2]->subtree[3]->subtree); $this->assertArrayNotHasKey(4, $tree[2]->subtree[3]->subtree);
// Menu link 5: no route name, treated as external, hence access granted. // Menu link 5: no route name, treated as external, hence access granted.
$element = $tree[5]; $element = $tree[5];
$this->assertEquals(AccessResult::allowed()->cachePerPermissions(), $element->access); $this->assertEquals(AccessResult::allowed(), $element->access);
$this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); $this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link);
// Menu link 6: external URL, hence access granted. // Menu link 6: external URL, hence access granted.
$element = $tree[6]; $element = $tree[6];
$this->assertEquals(AccessResult::allowed()->cachePerPermissions(), $element->access); $this->assertEquals(AccessResult::allowed(), $element->access);
$this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); $this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link);
// Menu link 7: 'access' already set: AccessResult::neutral(), top-level // Menu link 7: 'access' already set: AccessResult::neutral(), top-level
// inaccessible link, hence kept for its cacheability metadata. // inaccessible link, hence kept for its cacheability metadata.
@ -247,31 +242,31 @@ class DefaultMenuLinkTreeManipulatorsTest extends UnitTestCase {
$element = $tree[5]->subtree[7]; $element = $tree[5]->subtree[7];
$this->assertEquals(AccessResult::neutral(), $element->access); $this->assertEquals(AccessResult::neutral(), $element->access);
$this->assertInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); $this->assertInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link);
// Menu link 8: 'access' already set, note that 'per permissions' caching // Menu link 8: 'access' already set.
// is not added.
$element = $tree[8]; $element = $tree[8];
$this->assertEquals(AccessResult::allowed()->cachePerUser(), $element->access); $this->assertEquals(AccessResult::allowed()->cachePerUser(), $element->access);
$this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); $this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link);
} }
/** /**
* Tests checkAccess() tree manipulator with 'link to any page' permission. * Tests checkAccess() tree manipulator.
* *
* @covers ::checkAccess * @covers ::checkAccess
* @covers ::menuLinkCheckAccess * @covers ::menuLinkCheckAccess
*/ */
public function testCheckAccessWithLinkToAnyPagePermission() { public function testCheckAccessTreeManipulator(): void {
$this->mockTree(); $this->mockTree();
$this->currentUser->expects($this->exactly(9)) // There are 9 checks but one is on an external link, so the route access
->method('hasPermission') // checker should be called only 8 times.
->with('link to any page') // @see \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::menuLinkCheckAccess()
->willReturn(TRUE); $this->accessManager->expects($this->exactly(8))
->method('checkNamedRoute')
->willReturn(AccessResult::allowed());
$this->mockTree(); $this->mockTree();
$this->cacheContextManager->assertValidTokens(['user.permissions'])->shouldBeCalled()->willReturn(TRUE);
$this->defaultMenuTreeManipulators->checkAccess($this->originalTree); $this->defaultMenuTreeManipulators->checkAccess($this->originalTree);
$expected_access_result = AccessResult::allowed()->cachePerPermissions(); $expected_access_result = AccessResult::allowed();
$this->assertEquals($expected_access_result, $this->originalTree[1]->access); $this->assertEquals($expected_access_result, $this->originalTree[1]->access);
$this->assertEquals($expected_access_result, $this->originalTree[2]->access); $this->assertEquals($expected_access_result, $this->originalTree[2]->access);
$this->assertEquals($expected_access_result, $this->originalTree[2]->subtree[3]->access); $this->assertEquals($expected_access_result, $this->originalTree[2]->subtree[3]->access);
@ -366,8 +361,8 @@ class DefaultMenuLinkTreeManipulatorsTest extends UnitTestCase {
$this->assertEquals($node_access_result, $tree[1]->access); $this->assertEquals($node_access_result, $tree[1]->access);
$this->assertEquals($node_access_result, $tree[2]->access); $this->assertEquals($node_access_result, $tree[2]->access);
$this->assertEquals(AccessResult::neutral(), $tree[2]->subtree[3]->access); $this->assertEquals(AccessResult::neutral(), $tree[2]->subtree[3]->access);
$this->assertEquals(AccessResult::allowed()->cachePerPermissions(), $tree[5]->access); $this->assertEquals(AccessResult::allowed(), $tree[5]->access);
$this->assertEquals(AccessResult::neutral()->cachePerPermissions(), $tree[5]->subtree[6]->access); $this->assertEquals(AccessResult::neutral(), $tree[5]->subtree[6]->access);
} }
/** /**