Skip to content

Commit

Permalink
refactor: adjust validate and add shouldAutoLink to improve URL handling
Browse files Browse the repository at this point in the history
  • Loading branch information
guarmo committed Nov 6, 2024
1 parent 4ee59c1 commit 444e6e5
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 14 deletions.
6 changes: 6 additions & 0 deletions .changeset/witty-olives-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tiptap/extension-link": patch
"tiptap-demos": patch
---

Refactor validate and add shouldAutoLink function to improve URL handling
55 changes: 55 additions & 0 deletions demos/src/Marks/Link/React/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,61 @@ export default () => {
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
protocols: ['http', 'https'],
validate: (url, ctx) => {
try {
// construct URL
const parsedUrl = url.startsWith('http') ? 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.startsWith('http') ? 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: `
Expand Down
42 changes: 35 additions & 7 deletions demos/src/Marks/Link/React/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>',
)
})
})
Expand All @@ -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')
Expand Down Expand Up @@ -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:[email protected]']

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(`<a href="${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(`<a href="${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')
})
})
3 changes: 3 additions & 0 deletions packages/extension-link/src/helpers/autolink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type AutolinkOptions = {
type: MarkType
defaultProtocol: string
validate: (url: string) => boolean
shouldAutoLink: (url: string) => boolean
}

/**
Expand Down Expand Up @@ -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)) {
Expand Down
30 changes: 23 additions & 7 deletions packages/extension-link/src/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,21 @@ export interface LinkOptions {
/**
* A validation function that modifies link verification for the auto linker.
* @param url - The url to be validated.
* @param ctx - An object containing:
* - `defaultValidate`: A function that performs the default URL validation.
* - `protocols`: An array of allowed protocols for the URL (e.g., "http", "https").
* - `defaultProtocol`: A string that represents the default protocol (e.g. 'http')
* @returns - 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 url - The URL that has already been validated.
* @returns - True if the link should be auto-linked; false if it should not be auto-linked.
*/
shouldAutoLink: (url: string) => boolean
}

declare module '@tiptap/core' {
Expand Down Expand Up @@ -169,7 +181,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,
}
},

Expand Down Expand Up @@ -200,7 +213,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
Expand All @@ -210,7 +223,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]
}
Expand Down Expand Up @@ -250,8 +263,9 @@ 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))
console.log(text)
const { validate, protocols, defaultProtocol } = this.options
const links = find(text).filter(item => item.isLink && validate(item.href, { defaultValidate: href => !!isAllowedUri(href, protocols), protocols, defaultProtocol }))

if (links.length) {
links.forEach(link => (foundLinks.push({
Expand All @@ -278,13 +292,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,
}),
)
}
Expand Down

0 comments on commit 444e6e5

Please sign in to comment.