CVE-2023-34245 (TypeScript, published 2023-06-09T18:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 6.1)
Weakness: CWE-79 \u2014 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
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.
Repository: https://github.com/udecode/plate
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 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>

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;
};