diff --git a/.changeset/witty-olives-protect.md b/.changeset/witty-olives-protect.md new file mode 100644 index 000000000..0893a2fde --- /dev/null +++ b/.changeset/witty-olives-protect.md @@ -0,0 +1,6 @@ +--- +"@tiptap/extension-link": patch +"tiptap-demos": patch +--- + +The link extension's `validate` option now applies to both auto-linking and XSS mitigation. While, the new `shouldAutoLink` option is used to disable auto linking on an otherwise valid url. diff --git a/demos/src/Marks/Link/React/index.jsx b/demos/src/Marks/Link/React/index.jsx index 452bb5b4b..d9a57661a 100644 --- a/demos/src/Marks/Link/React/index.jsx +++ b/demos/src/Marks/Link/React/index.jsx @@ -20,6 +20,61 @@ export default () => { openOnClick: false, autolink: true, defaultProtocol: 'https', + protocols: ['http', 'https'], + validate: (url, ctx) => { + try { + // construct URL + const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`) + + // use default validation + if (!ctx.defaultValidate(parsedUrl.href)) { + return false + } + + // disallowed protocols + const disallowedProtocols = ['ftp', 'file', 'mailto'] + const protocol = parsedUrl.protocol.replace(':', '') + + if (disallowedProtocols.includes(protocol)) { + return false + } + + // only allow protocols specified in ctx.protocols + const allowedProtocols = ctx.protocols.map(p => (typeof p === 'string' ? p : p.scheme)) + + if (!allowedProtocols.includes(protocol)) { + return false + } + + // disallowed domains + const disallowedDomains = ['example-phishing.com', 'malicious-site.net'] + const domain = parsedUrl.hostname + + if (disallowedDomains.includes(domain)) { + return false + } + + // all checks have passed + return true + } catch (error) { + return false + } + }, + shouldAutoLink: url => { + try { + // construct URL + const parsedUrl = url.includes(':') ? new URL(url) : new URL(`https://${url}`) + + // only auto-link if the domain is not in the disallowed list + const disallowedDomains = ['example-no-autolink.com', 'another-no-autolink.com'] + const domain = parsedUrl.hostname + + return !disallowedDomains.includes(domain) + } catch (error) { + return false + } + }, + }), ], content: ` diff --git a/demos/src/Marks/Link/React/index.spec.js b/demos/src/Marks/Link/React/index.spec.js index 61fd1b281..ea5bf9057 100644 --- a/demos/src/Marks/Link/React/index.spec.js +++ b/demos/src/Marks/Link/React/index.spec.js @@ -12,27 +12,27 @@ context('/src/Marks/Link/React/', () => { it('should parse a tags correctly', () => { cy.get('.tiptap').then(([{ editor }]) => { - editor.commands.setContent('

Example Text1

') + editor.commands.setContent('

Example Text1

') expect(editor.getHTML()).to.eq( - '

Example Text1

', + '

Example Text1

', ) }) }) it('should parse a tags with target attribute correctly', () => { cy.get('.tiptap').then(([{ editor }]) => { - editor.commands.setContent('

Example Text2

') + editor.commands.setContent('

Example Text2

') expect(editor.getHTML()).to.eq( - '

Example Text2

', + '

Example Text2

', ) }) }) it('should parse a tags with rel attribute correctly', () => { cy.get('.tiptap').then(([{ editor }]) => { - editor.commands.setContent('

Example Text3

') + editor.commands.setContent('

Example Text3

') expect(editor.getHTML()).to.eq( - '

Example Text3

', + '

Example Text3

', ) }) }) @@ -54,7 +54,7 @@ context('/src/Marks/Link/React/', () => { it('should allow exiting the link once set', () => { cy.get('.tiptap').then(([{ editor }]) => { - editor.commands.setContent('

Example Text2

') + editor.commands.setContent('

Example Text2

') cy.get('.tiptap').type('{rightArrow}') cy.get('button:first').should('not.have.class', 'is-active') @@ -129,4 +129,32 @@ context('/src/Marks/Link/React/', () => { .find('a[href="http://example3.com/foobar"]') .should('contain', 'http://example3.com/foobar') }) + + it('should not allow links with disallowed protocols', () => { + const disallowedProtocols = ['ftp://example.com', 'file:///example.txt', 'mailto:test@example.com'] + + disallowedProtocols.forEach(url => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent(`

Example Text

`) + expect(editor.getHTML()).to.not.include(url) + }) + }) + }) + + it('should not allow links with disallowed domains', () => { + const disallowedDomains = ['https://example-phishing.com', 'https://malicious-site.net'] + + disallowedDomains.forEach(url => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent(`

Example Text

