Cross site scripting (XSS) in @udecode/plate-link
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.
| Package | Affected versions | Patched versions |
|---|---|---|
@udecode/plate-linknpm | < 20.0.0 | 20.0.0 |
Affected products
2Patches
193dd57128546Apply isUrl option to pasted links #2239 (#2240)
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- github.com/advisories/GHSA-4882-hxpr-hrvmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-34245ghsaADVISORY
- github.com/udecode/plate/commit/93dd5712854660874900ae12e4d8e6ff28089eb7ghsax_refsource_MISCWEB
- github.com/udecode/plate/pull/2240ghsaWEB
- github.com/udecode/plate/security/advisories/GHSA-4882-hxpr-hrvmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.