Problem/Motivation

We want to not allow people either via REST or UI to link to a URL they don't have access to, because this would result in some form of information disclosure,
for example via the URL alias.

Proposed resolution

Move the current access related validation logic from the form into a new access validation constraint.

Remaining tasks

None.

User interface changes

None.

API changes

None.

Original report by @dawehner

Comments

upchuk’s picture

Assigned: Unassigned » upchuk
Status: Active » Needs work

Will take a stab at it.

D

upchuk’s picture

Assigned: upchuk » Unassigned
Status: Needs work » Needs review
StatusFileSize
new5.5 KB

Here's a patch, no tests yet (not sure if any are needed).

One question: the error message of the `LinkTypeConstraint` plugin fits perfectly well with the error message for the new Access constraint so I copied it from there. Should we not adjust it in `LinkTypeConstraint` to better reflect the validation error? Not sure why this: or you do not have access to it is in it :)

D

Status: Needs review » Needs work

The last submitted patch, 2: 2419693-2.patch, failed testing.

upchuk’s picture

Assigned: Unassigned » upchuk
effulgentsia’s picture

Title: Ensure that you cannot link to a URI you don't have access to » Move URI access validation from widget to field constraint
Category: Bug report » Task
Priority: Critical » Major
Issue tags: -Security

But, if we're gonna hide them in the Menu UI, we should also prevent you from making them, since otherwise, you can create a link that you then can't find or edit.

That's a UX goal rather than a security one. And core's only link widget already provides this desired UX. So this isn't fixing a bug or addressing security. But, it is a good code move that will make it easier for contrib widgets and REST services to have the desired UX by default, so recategorizing as a Major task.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new619 bytes
new5.54 KB

One of the reasons of the fail in the interdiff :)

Starting the bot for the other ones because I noticed something weird when I was doing partial local testing. While it was testing validation of links, at some point it was using the url input element instead of textfield with an internal URI. According to the widget this shouldn't happen...

D

Status: Needs review » Needs work

The last submitted patch, 6: 2419693-6.patch, failed testing.

upchuk’s picture

Issue summary: View changes
StatusFileSize
new819 bytes
new5.58 KB

Alright, found the problem. When I stripped the widget of the validation method, I also removed its call from the `#element_validate` render key. This means that the default Url validation kicked in before the constrain and that provides a different error message. I replaced it with an empty array so the constraint is used instead.

upchuk’s picture

Status: Needs work » Needs review
dawehner’s picture

It would be great to expand the test coverage of the LinkFieldTest with an API only test checking that the validation is also fired.

yched’s picture

re #8:

+++ b/core/modules/link/src/Plugin/Field/FieldWidget/LinkWidget.php
@@ -97,6 +97,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
+      '#element_validate' => array(),

Would be worth a comment then :-)

Also, minor : [] rather than array() ?

upchuk’s picture

@dawehner Will do. My next step was to try and see how I can create the entities through the API to test the constraint.

@yched Totally right. About the array, I was wondering myself :)

D

upchuk’s picture

Yeah, there is a problem with the validation using the API..

The problem is the following: when creating entities using the link field and through a form widget, there is a value massage method that calls the getUserEnteredStringAsUrimethod to transform a schemaless URI into the default user-path schema. The problem with that is that when you create a simple entity like so:

$link = entity_create('menu_link_content', array(
    'title' => 'bla',
    'link' => 'your-alias'
  ));

...the massaging doesn't happen and the Url::fromUri method throws an exception. And this means that entities using the link field and which do not actively implement some sort of hook to transform schemaless URI's into the default schema, will be broken for creation through the API.

Any thoughts on where best to implement this value massaging?

D

upchuk’s picture

I actually saw inside a menu_link_content test this entity_create usage:

$options = array(
      'menu_name' => 'menu_test',
      'bundle' => 'menu_link_content',
      'link' => [['uri' => 'user-path:<front>']],
    );
$link = entity_create('menu_link_content', $options);

So yeah, obviously you can prefix internal paths such as aliases with the schema. But I;m not sure it's a good thing. Why are we asking the "API user" to provide more information than the "form user"? And not to mention the dependency it puts on knowing whether the schema is necessary or not. Is there no way we can make this happen somewhere on entity_create level?

Something like this maybe? :)