`) + expect(editor.getHTML()).to.not.include(url) + }) + }) + }) + + it('should not auto-link a URL from a disallowed domain', () => { + cy.get('.tiptap').type('https://example-phishing.com ') // disallowed domain + cy.get('.tiptap').should('not.have.descendants', 'a') + cy.get('.tiptap').should('contain.text', 'https://example-phishing.com') + }) }) diff --git a/packages/extension-link/src/helpers/autolink.ts b/packages/extension-link/src/helpers/autolink.ts index c15efa9a1..7b57007a5 100644 --- a/packages/extension-link/src/helpers/autolink.ts +++ b/packages/extension-link/src/helpers/autolink.ts @@ -35,6 +35,7 @@ type AutolinkOptions = { type: MarkType defaultProtocol: string validate: (url: string) => boolean + shouldAutoLink: (url: string) => boolean } /** @@ -144,6 +145,8 @@ export function autolink(options: AutolinkOptions): Plugin { }) // validate link .filter(link => options.validate(link.value)) + // check whether should autolink + .filter(link => options.shouldAutoLink(link.value)) // Add link mark. .forEach(link => { if (getMarksBetween(link.from, link.to, newState.doc).some(item => item.mark.type === options.type)) { diff --git a/packages/extension-link/src/link.ts b/packages/extension-link/src/link.ts index f78dc1db0..763684882 100644 --- a/packages/extension-link/src/link.ts +++ b/packages/extension-link/src/link.ts @@ -73,11 +73,25 @@ export interface LinkOptions { HTMLAttributes: Record /** - * A validation function that modifies link verification for the auto linker. - * @param url - The url to be validated. - * @returns - True if the url is valid, false otherwise. + * A validation function that modifies link verification. + * + * @param {string} url - The URL to be validated. + * @param {Object} ctx - An object containing: + * @param {Function} ctx.defaultValidate - A function that performs the default URL validation. + * @param {string[]} ctx.protocols - An array of allowed protocols for the URL (e.g., "http", "https"). + * @param {string} ctx.defaultProtocol - A string that represents the default protocol (e.g., 'http'). + * + * @returns {boolean} True if the URL is valid, false otherwise. */ - validate: (url: string) => boolean + validate: (url: string, ctx: { defaultValidate: (url: string) => boolean, protocols: Array, defaultProtocol: string }) => boolean + + /** + * Determines whether a valid link should be automatically linked in the content. + * + * @param {string} url - The URL that has already been validated. + * @returns {boolean} - True if the link should be auto-linked; false if it should not be auto-linked. + */ + shouldAutoLink: (url: string) => boolean } declare module '@tiptap/core' { @@ -169,7 +183,8 @@ export const Link = Mark.create({ rel: 'noopener noreferrer nofollow', class: null, }, - validate: url => !!url, + validate: (url, ctx) => !!isAllowedUri(url, ctx.protocols), + shouldAutoLink: url => !!url, } }, @@ -200,7 +215,7 @@ export const Link = Mark.create({ const href = (dom as HTMLElement).getAttribute('href') // prevent XSS attacks - if (!href || !isAllowedUri(href, this.options.protocols)) { + if (!href || !this.options.validate(href, { defaultValidate: url => !!isAllowedUri(url, this.options.protocols), protocols: this.options.protocols, defaultProtocol: this.options.defaultProtocol })) { return false } return null @@ -210,7 +225,7 @@ export const Link = Mark.create({ renderHTML({ HTMLAttributes }) { // prevent XSS attacks - if (!isAllowedUri(HTMLAttributes.href, this.options.protocols)) { + if (!this.options.validate(HTMLAttributes.href, { defaultValidate: href => !!isAllowedUri(href, this.options.protocols), protocols: this.options.protocols, defaultProtocol: this.options.defaultProtocol })) { // strip out the href return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0] } @@ -250,8 +265,8 @@ export const Link = Mark.create({ const foundLinks: PasteRuleMatch[] = [] if (text) { - const { validate } = this.options - const links = find(text).filter(item => item.isLink && validate(item.value)) + const { validate, protocols, defaultProtocol } = this.options + const links = find(text).filter(item => item.isLink && validate(item.value, { defaultValidate: href => !!isAllowedUri(href, protocols), protocols, defaultProtocol })) if (links.length) { links.forEach(link => (foundLinks.push({ @@ -278,13 +293,15 @@ export const Link = Mark.create({ addProseMirrorPlugins() { const plugins: Plugin[] = [] + const { validate, protocols, defaultProtocol } = this.options if (this.options.autolink) { plugins.push( autolink({ type: this.type, defaultProtocol: this.options.defaultProtocol, - validate: this.options.validate, + validate: url => validate(url, { defaultValidate: href => !!isAllowedUri(href, protocols), protocols, defaultProtocol }), + shouldAutoLink: this.options.shouldAutoLink, }), ) }