diff --git a/core/modules/link/src/Plugin/Field/FieldType/LinkItem.php b/core/modules/link/src/Plugin/Field/FieldType/LinkItem.php
index 2849b0ac..cd3eabb7 100644
--- a/core/modules/link/src/Plugin/Field/FieldType/LinkItem.php
+++ b/core/modules/link/src/Plugin/Field/FieldType/LinkItem.php
@@ -28,6 +28,7 @@
     "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 00000000..0789fc33
--- /dev/null
+++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkUriValidConstraint.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\link\Plugin\Validation\Constraint;
+
+use Drupal\Core\Validation\Attribute\Constraint;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Symfony\Component\Validator\Constraints\Url as SymfonyConstraint;
+
+/**
+ * Defines a validation constraint for invalid characters in the URI.
+ */
+#[Constraint(
+  id: 'LinkUriValid',
+  label: new TranslatableMarkup('No invalid characters in the URI field', [], ['context' => 'Validation']),
+)]
+class LinkUriValidConstraint extends SymfonyConstraint {
+
+  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 00000000..dbd02764
--- /dev/null
+++ b/core/modules/link/src/Plugin/Validation/Constraint/LinkUriValidConstraintValidator.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\link\Plugin\Validation\Constraint;
+
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\Utility\Error;
+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();
+        $parts = parse_url($url) + ['scheme' => ''];
+        // Disallow external URLs using untrusted protocols.
+        if (!$urlObject->isExternal() || in_array($parts['scheme'], UrlHelper::getAllowedProtocols())) {
+          $this->context->setNode(
+            $this->context->getValue(),
+            $this->context->getObject(),
+            $this->context->getMetadata(),
+            $this->context->getPropertyPath() . '.uri'
+          );
+          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)
+            ->atPath('uri')
+            ->addViolation();
+        }
+      }
+      catch (\Exception $e) {
+        $variables = Error::decodeException($e);
+        \Drupal::logger('update')->error('%type: @message in %function (line %line of %file).', $variables);
+      }
+    }
+  }
+
+}
diff --git a/core/modules/link/tests/src/Functional/LinkFieldTest.php b/core/modules/link/tests/src/Functional/LinkFieldTest.php
index c619a772..b2bea16f 100644
--- a/core/modules/link/tests/src/Functional/LinkFieldTest.php
+++ b/core/modules/link/tests/src/Functional/LinkFieldTest.php
@@ -167,10 +167,6 @@ protected function doTestURLValidation() {
       '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.
@@ -199,6 +195,10 @@ protected function doTestURLValidation() {
       '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.
@@ -244,7 +244,7 @@ protected function assertValidEntries(string $field_name, array $valid_entries):
       $this->drupalGet('entity_test/add');
       $this->submitForm($edit, 'Save');
       preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match);
-      $id = $match[1];
+      $id = !empty($match) ? $match[1] : '';
       $this->assertSession()->statusMessageContains('entity_test ' . $id . ' has been created.', 'status');
       $this->assertSession()->responseContains('"' . $string . '"');
     }
@@ -436,13 +436,13 @@ protected function doTestLinkFormatter() {
     // Create an entity with three link field values:
     // - The first field item uses a URL only.
     // - The second field item uses a URL and link text.
-    // - The third field item uses a fragment-only URL with text.
+    // - The third field item uses a relative URL with a fragment with text.
     // For consistency in assertion code below, the URL is assigned to the title
     // variable for the first field.
     $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 = '/#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';
@@ -771,13 +771,13 @@ protected function doTestLinkSeparateFormatter() {
     // Create an entity with three link field values:
     // - The first field item uses a URL only.
     // - The second field item uses a URL and link text.
-    // - The third field item uses a fragment-only URL with text.
+    // - The third field item uses a relative URL with a fragment with text.
     // For consistency in assertion code below, the URL is assigned to the title
     // variable for the first field.
     $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';
@@ -1008,7 +1008,7 @@ public function testNoLinkUri(): void {
     $this->drupalGet('/entity_test/add');
     $this->submitForm($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 2b7f0da2..24bad82a 100644
--- a/core/modules/link/tests/src/Kernel/LinkItemUrlValidationTest.php
+++ b/core/modules/link/tests/src/Kernel/LinkItemUrlValidationTest.php
@@ -40,7 +40,9 @@ public function testExternalLinkValidation(): void {
           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);
         }
       }
     }
@@ -56,6 +58,7 @@ public function testExternalLinkValidation(): void {
   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/', []],
@@ -90,7 +93,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", []],
@@ -99,7 +102,7 @@ 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]],