function link_entity_presave(Drupal\Core\Entity\EntityInterface $entity) {
  $definitions = $entity->getFieldDefinitions();
  if (!empty($definitions)) {
    foreach ($definitions as $definition) {
      if ($definition->getType() == 'link') {
        $field_name = $definition->getName();
        $uri = $entity->{$field_name}->uri;
        if (parse_url($uri, PHP_URL_SCHEME) === NULL) {
          $entity->{$field_name}->uri = 'user-path:' . $uri;
        }
      }
    }
  }
}

This way we can also remove this from the form.

yched’s picture

This is 100% legit. When you work on an entity at the api level, you work with raw values, the same you would read from an $entity loaded from db.

Then widgets provide UX, that doesnt affect the shape of the values when working with entities in code.

upchuk’s picture

StatusFileSize
new2.48 KB
new7.22 KB

Alrighty.

Here is the updated test to include also API level validation.

upchuk’s picture

StatusFileSize
new2.64 KB
new7.38 KB

Don't mind the patches in the previous comment, there are some obvious testing mistakes in it :)

These should be better. The interdiff is from #8.

Status: Needs review » Needs work

The last submitted patch, 17: 2419693-17.patch, failed testing.

Status: Needs work » Needs review

Upchuk queued 17: 2419693-17.patch for re-testing.

upchuk’s picture

StatusFileSize
new2.65 KB
new7.38 KB

Yeah, turns out that #2417647: Add leading slash to paths within 'user-path:' URIs, to allow 'user-path:' URIs to point to the <none> route added a slash to the user-path scheme and I hadn't pulled since then :) Let's see if it works now.

Interdiff still from #8;

The last submitted patch, 17: 2419693-17.patch, failed testing.

dawehner’s picture

