mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-11-27 14:59:27 +08:00
Merge pull request #5808 from ueberdosis/refactor/url-validation-and-autolink
refactor: adjust validate and add shouldAutoLink to improve URL handling
This commit is contained in:
commit
8a2e548c5b
6
.changeset/witty-olives-protect.md
Normal file
6
.changeset/witty-olives-protect.md
Normal file
@ -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.
|
@ -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: `
|
||||
|
@ -12,27 +12,27 @@ context('/src/Marks/Link/React/', () => {
|
||||
|
||||
it('should parse a tags correctly', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent('<p><a href="#">Example Text1</a></p>')
|
||||
editor.commands.setContent('<p><a href="https://example.com">Example Text1</a></p>')
|
||||
expect(editor.getHTML()).to.eq(
|
||||
'<p><a target="_blank" rel="noopener noreferrer nofollow" href="#">Example Text1</a></p>',
|
||||
'<p><a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com">Example Text1</a></p>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse a tags with target attribute correctly', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent('<p><a href="#" target="_self">Example Text2</a></p>')
|
||||
editor.commands.setContent('<p><a href="https://example.com" target="_self">Example Text2</a></p>')
|
||||
expect(editor.getHTML()).to.eq(
|
||||
'<p><a target="_self" rel="noopener noreferrer nofollow" href="#">Example Text2</a></p>',
|
||||
'<p><a target="_self" rel="noopener noreferrer nofollow" href="https://example.com">Example Text2</a></p>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse a tags with rel attribute correctly', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent('<p><a href="#" rel="follow">Example Text3</a></p>')
|
||||
editor.commands.setContent('<p><a href="https://example.com" rel="follow">Example Text3</a></p>')
|
||||
expect(editor.getHTML()).to.eq(
|
||||
'<p><a target="_blank" rel="follow" href="#">Example Text3</a></p>',
|
||||
'<p><a target="_blank" rel="follow" href="https://example.com">Example Text3</a></p>',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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('<p><a href="#" target="_self">Example Text2</a></p>')
|
||||
editor.commands.setContent('<p><a href="https://example.com" target="_self">Example Text2</a></p>')
|
||||
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(`<p><a href="${url}">Example Text</a></p>`)
|
||||
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(`<p><a href="${url}">Example Text</a></p>`)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
@ -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)) {
|
||||
|
@ -73,11 +73,25 @@ export interface LinkOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
|
||||
/**
|
||||
* 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<LinkProtocolOptions | string>, 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<LinkOptions>({
|
||||
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<LinkOptions>({
|
||||
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<LinkOptions>({
|
||||
|
||||
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<LinkOptions>({
|
||||
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<LinkOptions>({
|
||||
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user