diff --git a/core/modules/link/src/Plugin/Field/FieldType/LinkItem.php b/core/modules/link/src/Plugin/Field/FieldType/LinkItem.php
index 84556f8be0..0e87a4331d 100644
--- a/core/modules/link/src/Plugin/Field/FieldType/LinkItem.php
+++ b/core/modules/link/src/Plugin/Field/FieldType/LinkItem.php
@@ -21,7 +21,7 @@
  *   description = @Translation("Stores a URL string, optional varchar link text, and optional blob of attributes to assemble a link."),
  *   default_widget = "link_default",
  *   default_formatter = "link",
- *   constraints = {"LinkType" = {}, "LinkAccess" = {}, "LinkExternalProtocols" = {}, "LinkNotExistingInternal" = {}}
+ *   constraints = {"LinkType" = {}, "LinkAccess" = {}, "LinkExternalProtocols" = {}, "LinkNotExistingInternal" = {}, "LinkUriValid" = {}}
  * )
  */
 class LinkItem extends FieldItemBase implements LinkItemInterface {
diff --git a/core/modules/link/src/Plugin/Validation/Constraint/LinkUriValidConstraint.php b/core/modules/link/src/Plugin/Validation/Constraint/LinkUriValidConstraint.php
new file mode 100644
index 0000000000..df402339ec
--- /dev/null
+++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkUriValidConstraint.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\link\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraints\Url;
+
+/**
+ * Defines a validation constraint for invalid characters in the URI.
+ *
+ * @Constraint(
+ *   id = "LinkUriValid",
+ *   label = @Translation("No invalid characters in the URI field", context =
+ *   "Validation"),
+ * )
+ */
+class LinkUriValidConstraint extends Url {
+
+  public $message = "The path/URL '%value' contains invalid characters.";
+
+}
diff --git a/core/modules/link/src/Plugin/Validation/Constraint/LinkUriValidConstraintValidator.php b/core/modules/link/src/Plugin/Validation/Constraint/LinkUriValidConstraintValidator.php
new file mode 100644
index 0000000000..b194e2ac45
--- /dev/null
+++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkUriValidConstraintValidator.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\link\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\Constraints\Url;
+use Symfony\Component\Validator\Constraints\UrlValidator;
+
+/**
+ * Validates the LinkUriValid constraint.
+ */
+class LinkUriValidConstraintValidator extends UrlValidator {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($value, Constraint $constraint) {
+    if (isset($value)) {
+      try {
+        /** @var \Drupal\Core\Url $urlObject */
+        $urlObject = $value->getUrl();
+        $url = $urlObject->setAbsolute()->toString();
+        // LinkExternalProtocolsConstraintValidator will deal with external
+        // URLs with invalid protocols.
+        $parts = parse_url($url) + ['scheme' => ''];
+        if (!$urlObject->isExternal() || in_array($parts['scheme'], $constraint->protocols)) {
+          parent::validate($url, $constraint);
+        }
+        if ($parts['scheme'] === 'mailto' && !filter_var($parts['path'], FILTER_VALIDATE_EMAIL)) {
+          $this->context->buildViolation($constraint->message)
+            ->setParameter('{{ value }}', $this->formatValue($value))
+            ->setCode(Url::INVALID_URL_ERROR)
+            ->addViolation();
+        }
+      }
+      catch (\Exception $e) {
+        // This is not our job, it can be invalid internal path and more.
+      }
+    }
+  }
+
+}
diff --git a/core/modules/link/tests/src/Functional/LinkFieldTest.php b/core/modules/link/tests/src/Functional/LinkFieldTest.php
index 5c51f9bd59..2c6ec9bb6c 100644
--- a/core/modules/link/tests/src/Functional/LinkFieldTest.php
+++ b/core/modules/link/tests/src/Functional/LinkFieldTest.php
@@ -148,10 +148,6 @@ public function testURLValidation() {
       'route:<nolink>' => '&lt;nolink&gt;',
       '<none>' => '&lt;none&gt;',
 
-      // Query string and fragment.
-      '?example=llama' => '?example=llama',
-      '#example' => '#example',
-
       // Entity reference autocomplete value.
       $node->label() . ' (1)' => $node->label() . ' (1)',
       // Entity URI displayed as ER autocomplete value when displayed in a form.
@@ -167,9 +163,9 @@ public function testURLValidation() {
     $validation_error_2 = 'Manually entered paths should start with one of the following characters: / ? #';
     $validation_error_3 = "The path '@link_path' is inaccessible.";
     $invalid_external_entries = [
-      // Invalid protocol
+      // Invalid protocol.
       'invalid://not-a-valid-protocol' => $validation_error_1,
-      // Missing host name
+      // Missing host name.
       'http://' => $validation_error_1,
     ];
     $invalid_internal_entries = [
@@ -177,6 +173,10 @@ public function testURLValidation() {
       'entity:non_existing_entity_type/yar' => $validation_error_1,
       // URI for an entity that doesn't exist, with an invalid ID.
       'entity:user/invalid-parameter' => $validation_error_1,
+
+      // Query string and fragment.
+      '?example=llama' => '?example=llama',
+      '#example' => '#example',
     ];
 
     // Test external and internal URLs for 'link_type' = LinkItemInterface::LINK_GENERIC.
@@ -219,7 +219,7 @@ protected function assertValidEntries($field_name, array $valid_entries) {
       ];
       $this->drupalPostForm('entity_test/add', $edit, 'Save');
       preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match);
-      $id = $match[1];
+      $id = !empty($match) ? $match[1] : '';
       $this->assertText('entity_test ' . $id . ' has been created.');
       $this->assertRaw('"' . $string . '"');
     }
@@ -413,7 +413,8 @@ public function testLinkFormatter() {
     $this->drupalGet('entity_test/add');
     $url1 = 'http://www.example.com/content/articles/archive?author=John&year=2012#com';
     $url2 = 'http://www.example.org/content/articles/archive?author=John&year=2012#org';
-    $url3 = '#net';
+    // The (#tag) fragment is tested within testURLValidation().
+    $url3 = '/#tag';
     $title1 = $url1;
     // Intentionally contains an ampersand that needs sanitization on output.
     $title2 = 'A very long & strange example title that could break the nice layout of the site';
@@ -431,7 +432,7 @@ public function testLinkFormatter() {
     $this->assertText('Read more about this entity');
     $this->submitForm($edit, 'Save');
     preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match);
-    $id = $match[1];
+    $id = !empty($match) ? $match[1] : '';
     $this->assertText('entity_test ' . $id . ' has been created.');
 
     // Verify that the link is output according to the formatter settings.
@@ -450,6 +451,7 @@ public function testLinkFormatter() {
         ['url_only' => TRUE, 'url_plain' => TRUE],
       ],
     ];
+    $url3 = Url::fromUserInput($url3 , [])->toString();
     foreach ($options as $setting => $values) {
       foreach ($values as $new_value) {
         // Update the field formatter settings.
@@ -569,7 +571,7 @@ public function testLinkSeparateFormatter() {
     $this->drupalGet('entity_test/add');
     $url1 = 'http://www.example.com/content/articles/archive?author=John&year=2012#com';
     $url2 = 'http://www.example.org/content/articles/archive?author=John&year=2012#org';
-    $url3 = '#net';
+    $url3 = '/#net';
     // Intentionally contains an ampersand that needs sanitization on output.
     $title2 = 'A very long & strange example title that could break the nice layout of the site';
     $title3 = 'Fragment only';
@@ -582,7 +584,7 @@ public function testLinkSeparateFormatter() {
     ];
     $this->submitForm($edit, 'Save');
     preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match);
-    $id = $match[1];
+    $id = !empty($match) ? $match[1] : '';
     $this->assertText('entity_test ' . $id . ' has been created.');
 
     // Verify that the link is output according to the formatter settings.
@@ -591,6 +593,7 @@ public function testLinkSeparateFormatter() {
       'rel' => [NULL, 'nofollow'],
       'target' => [NULL, '_blank'],
     ];
+    $url3 = Url::fromUserInput($url3 , [])->toString();
     foreach ($options as $setting => $values) {
       foreach ($values as $new_value) {
         // Update the field formatter settings.
@@ -799,7 +802,7 @@ public function testNoLinkUri() {
 
     $this->drupalPostForm('/entity_test/add', $edit, 'Save');
     preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match);
-    $id = $match[1];
+    $id = !empty($match) ? $match[1] : '';
     $output = $this->renderTestEntity($id);
     $expected_link = (string) $this->container->get('link_generator')->generate('Title, no link', Url::fromUri('route:<nolink>'));
     $this->assertStringContainsString($expected_link, $output);
diff --git a/core/modules/link/tests/src/Kernel/LinkItemUrlValidationTest.php b/core/modules/link/tests/src/Kernel/LinkItemUrlValidationTest.php
index aa0c849ab0..1483cfed6b 100644
--- a/core/modules/link/tests/src/Kernel/LinkItemUrlValidationTest.php
+++ b/core/modules/link/tests/src/Kernel/LinkItemUrlValidationTest.php
@@ -38,7 +38,9 @@ public function testExternalLinkValidation() {
           if (strpos($error_msg, '%')) {
             $error_msg = sprintf($error_msg, $value);
           }
-          $this->assertEquals($error_msg, $violations[$i++]->getMessage());
+          $msg = $violations[$i++]->getMessage();
+          $actual_msg = htmlspecialchars_decode(strip_tags($msg->render()), ENT_QUOTES);
+          $this->assertEquals($error_msg, $actual_msg);
         }
       }
     }
@@ -54,6 +56,7 @@ public function testExternalLinkValidation() {
   protected function getTestLinks() {
     $violation_0 = "The path '%s' is invalid.";
     $violation_1 = 'This value should be of the correct primitive type.';
+    $violation_2 = "The path/URL '\"%s\"' contains invalid characters.";
     return [
       ['invalid://not-a-valid-protocol', [$violation_0]],
       ['http://www.example.com/', []],
@@ -88,7 +91,7 @@ protected function getTestLinks() {
       ["http://foo.com/blah_(wikipedia)#cite-1", []],
       ["http://foo.com/blah_(wikipedia)_blah#cite-1", []],
       // The following invalid URLs produce false positives.
-      ["http://foo.com/unicode_(✪)_in_parens", []],
+      ["http://foo.com/unicode_(✪)_in_parens", [$violation_2]],
       ["http://foo.com/(something)?after=parens", []],
       ["http://☺.damowmow.com/", []],
       ["http://code.google.com/events/#&product=browser", []],
@@ -97,10 +100,10 @@ protected function getTestLinks() {
       ["http://foo.bar/?q=Test%20URL-encoded%20stuff", []],
       ["http://مثال.إختبار", []],
       ["http://例子.测试", []],
-      ["http://-.~_!$&'()*+,;=:%40:80%2f::::::@example.com", []],
+      ["http://-.~_!$&'()*+,;=:%40:80%2f::::::@example.com", [$violation_2]],
       ["http://1337.net", []],
       ["http://a.b-c.de", []],
-      ["radar://1234", [$violation_0]],
+      ["radar://1234", [$violation_0]],
       ["h://test", [$violation_0]],
       ["ftps://foo.bar/", [$violation_0]],
       // Use invalid URLS from
