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:
Armando Guarino 2024-11-06 15:14:25 +01:00 committed by GitHub
commit 8a2e548c5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 126 additions and 17 deletions

View 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.

View File

@ -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: `

View File

@ -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')
})
})

View File

@ -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)) {

View File

@ -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,
}),
)
}