Awesome, this looks really great!

  1. +++ b/core/modules/link/src/Tests/LinkFieldTest.php
    @@ -169,7 +175,29 @@ protected function assertInvalidEntries($field_name, array $invalid_entries) {
    +      $this->assertTrue($violations->count() !== 0);
    

    Can we count the expected amount of failures?

  2. +++ b/core/modules/link/src/Tests/LinkFieldTest.php
    @@ -169,7 +175,29 @@ protected function assertInvalidEntries($field_name, array $invalid_entries) {
    +      $this->assertEqual($violations->get(0)->getMessage(), 'The path \'' . $this->getStringAsUri($invalid_value) .'\' is either invalid or you do not have access to it.');
    

    What about using String::format() here?

upchuk’s picture

StatusFileSize
new867 bytes
new7.44 KB

Hey there,

Counting the failure is tricky because I noticed a different number of fails for the different invalid URIs.

if I create an entity with an invalid URI in the link field I get 3 violations (2 from the 2 constraints of the Link module and one from the PrimitiveTypeConstraint). In the test however, sometimes it's only one, sometimes all 3...

I corrected the expectation message to use String::format() in the meantime. What do you suggest about the first issue? Is it important to know the number of constraints?

dawehner’s picture

Well, there should be a deterministic result of the amount of validation errors, shouldn't there?

wim leers’s picture

Status: Needs review » Needs work
  1. +++ b/core/modules/link/src/Plugin/Field/FieldWidget/LinkWidget.php
    @@ -162,7 +136,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
    -      '#element_validate' => array(array(get_called_class(), 'validateUriElement')),
    +      '#element_validate' => [],
    

    Let's instead just remove #element_validate altogether.

  2. +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraint.php
    @@ -0,0 +1,73 @@
    + *   label = @Translation("Link uri can be accessed by the user.", context = "Validation"),
    

    Either s/uri/URI/, or just s/uri//.

  3. +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraint.php
    @@ -0,0 +1,73 @@
    +      $link_item = $value;
    ...
    +        $url = $link_item->getUrl();
    

    Why not delete the first line and do $value->getUrl() on the second line?

  4. +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraint.php
    @@ -0,0 +1,73 @@
    +      catch (\InvalidArgumentException $e) {
    

    What exactly can throw that exception? We didn't have that before.

  5. +++ b/core/modules/link/src/Tests/LinkFieldTest.php
    @@ -169,7 +175,30 @@ protected function assertInvalidEntries($field_name, array $invalid_entries) {
    +      $message = String::format('The path \'@uri\' is either invalid or you do not have access to it.', array('@uri' => $this->getStringAsUri($invalid_value)));
    

    You could also have used double quotes, then there is no need for the escaping. But, that doesn't really matter.

  6. +++ b/core/modules/link/src/Tests/LinkFieldTest.php
    @@ -169,7 +175,30 @@ protected function assertInvalidEntries($field_name, array $invalid_entries) {
    +   *   The potentially scheme-less URI
    ...
    +   *   A URI with the default scheme if none exists
    

    Missing trailing period.

  7. +++ b/core/modules/link/src/Tests/LinkFieldTest.php
    @@ -169,7 +175,30 @@ protected function assertInvalidEntries($field_name, array $invalid_entries) {
    +   * @return string
    

    Should have a blank line before it.

But actually, I don't think we want any of the changes to LinkFieldTest, because that is an integration test. I think we want a unit test similar to \Drupal\Tests\Core\Validation\Plugin\Validation\Constraint\PrimitiveTypeConstraintValidatorTest.

upchuk’s picture

As discussed with @Wim Leers, the following actions will be taken:

1. Leave as it is but add comment to reflect the need to keep #element_validate as an empty array
2, 3. Make the corrections
4. Exception being thrown by the Url::fromUri method so try..catch needs to stay
5,6,7. Make the corrections

Additionally, will need to create a unit test for LinkAccessConstraint which also means injecting the current user into the constraint class in order to mock it.

Another additionally, as discussed with @dawehner, will need to change the error message of LinkTypeConstraint to better reflect what it does. Integration tests for this constraint will follow in another issue.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new7.62 KB

Just triggering the bot to see if anything fails..

Status: Needs review » Needs work

The last submitted patch, 27: 2419693-27.patch, failed testing.

wim leers’s picture

  1. +++ b/core/modules/link/src/Plugin/Field/FieldWidget/LinkWidget.php
    @@ -162,7 +136,9 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
    +      '#element_validate' => array(),
    

    Let's change it to [].

  2. +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraint.php
    @@ -0,0 +1,25 @@
    +class LinkAccessConstraint extends Constraint {
    
    +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraintValidator.php
    @@ -0,0 +1,84 @@
    +class LinkAccessConstraintValidator implements ConstraintValidatorInterface, ContainerInjectionInterface {
    

    Why split this up in separate classes? What's the gain?

upchuk’s picture

Hey @Wim,

The validator had to be split from the constraint in order for the injection to work. The constraint is a plugin and gets built with the ContainerFactoryPluginInterface and the validator can implement the ContainerInjectionInterface. If they are both in one class, then they will get built twice with 2 different create() methods.

D

wim leers’s picture

I see :) Funky restriction, but seems legit. Thanks for the explanation!

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new6.28 KB

Alright, starting from scratch basically with

  • a LinkAccessConstraint and separate validator class that focus only on the access aspect. Rest remains inside the LinkWidget for now
  • minor change to the message of the LinkTypeConstraint to better reflect what actually it does

Unit tests are coming as well.

Status: Needs review » Needs work

The last submitted patch, 32: 2419693-32.patch, failed testing.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new6.72 KB

Trying again with more consolidated non-access related error messages.

Status: Needs review » Needs work

The last submitted patch, 34: 2419693-34.patch, failed testing.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new7.45 KB

Another test, hopefully this should pass :)

Status: Needs review » Needs work

The last submitted patch, 36: 2419693-36.patch, failed testing.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new10.73 KB

Here we go again. This time there is a unit test for the LinkAccessConstrainValidator.

If test is green, the whole patch can be reviewed manually.

upchuk’s picture

+++ b/core/modules/link/tests/src/LinkAccessConstraintValidatorTest.php
@@ -0,0 +1,82 @@
+ * @group validation2

Left 'validation2' by accident. Will fix it after manual review if the automated test passes.

wim leers’s picture

Status: Needs review » Needs work

Looking close! :)

  1. +++ b/core/modules/link/src/Plugin/Field/FieldWidget/LinkWidget.php
    @@ -180,7 +177,7 @@ public static function validateUriElement($element, FormStateInterface $form_sta
    -        $form_state->setError($element, t("The path '@link_path' is either invalid or you do not have access to it.", ['@link_path' => static::getUriAsDisplayableString($uri)]));
    +        $form_state->setError($element, t("The path '@link_path' is invalid.", ['@link_path' => static::getUriAsDisplayableString($uri)]));
    
    +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraint.php
    @@ -0,0 +1,25 @@
    +  public $message = "The path '@uri' is either invalid or you do not have access to it.";
    

    I'm not sure if we want to change the messaging here, because it amounts to a subtle information disclosure.

    I think we'll have to keep the messages the same.

    @dawehner, @effulgentsia, please confirm.

  2. +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraintValidator.php
    @@ -0,0 +1,78 @@
    +use Symfony\Component\DependencyInjection\ContainerInterface;
    +use Drupal\Core\Session\AccountProxyInterface;
    

    Nit: let's swap these two, then all "use" statements are ordered alphabetically.

  3. +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraintValidator.php
    @@ -0,0 +1,78 @@
    +   * @param AccountProxyInterface $current_user
    

    Needs fully qualified name.

  4. +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraintValidator.php
    @@ -0,0 +1,78 @@
    +   * {@inheritDoc}
    

    Capital D.

  5. +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraintValidator.php
    @@ -0,0 +1,78 @@
    +  }
    +}
    

    Nit: blank line between last method's closing brace and the class' closing brace.

  6. +++ b/core/modules/link/tests/src/LinkAccessConstraintValidatorTest.php
    @@ -0,0 +1,82 @@
    +  public function testValidate($value, $user, $valid) {
    +
    +    $context = $this->getMock('Symfony\Component\Validator\ExecutionContextInterface');
    

    Nit: No blank line here.

  7. +++ b/core/modules/link/tests/src/LinkAccessConstraintValidatorTest.php
    @@ -0,0 +1,82 @@
    +      ['user_admin' => true, 'url_admin' => true, 'valid' => true],
    +      ['user_admin' => true, 'url_admin' => false, 'valid' => true],
    +      ['user_admin' => false, 'url_admin' => true, 'valid' => true],
    +      ['user_admin' => false, 'url_admin' => false, 'valid' => false],
    

    s/true/TRUE/
    s/false/FALSE/

  8. +++ b/core/modules/link/tests/src/LinkAccessConstraintValidatorTest.php
    @@ -0,0 +1,82 @@
    +      // Mock a URL object that returns a boolean indicating user access.
    ...
    +      // Mock a link object that returns the URL object.
    

    s/URL/Url/, because we're referring to a specific classname.

  9. +++ b/core/modules/link/tests/src/LinkAccessConstraintValidatorTest.php
    @@ -0,0 +1,82 @@
    +      // Mock a user object that returns a boolean indicating user access to all
    +      // links.
    +      $user = $this->getMock('Drupal\Core\Session\AccountProxyInterface');
    +      $user->expects($this->any())
    +        ->method('hasPermission')
    

    Let's add a ->with() line to clarify which permission this is mocking.

  10. +++ b/core/modules/link/tests/src/LinkAccessConstraintValidatorTest.php
    @@ -0,0 +1,82 @@
    +        ->willReturn($case['user_admin']);
    

    I think "user_admin" may be a bit confusing, what about "may_link_any_page", or something like that?

upchuk’s picture

Hey Wim, thanks for the review.

Will make all the changes.

Regarding the first one (changing the message): I changed the messaging because it doesn't reflect reality (doesn't do any sort of access check) and that exact message fits perfectly with the new LinkAccessConstraint. Additionally, since integration testing is not possible by checking the type of constraint that added a violation, we have to rely on the message it throws (still don't know why that is though :) ). This is yet another reason why the two messages need to be separate. You'll notice I also changed it in the LinkTypeConstraint. This I discussed already with @dawehner, maybe you remember :)

D

wim leers’s picture

Did you discuss the information disclosure aspect with dawehner? I don't remember, sorry. (Too many things happening at the same time in the whole menu links/URI set of issues.)

dawehner’s picture

@Wim Leers
Given that you can also just use the HTTP level to determine whether something exists or not, I think its fine to show a different message.
In case someone wants to change that and set 404==403 its already required to write some code, so this would be just additional effort.

wim leers’s picture

In case someone wants to change that and set 404==403 its already required to write some code, so this would be just additional effort.

Ah, right, that was our conclusion.

Then please ignore #40.1! :)

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new4.54 KB
new10.84 KB

Here we go. Hope it still passes :)

Let me know if you spot any other issues.

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

Looks perfect.

dawehner’s picture

Issue summary: View changes

Mh okay, let's adapt the issue summary

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 45: 2419693-46.patch, failed testing.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new636 bytes
new10.87 KB

Alright, here it is, a small error creeped in that was caught in the meantime by #2418237: Fix incorrect @covers in PHPUnit tests I guess.

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC.

dawehner’s picture

+1

amateescu’s picture

+++ b/core/modules/link/tests/src/LinkAccessConstraintValidatorTest.php
@@ -0,0 +1,82 @@
+      ['may_link_any_page' => TRUE, 'url_access' => false, 'valid' => TRUE],

A lowercase false creeped in here, can be fixed on commit though.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraint.php
@@ -0,0 +1,25 @@
+  public $message = "The path '@uri' is either invalid or you do not have access to it.";

I think this message should be The path '@uri' is inaccessible.'. We already have the minor information disclosure.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new2.23 KB
new10.8 KB

Here we go. Hope it still passes :)

Status: Needs review » Needs work

The last submitted patch, 54: 2419693-54.patch, failed testing.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new782 bytes
new11.56 KB

Here we go again :)

dawehner’s picture

Status: Needs review » Reviewed & tested by the community

Ha, we even have test coverage for the string, good to know!

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraintValidator.php
@@ -0,0 +1,79 @@
+      $allowed = TRUE;
+      try {
+        $url = $value->getUrl();
+        // Disallow URLs if the current user doesn't have the 'link to any page'
+        // permission nor can access this URI.
+        $allowed = $this->current_user->hasPermission('link to any page') || $url->access();
+      }
+      catch (\InvalidArgumentException $e) {
+        $allowed = FALSE;
+      }

I think this constraint should have no opinion if the url is not valid. So something like this:

  /**
   * {@inheritdoc}
   */
  public function validate($value, Constraint $constraint) {
    if (isset($value)) {
      try {
        $url = $value->getUrl();
      }
       // If the URL is malformed this constraint can not check access.
      catch (\InvalidArgumentException $e) {
        return;
      }

      // Disallow URLs if the current user doesn't have the 'link to any page'
      // permission nor can access this URI.
      $allowed = $this->current_user->hasPermission('link to any page') || $url->access();
      if (!$allowed) {
        $this->context->addViolation($constraint->message, array('@uri' => $value->uri));
      }
    }
  }
alexpott’s picture

+++ b/core/modules/link/tests/src/LinkAccessConstraintValidatorTest.php
@@ -0,0 +1,82 @@
+
+  public function provideTestValidate() {

There is consensus that this methods are called provider and have a doc block that points to the method(s) they provide data for.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new3.01 KB
new12.29 KB

Hey there,

Made the changes. Additionally, I also added some docblock info to the test class and method.

Status: Needs review » Needs work

The last submitted patch, 60: 2419693-60.patch, failed testing.

upchuk’s picture

Status: Needs work » Needs review
StatusFileSize
new12.28 KB
+++ b/core/modules/link/tests/src/LinkAccessConstraintValidatorTest.php
@@ -0,0 +1,102 @@
+   * @dataProvider provideTestValidate

This was the problem. Fixed now by changing to providerValidate (to match the changed method name)

dawehner’s picture

  1. +++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkAccessConstraintValidator.php
    @@ -0,0 +1,79 @@
    +  /**
    +   * @var \Symfony\Component\Validator\ExecutionContextInterface
    +   */
    +  protected $context;
    +
    +  /**
    +   * @var \Drupal\Core\Session\AccountProxyInterface
    +   */
    +  protected $current_user;
    ...
    +   *
    +   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
    +   */
    

    Sadly sadly the documentation standard says we document those lines as wlel.

  2. +++ b/core/modules/menu_ui/src/Tests/MenuTest.php
    @@ -639,13 +639,17 @@ function addMenuLink($parent = '', $path = '/', $menu_name = 'tools', $expanded
    +      if ($type === 'access') {
    +        $this->assertRaw(t("The path '@link_path' is inaccessible.", array('@link_path' => $link_path)), 'Menu link was not created');
    +        continue;
    +      }
    +      $this->assertRaw(t("The path '@link_path' is invalid.", array('@link_path' => $link_path)), 'Menu link was not created');
         }
    

    can't you remove the continue and replace it with an else?

upchuk’s picture

StatusFileSize
new1.68 KB
new12.39 KB

Here you go.

upchuk’s picture

StatusFileSize
new3.28 KB
new12.51 KB

Here we go @YesCT.

upchuk’s picture

Issue summary: View changes
upchuk’s picture

StatusFileSize
new1.45 KB
new12.43 KB

Here we are.

yesct’s picture

Status: Needs review » Reviewed & tested by the community

looks like the concerns have been addressed. (and some nits we discussed in irc)

webchick’s picture

Assigned: upchuk » alexpott

Alex Pott has been all up in this issue's bizness, so assigning to him.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

This issue addresses a major bug and is allowed per https://www.drupal.org/core/beta-changes. Committed 9d4d62d and pushed to 8.0.x. Thanks!

  • alexpott committed 9d4d62d on 8.0.x
    Issue #2419693 by Upchuk: Move URI access validation from widget to...
wim leers’s picture

Upchuk++ — thanks! :)

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.