CVE-2023-34245: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: @udecode/plate-link is the link handler for the udecode/plate rich-text editor plugin system for Slate & React. Affected versions of the link plugin and link UI component do not sanitize URLs to prevent use of the `javascript:` scheme. As a result, links with JavaScript URLs can be inserted into the Plate editor through various means, including opening or pasting malicious content. `@udecode/plate-link` 20.0.0 resolves this issue by introducing an `allowedSchemes` option to the link plugin, defaulting to `['http', 'https', 'mailto', 'tel']`. URLs using a scheme that isn't in this list will not be rendered to the DOM. Users are advised to upgrade. Users unable to upgrade are advised to override the `LinkElement` and `PlateFloatingLink` components with implementations that explicitly check the URL scheme before rendering any anchor elements.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2023-06-09T18:15Z
Repository: https://github.com/udecode/plate
Language: TypeScript
Files changed: .changeset/great-actors-work-core.md, .changeset/great-actors-work.md, .changeset/quiet-wombats-hug.md, docs/docs/plugins/link.mdx, packages/core/src/utils/misc/index.ts, packages/core/src/utils/misc/sanitizeUrl.spec.ts, packages/core/src/utils/misc/sanitizeUrl.ts, packages/nodes/link/src/components/FloatingLink/OpenLinkButton.tsx, packages/nodes/link/src/components/Link.tsx, packages/nodes/link/src/createLinkPlugin.ts, packages/nodes/link/src/transforms/submitFloatingLink.ts, packages/nodes/link/src/transforms/upsertLink.spec.tsx, packages/nodes/link/src/transforms/upsertLink.ts, packages/nodes/link/src/utils/getLinkAttributes.spec.ts, packages/nodes/link/src/utils/getLinkAttributes.ts, packages/nodes/link/src/utils/index.ts, packages/nodes/link/src/utils/validateUrl.ts
Commit date: 2023-02-28 14:26:56 +0000
Commit message: Apply isUrl option to pasted links #2239 (#2240)

* Apply isUrl option to pasted links #2239

* Sanitize link URLs

* Sanitize link URLs before rendering

* Fix broken tests

* Separate changesets

* Separate changeset for breaking change

---------

