diff --git a/core/misc/drupal.es6.js b/core/misc/drupal.es6.js index 48f0510346d016fcb24ba66b98b72add7eea8e3e..546527a34c6bf90dc09e17ae53a47d95afeaa916 100644 --- a/core/misc/drupal.es6.js +++ b/core/misc/drupal.es6.js @@ -239,9 +239,10 @@ window.Drupal = { behaviors: {}, locale: {} }; Drupal.checkPlain = function (str) { str = str.toString() .replace(/&/g, '&') - .replace(/"/g, '"') .replace(//g, '>'); + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); return str; }; diff --git a/core/misc/drupal.js b/core/misc/drupal.js index e080caae65afc993d12e8051e18fcfeb59761773..39a74e4f51863ef988be9b8bc987ae4b4798aa6a 100644 --- a/core/misc/drupal.js +++ b/core/misc/drupal.js @@ -48,7 +48,7 @@ window.Drupal = { behaviors: {}, locale: {} }; }; Drupal.checkPlain = function (str) { - str = str.toString().replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + str = str.toString().replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); return str; }; diff --git a/core/modules/comment/src/Controller/CommentController.php b/core/modules/comment/src/Controller/CommentController.php index a01106d77f611c6614eab9f22295bd1d5978a4fc..560233889cccc69f6e042f56a2d2ff927d2fd846 100644 --- a/core/modules/comment/src/Controller/CommentController.php +++ b/core/modules/comment/src/Controller/CommentController.php @@ -279,9 +279,12 @@ public function replyFormAccess(EntityInterface $entity, $field_name, $pid = NUL // Check if the user has the proper permissions. $access = AccessResult::allowedIfHasPermission($account, 'post comments'); + // If commenting is open on the entity. $status = $entity->{$field_name}->status; $access = $access->andIf(AccessResult::allowedIf($status == CommentItemInterface::OPEN) - ->addCacheableDependency($entity)); + ->addCacheableDependency($entity)) + // And if user has access to the host entity. + ->andIf(AccessResult::allowedIf($entity->access('view'))); // $pid indicates that this is a reply to a comment. if ($pid) { diff --git a/core/modules/comment/tests/src/Functional/CommentAccessTest.php b/core/modules/comment/tests/src/Functional/CommentAccessTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4094ec6130e7505bb271240c8c6436633ba48e28 --- /dev/null +++ b/core/modules/comment/tests/src/Functional/CommentAccessTest.php @@ -0,0 +1,120 @@ + 'article', + 'name' => 'Article', + ]); + $node_type->save(); + $node_author = $this->drupalCreateUser([ + 'create article content', + 'access comments', + ]); + + $this->drupalLogin($this->drupalCreateUser([ + 'edit own comments', + 'skip comment approval', + 'post comments', + 'access comments', + 'access content', + ])); + + $this->addDefaultCommentField('node', 'article'); + $this->unpublishedNode = $this->createNode([ + 'title' => 'This is unpublished', + 'uid' => $node_author->id(), + 'status' => 0, + 'type' => 'article', + ]); + $this->unpublishedNode->save(); + } + + /** + * Tests commenting disabled for access-blocked entities. + */ + public function testCannotCommentOnEntitiesYouCannotView() { + $assert = $this->assertSession(); + + $comment_url = 'comment/reply/node/' . $this->unpublishedNode->id() . '/comment'; + + // Commenting on an unpublished node results in access denied. + $this->drupalGet($comment_url); + $assert->statusCodeEquals(403); + + // Publishing the node grants access. + $this->unpublishedNode->setPublished(TRUE)->save(); + $this->drupalGet($comment_url); + $assert->statusCodeEquals(200); + } + + /** + * Tests cannot view comment reply form on entities you cannot view. + */ + public function testCannotViewCommentReplyFormOnEntitiesYouCannotView() { + $assert = $this->assertSession(); + + // Create a comment on an unpublished node. + $comment = Comment::create([ + 'entity_type' => 'node', + 'name' => 'Tony', + 'hostname' => 'magic.example.com', + 'mail' => 'foo@example.com', + 'subject' => 'Comment on unpublished node', + 'entity_id' => $this->unpublishedNode->id(), + 'comment_type' => 'comment', + 'field_name' => 'comment', + 'pid' => 0, + 'uid' => $this->unpublishedNode->getOwnerId(), + 'status' => 1, + ]); + $comment->save(); + + $comment_url = 'comment/reply/node/' . $this->unpublishedNode->id() . '/comment/' . $comment->id(); + + // Replying to a comment on an unpublished node results in access denied. + $this->drupalGet($comment_url); + $assert->statusCodeEquals(403); + + // Publishing the node grants access. + $this->unpublishedNode->setPublished(TRUE)->save(); + $this->drupalGet($comment_url); + $assert->statusCodeEquals(200); + } + +} diff --git a/core/modules/comment/tests/src/Functional/CommentNonNodeTest.php b/core/modules/comment/tests/src/Functional/CommentNonNodeTest.php index 5889d69deefc535366cd5fae0c965199315bc7d7..7580bb20307ae3a25607cfaa4a4664a5632940fa 100644 --- a/core/modules/comment/tests/src/Functional/CommentNonNodeTest.php +++ b/core/modules/comment/tests/src/Functional/CommentNonNodeTest.php @@ -450,6 +450,7 @@ public function testCommentFunctionality() { 'post comments', 'administer comment fields', 'administer comment types', + 'view test entity', ]); $this->drupalLogin($limited_user); diff --git a/core/modules/comment/tests/src/Functional/CommentTokenReplaceTest.php b/core/modules/comment/tests/src/Functional/CommentTokenReplaceTest.php index a7fe6b5e072942f5daa48ecadd29eb6cc2775df5..72a941f9c5a454e52a7389f97553743671f9231e 100644 --- a/core/modules/comment/tests/src/Functional/CommentTokenReplaceTest.php +++ b/core/modules/comment/tests/src/Functional/CommentTokenReplaceTest.php @@ -144,6 +144,7 @@ public function testCommentTokenReplacement() { // Create a user and a comment. $user = User::create(['name' => 'alice']); + $user->activate(); $user->save(); $this->postComment($user, 'user body', 'user subject', TRUE); diff --git a/core/modules/node/node.install b/core/modules/node/node.install index 6469245c49bc47f484b4599936724c7a2e361e85..9b0cda8e56777fd4731613fc12f85334340a743e 100644 --- a/core/modules/node/node.install +++ b/core/modules/node/node.install @@ -255,3 +255,15 @@ function node_update_8400() { $schema['fields']['realm']['description'] = 'The realm in which the user must possess the grant ID. Modules can define one or more realms by implementing hook_node_grants().'; Database::getConnection()->schema()->changeField('node_access', 'realm', 'realm', $schema['fields']['realm']); } + +/** + * Run a node access rebuild, if required. + */ +function node_update_8401() { + // Get the list of node access modules. + $modules = \Drupal::moduleHandler()->getImplementations('node_grants'); + // If multilingual usage, then rebuild node access. + if (count($modules) > 0 && \Drupal::languageManager()->isMultilingual()) { + node_access_needs_rebuild(TRUE); + } +} diff --git a/core/modules/node/src/NodeGrantDatabaseStorage.php b/core/modules/node/src/NodeGrantDatabaseStorage.php index f6ae1f6c1343982a8aa06b240038002722d28997..b4947c9f123e4b46acb36b5b26d4efee5363d91d 100644 --- a/core/modules/node/src/NodeGrantDatabaseStorage.php +++ b/core/modules/node/src/NodeGrantDatabaseStorage.php @@ -211,6 +211,7 @@ public function write(NodeInterface $node, array $grants, $realm = NULL, $delete $query = $this->database->insert('node_access')->fields(['nid', 'langcode', 'fallback', 'realm', 'gid', 'grant_view', 'grant_update', 'grant_delete']); // If we have defined a granted langcode, use it. But if not, add a grant // for every language this node is translated to. + $fallback_langcode = $node->getUntranslated()->language()->getId(); foreach ($grants as $grant) { if ($realm && $realm != $grant['realm']) { continue; @@ -227,7 +228,7 @@ public function write(NodeInterface $node, array $grants, $realm = NULL, $delete $grant['nid'] = $node->id(); $grant['langcode'] = $grant_langcode; // The record with the original langcode is used as the fallback. - if ($grant['langcode'] == $node->language()->getId()) { + if ($grant['langcode'] == $fallback_langcode) { $grant['fallback'] = 1; } else { diff --git a/core/modules/node/tests/src/Functional/NodeAccessLanguageFallbackTest.php b/core/modules/node/tests/src/Functional/NodeAccessLanguageFallbackTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4beacc34235aea041b7735a6baa93d0f12b53755 --- /dev/null +++ b/core/modules/node/tests/src/Functional/NodeAccessLanguageFallbackTest.php @@ -0,0 +1,131 @@ +save(); + ConfigurableLanguage::createFromLangcode('ca')->save(); + ConfigurableLanguage::createFromLangcode('af')->save(); + + // Enable content translation for the current entity type. + \Drupal::service('content_translation.manager')->setEnabled('node', 'page', TRUE); + } + + /** + * Tests node access fallback handling with multiple node languages. + */ + public function testNodeAccessLanguageFallback() { + // The node_access_test module allows nodes to be marked private. We need to + // ensure that system honors the fallback system of node access properly. + // Note that node_access_test_language is language-sensitive and does not + // apply to the fallback test. + + // Create one node in Hungarian and marked as private. + $node = $this->drupalCreateNode([ + 'body' => [[]], + 'langcode' => 'hu', + 'private' => [['value' => 1]], + 'status' => 1, + ]); + + // There should be one entry in node_access, with fallback set to hu. + $this->checkRecords(1, 'hu'); + + // Create a translation user. + $admin = $this->drupalCreateUser([ + 'bypass node access', + 'administer nodes', + 'translate any entity', + 'administer content translation', + ]); + $this->drupalLogin($admin); + $this->drupalGet('node/' . $node->id() . '/translations'); + $this->assertSession()->statusCodeEquals(200); + + // Create a Catalan translation through the UI. + $url_options = ['language' => \Drupal::languageManager()->getLanguage('ca')]; + $this->drupalGet('node/' . $node->id() . '/translations/add/hu/ca', $url_options); + $this->assertSession()->statusCodeEquals(200); + // Save the form. + $this->getSession()->getPage()->pressButton('Save (this translation)'); + $this->assertSession()->statusCodeEquals(200); + + // Check the node access table. + $this->checkRecords(2, 'hu'); + + // Programmatically create a translation. This process lets us check that + // both forms and code behave in the same way. + $storage = \Drupal::entityTypeManager()->getStorage('node'); + // Reload the node. + $node = $storage->load(1); + // Create an Afrikaans translation. + $translation = $node->addTranslation('af'); + $translation->title->value = $this->randomString(); + $translation->status = 1; + $node->save(); + + // Check the node access table. + $this->checkRecords(3, 'hu'); + + // For completeness, edit the Catalan version again. + $this->drupalGet('node/' . $node->id() . '/edit', $url_options); + $this->assertSession()->statusCodeEquals(200); + // Save the form. + $this->getSession()->getPage()->pressButton('Save (this translation)'); + $this->assertSession()->statusCodeEquals(200); + // Check the node access table. + $this->checkRecords(3, 'hu'); + } + + /** + * Queries the node_access table and checks for proper storage. + * + * @param int $count + * The number of rows expected by the query (equal to the translation + * count). + * @param $langcode + * The expected language code set as the fallback property. + */ + public function checkRecords($count, $langcode = 'hu') { + $select = \Drupal::database() + ->select('node_access', 'na') + ->fields('na', ['nid', 'fallback', 'langcode', 'grant_view']) + ->condition('na.realm', 'node_access_test', '=') + ->condition('na.gid', 8888, '='); + $records = $select->execute()->fetchAll(); + // Check that the expected record count is returned. + $this->assertEquals(count($records), $count); + // The fallback value is 'hu' and should be set to 1. For other languages, + // it should be set to 0. Casting to boolean lets us run that comparison. + foreach ($records as $record) { + $this->assertEquals((bool) $record->fallback, $record->langcode === $langcode); + } + } + +} diff --git a/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayBlockFormTest.php b/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayBlockFormTest.php index 7475dab64a66b0c073755742082395a2986d276f..cc87d283b57efa5418c5bbf4f6a26b73f6f02599 100644 --- a/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayBlockFormTest.php +++ b/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayBlockFormTest.php @@ -73,7 +73,11 @@ protected function setUp() { * * @dataProvider providerTestBlocks */ - public function testBlocks($theme, $block_plugin, $new_page_text, $element_selector, $label_selector, $button_text, $toolbar_item) { + public function testBlocks($theme, $block_plugin, $new_page_text, $element_selector, $label_selector, $button_text, $toolbar_item, $permissions) { + if ($permissions) { + $this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), $permissions); + } + $web_assert = $this->assertSession(); $page = $this->getSession()->getPage(); $this->enableTheme($theme); @@ -174,6 +178,7 @@ public function providerTestBlocks() { 'label_selector' => 'h2', 'button_text' => 'Save Powered by Drupal', 'toolbar_item' => '#toolbar-item-user', + NULL, ], "$theme: block-branding" => [ 'theme' => $theme, @@ -183,6 +188,7 @@ public function providerTestBlocks() { 'label_selector' => "a[rel='home']:last-child", 'button_text' => 'Save Site branding', 'toolbar_item' => '#toolbar-item-administration', + ['administer site configuration'], ], "$theme: block-search" => [ 'theme' => $theme, @@ -192,6 +198,7 @@ public function providerTestBlocks() { 'label_selector' => 'h2', 'button_text' => 'Save Search form', 'toolbar_item' => NULL, + NULL, ], // This is the functional JS test coverage accompanying // \Drupal\Tests\settings_tray\Functional\SettingsTrayTest::testPossibleAnnotations(). @@ -203,6 +210,7 @@ public function providerTestBlocks() { 'label_selector' => NULL, 'button_text' => NULL, 'toolbar_item' => NULL, + NULL, ], // This is the functional JS test coverage accompanying // \Drupal\Tests\settings_tray\Functional\SettingsTrayTest::testPossibleAnnotations(). @@ -214,6 +222,7 @@ public function providerTestBlocks() { 'label_selector' => NULL, 'button_text' => NULL, 'toolbar_item' => NULL, + NULL, ], ]; } @@ -551,6 +560,71 @@ protected function isLabelInputVisible() { return $this->getSession()->getPage()->find('css', static::LABEL_INPUT_SELECTOR)->isVisible(); } + /** + * Tests access to block forms with related configuration is correct. + */ + public function testBlockConfigAccess() { + $page = $this->getSession()->getPage(); + $web_assert = $this->assertSession(); + + // Confirm that System Branding block does not expose Site Name field + // without permission. + $block = $this->placeBlock('system_branding_block'); + $this->drupalGet('user'); + $this->enableEditMode(); + $this->openBlockForm($this->getBlockSelector($block)); + // The site name field should not appear because the user doesn't have + // permission. + $web_assert->fieldNotExists('settings[site_information][site_name]'); + $page->pressButton('Save Site branding'); + $this->assertElementVisibleAfterWait('css', 'div:contains(The block configuration has been saved)'); + $web_assert->assertWaitOnAjaxRequest(); + // Confirm we did not save changes to the configuration. + $this->assertEquals('Drupal', \Drupal::configFactory()->getEditable('system.site')->get('name')); + + $this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), ['administer site configuration']); + $this->drupalGet('user'); + $this->openBlockForm($this->getBlockSelector($block)); + // The site name field should appear because the user does have permission. + $web_assert->fieldExists('settings[site_information][site_name]'); + + // Confirm that the Menu block does not expose menu configuration without + // permission. + // Add a link or the menu will not render. + $menu_link_content = MenuLinkContent::create([ + 'title' => 'This is on the menu', + 'menu_name' => 'main', + 'link' => ['uri' => 'route:'], + ]); + $menu_link_content->save(); + $this->assertNotEmpty($menu_link_content->isEnabled()); + $menu_without_overrides = \Drupal::configFactory()->getEditable('system.menu.main')->get(); + $block = $this->placeBlock('system_menu_block:main'); + $this->drupalGet('user'); + $web_assert->pageTextContains('This is on the menu'); + $this->openBlockForm($this->getBlockSelector($block)); + // Edit menu form should not appear because the user doesn't have + // permission. + $web_assert->pageTextNotContains('Edit menu'); + $page->pressButton('Save Main navigation'); + $this->assertElementVisibleAfterWait('css', 'div:contains(The block configuration has been saved)'); + $web_assert->assertWaitOnAjaxRequest(); + // Confirm we did not save changes to the menu or the menu link. + $this->assertEquals($menu_without_overrides, \Drupal::configFactory()->getEditable('system.menu.main')->get()); + $menu_link_content = MenuLinkContent::load($menu_link_content->id()); + $this->assertNotEmpty($menu_link_content->isEnabled()); + // Confirm menu is still on the page. + $this->drupalGet('user'); + $web_assert->pageTextContains('This is on the menu'); + + $this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), ['administer menu']); + $this->drupalGet('user'); + $web_assert->pageTextContains('This is on the menu'); + $this->openBlockForm($this->getBlockSelector($block)); + // Edit menu form should appear because the user does have permission. + $web_assert->pageTextContains('Edit menu'); + } + /** * Test that validation errors appear in the off-canvas dialog. */ @@ -634,6 +708,7 @@ public function testOverriddenBlock() { public function testOverriddenConfigurationRemoved() { $web_assert = $this->assertSession(); $page = $this->getSession()->getPage(); + $this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), ['administer site configuration', 'administer menu']); // Confirm the branding block does include 'site_information' section when // the site name is not overridden. diff --git a/core/modules/system/src/Form/SystemBrandingOffCanvasForm.php b/core/modules/system/src/Form/SystemBrandingOffCanvasForm.php index 35e683080dc2414ce4402aebfb622251e9a2d819..120a6580062a7320c3df0b99ee8c20d5bb1a0516 100644 --- a/core/modules/system/src/Form/SystemBrandingOffCanvasForm.php +++ b/core/modules/system/src/Form/SystemBrandingOffCanvasForm.php @@ -7,6 +7,7 @@ use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\PluginFormBase; +use Drupal\Core\Session\AccountInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -30,14 +31,24 @@ class SystemBrandingOffCanvasForm extends PluginFormBase implements ContainerInj */ protected $configFactory; + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected $currentUser; + /** * SystemBrandingOffCanvasForm constructor. * * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The config factory. + * @param \Drupal\Core\Session\AccountInterface $current_user + * The current user. */ - public function __construct(ConfigFactoryInterface $config_factory) { + public function __construct(ConfigFactoryInterface $config_factory, AccountInterface $current_user) { $this->configFactory = $config_factory; + $this->currentUser = $current_user; } /** @@ -45,7 +56,8 @@ public function __construct(ConfigFactoryInterface $config_factory) { */ public static function create(ContainerInterface $container) { return new static( - $container->get('config.factory') + $container->get('config.factory'), + $container->get('current_user') ); } @@ -68,7 +80,7 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#type' => 'details', '#title' => t('Site details'), '#open' => TRUE, - '#access' => AccessResult::allowedIf(!$site_config_immutable->hasOverrides('name') && !$site_config_immutable->hasOverrides('slogan')), + '#access' => $this->currentUser->hasPermission('administer site configuration') && !$site_config_immutable->hasOverrides('name') && !$site_config_immutable->hasOverrides('slogan'), ]; $form['site_information']['site_name'] = [ '#type' => 'textfield', diff --git a/core/modules/system/src/Form/SystemMenuOffCanvasForm.php b/core/modules/system/src/Form/SystemMenuOffCanvasForm.php index 82390bcbccdafd3650d33ea9cf65ca2801390797..a561a5bd9a5aac6baf28f3d47483d885451b4e97 100644 --- a/core/modules/system/src/Form/SystemMenuOffCanvasForm.php +++ b/core/modules/system/src/Form/SystemMenuOffCanvasForm.php @@ -3,7 +3,6 @@ namespace Drupal\system\Form; use Drupal\Component\Plugin\PluginInspectionInterface; -use Drupal\Core\Access\AccessResult; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityStorageInterface; @@ -98,7 +97,7 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#type' => 'details', '#title' => $this->t('Edit menu %label', ['%label' => $this->menu->label()]), '#open' => TRUE, - '#access' => AccessResult::allowedIf(!$this->hasMenuOverrides()), + '#access' => !$this->hasMenuOverrides() && $this->menu->access('edit'), ]; $form['entity_form'] += $this->getEntityForm($this->menu)->buildForm([], $form_state);