VYPR
High severityNVD Advisory· Published Jun 9, 2023· Updated Jan 6, 2025

Cross site scripting (XSS) in @udecode/plate-link

CVE-2023-34245

Description

CVE-2023-34245 is an XSS vulnerability in @udecode/plate-link due to missing URL scheme sanitization, allowing javascript: URLs to be inserted and executed.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

CVE-2023-34245 is an XSS vulnerability in @udecode/plate-link due to missing URL scheme sanitization, allowing `javascript:` URLs to be inserted and executed.

Vulnerability

Description CVE-2023-34245 affects the @udecode/plate-link plugin, part of the Plate rich-text editor framework for Slate and React. The plugin fails to sanitize URLs, allowing the use of the javascript: scheme. This means that any URL with a javascript: scheme can be inserted into the editor's content, leading to potential cross-site scripting (XSS) attacks [1].

Exploitation

An attacker can exploit this vulnerability by inserting a link with a javascript: URL into the editor. This can be done through various means such as opening a crafted link or pasting malicious content. The vulnerability does not require authentication, but it relies on user interaction, such as clicking on the link, to trigger the JavaScript execution [1].

Impact

If successfully exploited, an attacker can execute arbitrary JavaScript in the context of the user's browser session. This could lead to data theft, session hijacking, or other malicious actions depending on the application's security context [1].

Mitigation

The vulnerability is patched in version 20.0.0 of @udecode/plate-link. The fix introduces an allowedSchemes option that defaults to ['http', 'https', 'mailto', 'tel']. Any URL with a scheme not in this list will not be rendered to the DOM, effectively blocking javascript: and other dangerous schemes. Users unable to upgrade should override the LinkElement and PlateFloatingLink components to validate URL schemes before rendering anchor elements [1][2][3].

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@udecode/plate-linknpm
< 20.0.020.0.0

Affected products

2

Patches

1
93dd57128546

Apply isUrl option to pasted links #2239 (#2240)