Co-authored-by: Joe Anderson <joe@mousetrapped.co.uk>
Diff: diff --git a/.changeset/great-actors-work-core.md b/.changeset/great-actors-work-core.md
new file mode 100644
index 000000000..f1f0ac362
--- /dev/null
+++ b/.changeset/great-actors-work-core.md
@@ -0,0 +1,5 @@
+---
+'@udecode/plate-core': minor
+---
+
+- Add `sanitizeUrl` util to check if URL has an allowed scheme
diff --git a/.changeset/great-actors-work.md b/.changeset/great-actors-work.md
new file mode 100644
index 000000000..082ea1ca4
--- /dev/null
+++ b/.changeset/great-actors-work.md
@@ -0,0 +1,12 @@
+---
+'@udecode/plate-link': minor
+---
+
+- `upsertLink`:
+  - Removed `isUrl`
+  - Added `skipValidation`
+- Check that URL scheme is valid when:
+  - Upserting links
+  - Deserializing links from HTL
+  - Passing `href` to `nodeProps`
+  - Rendering the `OpenLinkButton` in `FloatingLink`
diff --git a/.changeset/quiet-wombats-hug.md b/.changeset/quiet-wombats-hug.md
new file mode 100644
index 000000000..babf1f407
--- /dev/null
+++ b/.changeset/quiet-wombats-hug.md
@@ -0,0 +1,6 @@
+---
+'@udecode/plate-link': major
+---
+
+- Add `allowedSchemes` plugin option
+  - Any URL schemes other than `http(s)`, `mailto` and `tel` must be added to `allowedSchemes`, otherwise they will not be included in links
diff --git a/docs/docs/plugins/link.mdx b/docs/docs/plugins/link.mdx
index fffeedb65..fa6614dbf 100644
--- a/docs/docs/plugins/link.mdx
+++ b/docs/docs/plugins/link.mdx
@@ -45,10 +45,16 @@ interface LinkPlugin {
    * Hotkeys to trigger floating link.
    * @default 'meta+k, ctrl+k'
    */
   triggerFloatingLinkHotkeys?: string | string[];
 
+  /**
+   * List of allowed URL schemes.
+   * @default ['http', 'https', 'mailto', 'tel']
+   */
+  allowedSchemes?: string[];
+
   /**
    * Callback to validate an url.
    * @default isUrl
    */
   isUrl?: (text: string) => boolean;
diff --git a/packages/core/src/utils/misc/index.ts b/packages/core/src/utils/misc/index.ts
index b6844a00b..281801143 100644
--- a/packages/core/src/utils/misc/index.ts
+++ b/packages/core/src/utils/misc/index.ts
@@ -13,7 +13,8 @@ export * from './hotkeys';
 export * from './isUrl';
 export * from './jotai';
 export * from './mergeProps';
 export * from './nanoid';
 export * from './react-hotkeys-hook';
+export * from './sanitizeUrl';
 export * from './type-utils';
 export * from './zustood';
diff --git a/packages/core/src/utils/misc/sanitizeUrl.spec.ts b/packages/core/src/utils/misc/sanitizeUrl.spec.ts
new file mode 100644
index 000000000..3e6034990
--- /dev/null
+++ b/packages/core/src/utils/misc/sanitizeUrl.spec.ts
@@ -0,0 +1,47 @@
+import { sanitizeUrl } from './sanitizeUrl';
+
+describe('sanitizeUrl', () => {
+  describe('when permitInvalid is false', () => {
+    const options = {
+      allowedSchemes: ['http'],
+      permitInvalid: false,
+    };
+
+    it('should return null when url is invalid', () => {
+      expect(sanitizeUrl('invalid', options)).toBeNull();
+    });
+
+    it('should return null when url has disallowed scheme', () => {
+      // eslint-disable-next-line no-script-url
+      expect(sanitizeUrl('javascript://example.com/', options)).toBeNull();
+    });
+
+    it('should return url when url is valid', () => {
+      expect(sanitizeUrl('http://example.com/', options)).toBe(
+        'http://example.com/'
+      );
+    });
+  });
+
+  describe('when permitInvalid is true', () => {
+    const options = {
+      allowedSchemes: ['http'],
+      permitInvalid: true,
+    };
+
+    it('should return url when url is invalid', () => {
+      expect(sanitizeUrl('invalid', options)).toBe('invalid');
+    });
+
+    it('should return null when url has disallowed scheme', () => {
+      // eslint-disable-next-line no-script-url
+      expect(sanitizeUrl('javascript://example.com/', options)).toBeNull();
+    });
+
+    it('should return url when url is valid', () => {
+      expect(sanitizeUrl('http://example.com/', options)).toBe(
+        'http://example.com/'
+      );
+    });
+  });
+});
diff --git a/packages/core/src/utils/misc/sanitizeUrl.ts b/packages/core/src/utils/misc/sanitizeUrl.ts
new file mode 100644
index 000000000..936099755
--- /dev/null
+++ b/packages/core/src/utils/misc/sanitizeUrl.ts
@@ -0,0 +1,28 @@
+export interface SanitizeUrlOptions {
+  allowedSchemes?: string[];
+  permitInvalid?: boolean;
+}
+
+export const sanitizeUrl = (
+  url: string | undefined,
+  { allowedSchemes, permitInvalid = false }: SanitizeUrlOptions
+): string | null => {
+  if (!url) return null;
+
+  let parsedUrl: URL | null = null;
+
+  try {
+    parsedUrl = new URL(url);
+  } catch (error) {
+    return permitInvalid ? url : null;
+  }
+
+  if (
+    allowedSchemes &&
+    !allowedSchemes.includes(parsedUrl.protocol.slice(0, -1))
+  ) {
+    return null;
+  }
+
+  return parsedUrl.href;
+};
diff --git a/packages/nodes/link/src/components/FloatingLink/OpenLinkButton.tsx b/packages/nodes/link/src/components/FloatingLink/OpenLinkButton.tsx
index abf728be1..9f3cd6ee1 100644
--- a/packages/nodes/link/src/components/FloatingLink/OpenLinkButton.tsx
+++ b/packages/nodes/link/src/components/FloatingLink/OpenLinkButton.tsx
@@ -9,10 +9,11 @@ import {
   useEditorRef,
   usePlateSelection,
 } from '@udecode/plate-core';
 import { ELEMENT_LINK } from '../../createLinkPlugin';
 import { TLinkElement } from '../../types';
+import { getLinkAttributes } from '../../utils/index';
 
 export const useOpenLinkButton = (
   props: HTMLPropsAs<'a'>
 ): HTMLPropsAs<'a'> => {
   const editor = useEditorRef();
@@ -29,16 +30,17 @@ export const useOpenLinkButton = (
 
   if (!entry) {
     return {};
   }
 
-  const [link] = entry;
+  const [element] = entry;
+  const linkAttributes = getLinkAttributes(editor, element);
 
   return {
-    'aria-label': 'Open link in a new tab',
+    ...linkAttributes,
     target: '_blank',
-    href: link.url,
+    'aria-label': 'Open link in a new tab',
     onMouseOver: (e) => {
       e.stopPropagation();
     },
     ...props,
   };
diff --git a/packages/nodes/link/src/components/Link.tsx b/packages/nodes/link/src/components/Link.tsx
index e01d845f7..e6d2c2a73 100644
--- a/packages/nodes/link/src/components/Link.tsx
+++ b/packages/nodes/link/src/components/Link.tsx
@@ -5,21 +5,21 @@ import {
   PlateRenderElementProps,
   useElementProps,
   Value,
 } from '@udecode/plate-core';
 import { TLinkElement } from '../types';
+import { getLinkAttributes } from '../utils/index';
 
 export type LinkRootProps = PlateRenderElementProps<Value, TLinkElement> &
   HTMLPropsAs<'a'>;
 
 export const useLink = (props: LinkRootProps): HTMLPropsAs<'a'> => {
+  const { editor } = props;
+
   const _props = useElementProps<TLinkElement, 'a'>({
     ...props,
-    elementToAttributes: (element) => ({
-      href: element.url,
-      target: element.target,
-    }),
+    elementToAttributes: (element) => getLinkAttributes(editor, element),
   });
 
   return {
     ..._props,
     // quick fix: hovering <a> with href loses the editor focus
diff --git a/packages/nodes/link/src/createLinkPlugin.ts b/packages/nodes/link/src/createLinkPlugin.ts
index 5643a0457..133366c02 100644
--- a/packages/nodes/link/src/createLinkPlugin.ts
+++ b/packages/nodes/link/src/createLinkPlugin.ts
@@ -1,10 +1,12 @@
 import {
   createPluginFactory,
-  isUrl as isUrlProtocol,
+  isUrl,
   RangeBeforeOptions,
 } from '@udecode/plate-core';
+import { getLinkAttributes, validateUrl } from './utils/index';
+import { TLinkElement } from './types';
 import { withLink } from './withLink';
 
 export const ELEMENT_LINK = 'a';
 
 export interface LinkPlugin {
@@ -25,10 +27,16 @@ export interface LinkPlugin {
    * Hotkeys to trigger floating link.
    * @default 'meta+k, ctrl+k'
    */
   triggerFloatingLinkHotkeys?: string | string[];
 
+  /**
+   * List of allowed URL schemes.
+   * @default ['http', 'https', 'mailto', 'tel']
+   */
+  allowedSchemes?: string[];
+
   /**
    * Callback to validate an url.
    * @default isUrl
    */
   isUrl?: (text: string) => boolean;
@@ -51,33 +59,42 @@ export interface LinkPlugin {
  */
 export const createLinkPlugin = createPluginFactory<LinkPlugin>({
   key: ELEMENT_LINK,
   isElement: true,
   isInline: true,
-  props: ({ element }) => ({
-    nodeProps: { href: element?.url, target: element?.target },
-  }),
   withOverrides: withLink,
   options: {
-    isUrl: isUrlProtocol,
+    allowedSchemes: ['http', 'https', 'mailto', 'tel'],
+    isUrl,
     rangeBeforeOptions: {
       matchString: ' ',
       skipInvalid: true,
       afterMatch: true,
     },
     triggerFloatingLinkHotkeys: 'meta+k, ctrl+k',
   },
   then: (editor, { type }) => ({
+    props: ({ element }) => ({
+      nodeProps: getLinkAttributes(editor, element as TLinkElement),
+    }),
     deserializeHtml: {
       rules: [
         {
           validNodeName: 'A',
         },
       ],
-      getNode: (el) => ({
-        type,
-        url: el.getAttribute('href'),
-        target: el.getAttribute('target') || '_blank',
-      }),
+      getNode: (el) => {
+        const url = el.getAttribute('href');
+
+        if (url && validateUrl(editor, url)) {
+          return {
+            type,
+            url,
+            target: el.getAttribute('target') || '_blank',
+          };
+        }
+
+        return undefined;
+      },
     },
   }),
 });
diff --git a/packages/nodes/link/src/transforms/submitFloatingLink.ts b/packages/nodes/link/src/transforms/submitFloatingLink.ts
index eea0b9026..9e6c0bf0f 100644
--- a/packages/nodes/link/src/transforms/submitFloatingLink.ts
+++ b/packages/nodes/link/src/transforms/submitFloatingLink.ts
@@ -7,10 +7,11 @@ import {
 import {
   floatingLinkActions,
   floatingLinkSelectors,
 } from '../components/FloatingLink/floatingLinkStore';
 import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';
+import { validateUrl } from '../utils/index';
 import { upsertLink } from './index';
 
 /**
  * Insert link if url is valid.
  * Text is url if empty.
@@ -18,29 +19,25 @@ import { upsertLink } from './index';
  * Focus editor.
  */
 export const submitFloatingLink = <V extends Value>(editor: PlateEditor<V>) => {
   if (!editor.selection) return;
 
-  const { isUrl, forceSubmit } = getPluginOptions<LinkPlugin, V>(
-    editor,
-    ELEMENT_LINK
-  );
+  const { forceSubmit } = getPluginOptions<LinkPlugin, V>(editor, ELEMENT_LINK);
 
   const url = floatingLinkSelectors.url();
-  const isValid = isUrl?.(url) || forceSubmit;
-  if (!isValid) return;
+  if (!forceSubmit && !validateUrl(editor, url)) return;
 
   const text = floatingLinkSelectors.text();
   const target = floatingLinkSelectors.newTab() ? undefined : '_self';
 
   floatingLinkActions.hide();
 
   upsertLink(editor, {
     url,
     text,
     target,
-    isUrl: (_url) => (forceSubmit || !isUrl ? true : isUrl(_url)),
+    skipValidation: true,
   });
 
   setTimeout(() => {
     focusEditor(editor, editor.selection!);
   }, 0);
diff --git a/packages/nodes/link/src/transforms/upsertLink.spec.tsx b/packages/nodes/link/src/transforms/upsertLink.spec.tsx
index a6a7b8279..c38d6d03a 100644
--- a/packages/nodes/link/src/transforms/upsertLink.spec.tsx
+++ b/packages/nodes/link/src/transforms/upsertLink.spec.tsx
@@ -413,31 +413,61 @@ describe('upsertLink', () => {
         expect(input.children).toEqual(output.children);
       });
     });
   });
 
-  describe('when isUrl always true', () => {
+  describe('when skipValidation is false and url is invalid', () => {
     const input = (
       <editor>
         <hp>
           insert link
           <cursor />.
         </hp>
       </editor>
     ) as any;
 
     const output = (
+      <editor>
+        <hp>insert link.</hp>
+      </editor>
+    ) as any;
+
+    it('should do nothing', () => {
+      const editor = createEditor(input);
+      upsertLink(editor, {
+        url: 'invalid',
+        skipValidation: false,
+      });
+
+      expect(input.children).toEqual(output.children);
+    });
+  });
+
+  describe('when skipValidation is true and url is invalid', () => {
+    const input = (
       <editor>
         <hp>
-          insert link<ha url="test">test</ha>.
+          insert link
+          <cursor />.
+        </hp>
+      </editor>
+    ) as any;
+
+    const output = (
+      <editor>
+        <hp>
+          insert link<ha url="invalid">invalid</ha>.
         </hp>
       </editor>
     ) as any;
 
     it('should insert', () => {
       const editor = createEditor(input);
-      upsertLink(editor, { url: 'test', isUrl: (_url) => true });
+      upsertLink(editor, {
+        url: 'invalid',
+        skipValidation: true,
+      });
 
       expect(input.children).toEqual(output.children);
     });
   });
 });
diff --git a/packages/nodes/link/src/transforms/upsertLink.ts b/packages/nodes/link/src/transforms/upsertLink.ts
index 6079599a1..f1f51fb10 100644
--- a/packages/nodes/link/src/transforms/upsertLink.ts
+++ b/packages/nodes/link/src/transforms/upsertLink.ts
@@ -2,11 +2,10 @@ import {
   findNode,
   getAboveNode,
   getEditorString,
   getNodeLeaf,
   getNodeProps,
-  getPluginOptions,
   getPluginType,
   InsertNodesOptions,
   isDefined,
   isExpanded,
   PlateEditor,
@@ -14,13 +13,13 @@ import {
   setNodes,
   UnwrapNodesOptions,
   Value,
   WrapNodesOptions,
 } from '@udecode/plate-core';
-import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';
+import { ELEMENT_LINK } from '../createLinkPlugin';
 import { TLinkElement } from '../types';
-import { CreateLinkNodeOptions } from '../utils/index';
+import { CreateLinkNodeOptions, validateUrl } from '../utils/index';
 import { insertLink } from './insertLink';
 import { unwrapLink } from './unwrapLink';
 import { upsertLinkText } from './upsertLinkText';
 import { wrapLink } from './wrapLink';
 
@@ -32,11 +31,11 @@ export type UpsertLinkOptions<
    */
   insertTextInLink?: boolean;
   insertNodesOptions?: InsertNodesOptions<V>;
   unwrapNodesOptions?: UnwrapNodesOptions<V>;
   wrapNodesOptions?: WrapNodesOptions<V>;
-  isUrl?: (url: string) => boolean;
+  skipValidation?: boolean;
 };
 
 /**
  * If selection in a link or is not url:
  * - insert text with url, exit
@@ -51,11 +50,11 @@ export const upsertLink = <V extends Value>(
     url,
     text,
     target,
     insertTextInLink,
     insertNodesOptions,
-    isUrl = getPluginOptions<LinkPlugin, V>(editor, ELEMENT_LINK).isUrl,
+    skipValidation = false,
   }: UpsertLinkOptions<V>
 ) => {
   const at = editor.selection;
 
   if (!at) return;
@@ -70,11 +69,11 @@ export const upsertLink = <V extends Value>(
     // we don't want to insert marks in links
     editor.insertText(url);
     return true;
   }
 
-  if (!isUrl?.(url)) return;
+  if (!skipValidation && !validateUrl(editor, url)) return;
 
   if (isDefined(text) && !text.length) {
     text = url;
   }
 
diff --git a/packages/nodes/link/src/utils/getLinkAttributes.spec.ts b/packages/nodes/link/src/utils/getLinkAttributes.spec.ts
new file mode 100644
index 000000000..dcf5b9d3b
--- /dev/null
+++ b/packages/nodes/link/src/utils/getLinkAttributes.spec.ts
@@ -0,0 +1,60 @@
+import { createPlateEditor } from '@udecode/plate-core';
+import { createLinkPlugin, LinkPlugin } from '../createLinkPlugin';
+import { TLinkElement } from '../types';
+import { getLinkAttributes } from './getLinkAttributes';
+
+const baseLink = {
+  type: 'a',
+  children: [{ text: 'Link text' }],
+};
+
+describe('getLinkAttributes', () => {
+  const editor = createPlateEditor({
+    plugins: [createLinkPlugin()],
+  });
+
+  describe('when url is valid', () => {
+    const link: TLinkElement = {
+      ...baseLink,
+      url: 'https://example.com/',
+      target: '_self',
+    };
+
+    it('should return href and target', () => {
+      expect(getLinkAttributes(editor, link)).toEqual({
+        href: 'https://example.com/',
+        target: '_self',
+      });
+    });
+  });
+
+  describe('when url is invalid', () => {
+    const link: TLinkElement = {
+      ...baseLink,
+      // eslint-disable-next-line no-script-url
+      url: 'javascript://example.com/',
+      target: '_self',
+    };
+
+    it('href should be undefined', () => {
+      expect(getLinkAttributes(editor, link)).toEqual({
+        href: undefined,
+        target: '_self',
+      });
+    });
+  });
+
+  describe('when target is not set', () => {
+    const link: TLinkElement = {
+      ...baseLink,
+      url: 'https://example.com/',
+    };
+
+    it('target should be undefiend', () => {
+      expect(getLinkAttributes(editor, link)).toEqual({
+        href: 'https://example.com/',
+        target: undefined,
+      });
+    });
+  });
+});
diff --git a/packages/nodes/link/src/utils/getLinkAttributes.ts b/packages/nodes/link/src/utils/getLinkAttributes.ts
new file mode 100644
index 000000000..be0eed01b
--- /dev/null
+++ b/packages/nodes/link/src/utils/getLinkAttributes.ts
@@ -0,0 +1,23 @@
+import {
+  getPluginOptions,
+  PlateEditor,
+  sanitizeUrl,
+  Value,
+} from '@udecode/plate-core';
+import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';
+import { TLinkElement } from '../types';
+
+export const getLinkAttributes = <V extends Value>(
+  editor: PlateEditor<V>,
+  link: TLinkElement
+) => {
+  const { allowedSchemes } = getPluginOptions<LinkPlugin, V>(
+    editor,
+    ELEMENT_LINK
+  );
+
+  const href = sanitizeUrl(link.url, { allowedSchemes }) || undefined;
+  const { target } = link;
+
+  return { href, target };
+};
diff --git a/packages/nodes/link/src/utils/index.ts b/packages/nodes/link/src/utils/index.ts
index ae3a4a3bb..0a74983a5 100644
--- a/packages/nodes/link/src/utils/index.ts
+++ b/packages/nodes/link/src/utils/index.ts
@@ -1,8 +1,10 @@
 /**
  * @file Automatically generated by barrelsby.
  */
 
 export * from './createLinkNode';
+export * from './getLinkAttributes';
 export * from './triggerFloatingLink';
 export * from './triggerFloatingLinkEdit';
 export * from './triggerFloatingLinkInsert';
+export * from './validateUrl';
diff --git a/packages/nodes/link/src/utils/validateUrl.ts b/packages/nodes/link/src/utils/validateUrl.ts
new file mode 100644
index 000000000..1a7c85ea6
--- /dev/null
+++ b/packages/nodes/link/src/utils/validateUrl.ts
@@ -0,0 +1,29 @@
+import {
+  getPluginOptions,
+  PlateEditor,
+  sanitizeUrl,
+  Value,
+} from '@udecode/plate-core';
+import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';
+
+export const validateUrl = <V extends Value>(
+  editor: PlateEditor<V>,
+  url: string
+): boolean => {
+  const { allowedSchemes, isUrl } = getPluginOptions<LinkPlugin, V>(
+    editor,
+    ELEMENT_LINK
+  );
+
+  if (isUrl && !isUrl(url)) return false;
+
+  if (
+    !sanitizeUrl(url, {
+      allowedSchemes,
+      permitInvalid: true,
+    })
+  )
+    return false;
+
+  return true;
+};

Vulnerable code:   const [link] = entry;
    'aria-label': 'Open link in a new tab',
    href: link.url,
    elementToAttributes: (element) => ({
      href: element.url,
      target: element.target,
    }),
  isUrl as isUrlProtocol,
  props: ({ element }) => ({
    nodeProps: { href: element?.url, target: element?.target },
  }),
    isUrl: isUrlProtocol,
      getNode: (el) => ({
        type,
        url: el.getAttribute('href'),
        target: el.getAttribute('target') || '_blank',
      }),
  const { isUrl, forceSubmit } = getPluginOptions<LinkPlugin, V>(
    editor,
    ELEMENT_LINK
  );
  const isValid = isUrl?.(url) || forceSubmit;
  if (!isValid) return;
    isUrl: (_url) => (forceSubmit || !isUrl ? true : isUrl(_url)),
  describe('when isUrl always true', () => {
          insert link<ha url="test">test</ha>.
      upsertLink(editor, { url: 'test', isUrl: (_url) => true });
  getPluginOptions,
import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';
import { CreateLinkNodeOptions } from '../utils/index';
  isUrl?: (url: string) => boolean;
    isUrl = getPluginOptions<LinkPlugin, V>(editor, ELEMENT_LINK).isUrl,
  if (!isUrl?.(url)) return;
Fixed code: ---
'@udecode/plate-core': minor
---

- Add `sanitizeUrl` util to check if URL has an allowed scheme
---
'@udecode/plate-link': minor
---

- `upsertLink`:
  - Removed `isUrl`
  - Added `skipValidation`
- Check that URL scheme is valid when:
  - Upserting links
  - Deserializing links from HTL
  - Passing `href` to `nodeProps`
  - Rendering the `OpenLinkButton` in `FloatingLink`
---
'@udecode/plate-link': major
---

- Add `allowedSchemes` plugin option
  - Any URL schemes other than `http(s)`, `mailto` and `tel` must be added to `allowedSchemes`, otherwise they will not be included in links
  /**
   * List of allowed URL schemes.
   * @default ['http', 'https', 'mailto', 'tel']
   */
  allowedSchemes?: string[];

export * from './sanitizeUrl';
import { sanitizeUrl } from './sanitizeUrl';

describe('sanitizeUrl', () => {
  describe('when permitInvalid is false', () => {
    const options = {
      allowedSchemes: ['http'],
      permitInvalid: false,
    };

    it('should return null when url is invalid', () => {
      expect(sanitizeUrl('invalid', options)).toBeNull();
    });

    it('should return null when url has disallowed scheme', () => {
      // eslint-disable-next-line no-script-url
      expect(sanitizeUrl('javascript://example.com/', options)).toBeNull();
    });

    it('should return url when url is valid', () => {
      expect(sanitizeUrl('http://example.com/', options)).toBe(
        'http://example.com/'
      );
    });
  });

  describe('when permitInvalid is true', () => {
    const options = {
      allowedSchemes: ['http'],
      permitInvalid: true,
    };

    it('should return url when url is invalid', () => {
      expect(sanitizeUrl('invalid', options)).toBe('invalid');
    });

    it('should return null when url has disallowed scheme', () => {
      // eslint-disable-next-line no-script-url
      expect(sanitizeUrl('javascript://example.com/', options)).toBeNull();
    });

    it('should return url when url is valid', () => {
      expect(sanitizeUrl('http://example.com/', options)).toBe(
        'http://example.com/'
      );
    });
  });
});
export interface SanitizeUrlOptions {
  allowedSchemes?: string[];
  permitInvalid?: boolean;
}

export const sanitizeUrl = (
  url: string | undefined,
  { allowedSchemes, permitInvalid = false }: SanitizeUrlOptions
): string | null => {
  if (!url) return null;

  let parsedUrl: URL | null = null;

  try {
    parsedUrl = new URL(url);
  } catch (error) {
    return permitInvalid ? url : null;
  }

  if (
    allowedSchemes &&
    !allowedSchemes.includes(parsedUrl.protocol.slice(0, -1))
  ) {
    return null;
  }

  return parsedUrl.href;
};
import { getLinkAttributes } from '../../utils/index';
  const [element] = entry;
  const linkAttributes = getLinkAttributes(editor, element);
    ...linkAttributes,
    'aria-label': 'Open link in a new tab',
import { getLinkAttributes } from '../utils/index';
  const { editor } = props;

    elementToAttributes: (element) => getLinkAttributes(editor, element),
  isUrl,
import { getLinkAttributes, validateUrl } from './utils/index';
import { TLinkElement } from './types';
  /**
   * List of allowed URL schemes.
   * @default ['http', 'https', 'mailto', 'tel']
   */
  allowedSchemes?: string[];

    allowedSchemes: ['http', 'https', 'mailto', 'tel'],
    isUrl,
    props: ({ element }) => ({
      nodeProps: getLinkAttributes(editor, element as TLinkElement),
    }),
      getNode: (el) => {
        const url = el.getAttribute('href');

        if (url && validateUrl(editor, url)) {
          return {
            type,
            url,
            target: el.getAttribute('target') || '_blank',
          };
        }

        return undefined;
      },
import { validateUrl } from '../utils/index';
  const { forceSubmit } = getPluginOptions<LinkPlugin, V>(editor, ELEMENT_LINK);
  if (!forceSubmit && !validateUrl(editor, url)) return;
    skipValidation: true,
  describe('when skipValidation is false and url is invalid', () => {
      <editor>
        <hp>insert link.</hp>
      </editor>
    ) as any;

    it('should do nothing', () => {
      const editor = createEditor(input);
      upsertLink(editor, {
        url: 'invalid',
        skipValidation: false,
      });

      expect(input.children).toEqual(output.children);
    });
  });

  describe('when skipValidation is true and url is invalid', () => {
    const input = (
          insert link
          <cursor />.
        </hp>
      </editor>
    ) as any;

    const output = (
      <editor>
        <hp>
          insert link<ha url="invalid">invalid</ha>.
      upsertLink(editor, {
        url: 'invalid',
        skipValidation: true,
      });
import { ELEMENT_LINK } from '../createLinkPlugin';
import { CreateLinkNodeOptions, validateUrl } from '../utils/index';
  skipValidation?: boolean;
    skipValidation = false,
  if (!skipValidation && !validateUrl(editor, url)) return;
import { createPlateEditor } from '@udecode/plate-core';
import { createLinkPlugin, LinkPlugin } from '../createLinkPlugin';
import { TLinkElement } from '../types';
import { getLinkAttributes } from './getLinkAttributes';

const baseLink = {
  type: 'a',
  children: [{ text: 'Link text' }],
};

describe('getLinkAttributes', () => {
  const editor = createPlateEditor({
    plugins: [createLinkPlugin()],
  });

  describe('when url is valid', () => {
    const link: TLinkElement = {
      ...baseLink,
      url: 'https://example.com/',
      target: '_self',
    };

    it('should return href and target', () => {
      expect(getLinkAttributes(editor, link)).toEqual({
        href: 'https://example.com/',
        target: '_self',
      });
    });
  });

  describe('when url is invalid', () => {
    const link: TLinkElement = {
      ...baseLink,
      // eslint-disable-next-line no-script-url
      url: 'javascript://example.com/',
      target: '_self',
    };

    it('href should be undefined', () => {
      expect(getLinkAttributes(editor, link)).toEqual({
        href: undefined,
        target: '_self',
      });
    });
  });

  describe('when target is not set', () => {
    const link: TLinkElement = {
      ...baseLink,
      url: 'https://example.com/',
    };

    it('target should be undefiend', () => {
      expect(getLinkAttributes(editor, link)).toEqual({
        href: 'https://example.com/',
        target: undefined,
      });
    });
  });
});
import {
  getPluginOptions,
  PlateEditor,
  sanitizeUrl,
  Value,
} from '@udecode/plate-core';
import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';
import { TLinkElement } from '../types';

export const getLinkAttributes = <V extends Value>(
  editor: PlateEditor<V>,
  link: TLinkElement
) => {
  const { allowedSchemes } = getPluginOptions<LinkPlugin, V>(
    editor,
    ELEMENT_LINK
  );

  const href = sanitizeUrl(link.url, { allowedSchemes }) || undefined;
  const { target } = link;

  return { href, target };
};
export * from './getLinkAttributes';
export * from './validateUrl';
import {
  getPluginOptions,
  PlateEditor,
  sanitizeUrl,
  Value,
} from '@udecode/plate-core';
import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';

export const validateUrl = <V extends Value>(
  editor: PlateEditor<V>,
  url: string
): boolean => {
  const { allowedSchemes, isUrl } = getPluginOptions<LinkPlugin, V>(
    editor,
    ELEMENT_LINK
  );

  if (isUrl && !isUrl(url)) return false;

  if (
    !sanitizeUrl(url, {
      allowedSchemes,
      permitInvalid: true,
    })
  )
    return false;

  return true;
};