https://github.com/udecode/plateOliFeb 28, 2023via ghsa
17 files changed · +297 33
  • .changeset/great-actors-work-core.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +'@udecode/plate-core': minor
    +---
    +
    +- Add `sanitizeUrl` util to check if URL has an allowed scheme
    
  • .changeset/great-actors-work.md+12 0 added
    @@ -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`
    
  • .changeset/quiet-wombats-hug.md+6 0 added
    @@ -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
    
  • docs/docs/plugins/link.mdx+6 0 modified
    @@ -47,6 +47,12 @@ interface LinkPlugin {
        */
       triggerFloatingLinkHotkeys?: string | string[];
     
    +  /**
    +   * List of allowed URL schemes.
    +   * @default ['http', 'https', 'mailto', 'tel']
    +   */
    +  allowedSchemes?: string[];
    +
       /**
        * Callback to validate an url.
        * @default isUrl
    
  • packages/core/src/utils/misc/index.ts+1 0 modified
    @@ -15,5 +15,6 @@ export * from './jotai';
     export * from './mergeProps';
     export * from './nanoid';
     export * from './react-hotkeys-hook';
    +export * from './sanitizeUrl';
     export * from './type-utils';
     export * from './zustood';
    
  • packages/core/src/utils/misc/sanitizeUrl.spec.ts+47 0 added
    @@ -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/'
    +      );
    +    });
    +  });
    +});
    
  • packages/core/src/utils/misc/sanitizeUrl.ts+28 0 added
    @@ -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;
    +};
    
  • packages/nodes/link/src/components/FloatingLink/OpenLinkButton.tsx+5 3 modified
    @@ -11,6 +11,7 @@ import {
     } from '@udecode/plate-core';
     import { ELEMENT_LINK } from '../../createLinkPlugin';
     import { TLinkElement } from '../../types';
    +import { getLinkAttributes } from '../../utils/index';
     
     export const useOpenLinkButton = (
       props: HTMLPropsAs<'a'>
    @@ -31,12 +32,13 @@ export const useOpenLinkButton = (
         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();
         },
    
  • packages/nodes/link/src/components/Link.tsx+4 4 modified
    @@ -7,17 +7,17 @@ import {
       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 {
    
  • packages/nodes/link/src/createLinkPlugin.ts+27 10 modified
    @@ -1,8 +1,10 @@
     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';
    @@ -27,6 +29,12 @@ export interface LinkPlugin {
        */
       triggerFloatingLinkHotkeys?: string | string[];
     
    +  /**
    +   * List of allowed URL schemes.
    +   * @default ['http', 'https', 'mailto', 'tel']
    +   */
    +  allowedSchemes?: string[];
    +
       /**
        * Callback to validate an url.
        * @default isUrl
    @@ -53,12 +61,10 @@ 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,
    @@ -67,17 +73,28 @@ export const createLinkPlugin = createPluginFactory<LinkPlugin>({
         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;
    +      },
         },
       }),
     });
    
  • packages/nodes/link/src/transforms/submitFloatingLink.ts+4 7 modified
    @@ -9,6 +9,7 @@ import {
       floatingLinkSelectors,
     } from '../components/FloatingLink/floatingLinkStore';
     import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';
    +import { validateUrl } from '../utils/index';
     import { upsertLink } from './index';
     
     /**
    @@ -20,14 +21,10 @@ import { upsertLink } from './index';
     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';
    @@ -38,7 +35,7 @@ export const submitFloatingLink = <V extends Value>(editor: PlateEditor<V>) => {
         url,
         text,
         target,
    -    isUrl: (_url) => (forceSubmit || !isUrl ? true : isUrl(_url)),
    +    skipValidation: true,
       });
     
       setTimeout(() => {
    
  • packages/nodes/link/src/transforms/upsertLink.spec.tsx+33 3 modified
    @@ -415,7 +415,7 @@ describe('upsertLink', () => {
         });
       });
     
    -  describe('when isUrl always true', () => {
    +  describe('when skipValidation is false and url is invalid', () => {
         const input = (
           <editor>
             <hp>
    @@ -426,16 +426,46 @@ describe('upsertLink', () => {
         ) 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);
         });
    
  • packages/nodes/link/src/transforms/upsertLink.ts+5 6 modified
    @@ -4,7 +4,6 @@ import {
       getEditorString,
       getNodeLeaf,
       getNodeProps,
    -  getPluginOptions,
       getPluginType,
       InsertNodesOptions,
       isDefined,
    @@ -16,9 +15,9 @@ import {
       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';
    @@ -34,7 +33,7 @@ export type UpsertLinkOptions<
       insertNodesOptions?: InsertNodesOptions<V>;
       unwrapNodesOptions?: UnwrapNodesOptions<V>;
       wrapNodesOptions?: WrapNodesOptions<V>;
    -  isUrl?: (url: string) => boolean;
    +  skipValidation?: boolean;
     };
     
     /**
    @@ -53,7 +52,7 @@ export const upsertLink = <V extends Value>(
         target,
         insertTextInLink,
         insertNodesOptions,
    -    isUrl = getPluginOptions<LinkPlugin, V>(editor, ELEMENT_LINK).isUrl,
    +    skipValidation = false,
       }: UpsertLinkOptions<V>
     ) => {
       const at = editor.selection;
    @@ -72,7 +71,7 @@ export const upsertLink = <V extends Value>(
         return true;
       }
     
    -  if (!isUrl?.(url)) return;
    +  if (!skipValidation && !validateUrl(editor, url)) return;
     
       if (isDefined(text) && !text.length) {
         text = url;
    
  • packages/nodes/link/src/utils/getLinkAttributes.spec.ts+60 0 added
    @@ -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,
    +      });
    +    });
    +  });
    +});
    
  • packages/nodes/link/src/utils/getLinkAttributes.ts+23 0 added
    @@ -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 };
    +};
    
  • packages/nodes/link/src/utils/index.ts+2 0 modified
    @@ -3,6 +3,8 @@
      */
     
     export * from './createLinkNode';
    +export * from './getLinkAttributes';
     export * from './triggerFloatingLink';
     export * from './triggerFloatingLinkEdit';
     export * from './triggerFloatingLinkInsert';
    +export * from './validateUrl';
    
  • packages/nodes/link/src/utils/validateUrl.ts+29 0 added
    @@ -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;
    +};
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.