Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(extension-link): use whitelist for allowed href values #5160

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions packages/extension-link/src/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ declare module '@tiptap/core' {
}
}

// From DOMPurify
// https://github.com/cure53/DOMPurify/blob/main/src/regexp.js
const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex
const IS_ALLOWED_URI = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape

function isAllowedUri(uri: string | undefined) {
return !uri || uri.replace(ATTR_WHITESPACE, '').match(IS_ALLOWED_URI)
}

/**
* This extension allows you to create links.
* @see https://www.tiptap.dev/api/marks/link
Expand Down Expand Up @@ -157,16 +166,27 @@ export const Link = Mark.create<LinkOptions>({
},

parseHTML() {
return [{ tag: 'a[href]:not([href *= "javascript:" i])' }]
return [{
tag: 'a[href]',
getAttrs: dom => {
const href = (dom as HTMLElement).getAttribute('href')

// prevent XSS attacks
if (!href || !isAllowedUri(href)) {
return false
}
return { href }
},
}]
},

renderHTML({ HTMLAttributes }) {
// False positive; we're explicitly checking for javascript: links to ignore them
// eslint-disable-next-line no-script-url
if (HTMLAttributes.href?.startsWith('javascript:')) {
// prevent XSS attacks
if (!isAllowedUri(HTMLAttributes.href)) {
// strip out the href
return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0]
}

return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},

Expand Down
257 changes: 223 additions & 34 deletions tests/cypress/integration/extensions/link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,45 +20,234 @@ describe('extension-link', () => {
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)

it('does not output src tag for javascript schema', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Link,
],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'hello world!',
marks: [
{
type: 'link',
attrs: {
// We have to disable the eslint rule here because we're trying to purposely test eval urls
// eslint-disable-next-line no-script-url
href: 'javascript:alert(window.origin)',
const validUrls = [
'https://example.com',
'http://example.com',
'/same-site/index.html',
'../relative.html',
'mailto:[email protected]',
'ftp://[email protected]',
]

it('does output href tag for valid JSON schemas', () => {
validUrls.forEach(url => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Link,
],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'hello world!',
marks: [
{
type: 'link',
attrs: {
href: url,
},
},
},
],
},
],
},
],
},
],
},
],
},
})

expect(editor.getHTML()).to.include(url)
expect(JSON.stringify(editor.getJSON())).to.include(url)

editor?.destroy()
getEditorEl()?.remove()
})
})

it('does output href tag for valid HTML schemas', () => {
validUrls.forEach(url => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Link,
],
},
content: `<p><a href="${url}">hello world!</a></p>`,
})

expect(editor.getHTML()).to.include(url)
expect(JSON.stringify(editor.getJSON())).to.include(url)

editor?.destroy()
getEditorEl()?.remove()
})
})

// We have to disable the eslint rule here because we're trying to purposely test eval urls
// Examples inspired by: https://portswigger.net/web-security/cross-site-scripting/cheat-sheet#protocols
const invalidUrls = [
// A standard JavaScript protocol
// eslint-disable-next-line no-script-url
'javascript:alert(window.origin)',

// The protocol is not case sensitive
// eslint-disable-next-line no-script-url
'jAvAsCrIpT:alert(window.origin)',

// Characters \x01-\x20 are allowed before the protocol
// eslint-disable-next-line no-script-url
'\x00javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x01javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x02javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x03javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x04javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x05javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x06javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x07javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x08javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x09javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x0ajavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x0bjavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x0cjavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x0djavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x0ejavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x0fjavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x10javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x11javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x12javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x13javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x14javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x15javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x16javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x17javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x18javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x19javascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x1ajavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x1bjavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x1cjavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x1djavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x1ejavascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'\x1fjavascript:alert(window.origin)',

// Characters \x09,\x0a,\x0d are allowed inside the protocol
// eslint-disable-next-line no-script-url
'java\x09script:alert(window.origin)',
// eslint-disable-next-line no-script-url
'java\x0ascript:alert(window.origin)',
// eslint-disable-next-line no-script-url
'java\x0dscript:alert(window.origin)',

// Characters \x09,\x0a,\x0d are allowed after protocol name before the colon
// eslint-disable-next-line no-script-url
'javascript\x09:alert(window.origin)',
// eslint-disable-next-line no-script-url
expect(editor.getHTML()).to.not.include('javascript:alert(window.origin)')
'javascript\x0a:alert(window.origin)',
// eslint-disable-next-line no-script-url
'javascript\x0d:alert(window.origin)',
]

it('does not output href for :javascript links in JSON schema', () => {
invalidUrls.forEach(url => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Link,
],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'hello world!',
marks: [
{
type: 'link',
attrs: {
href: url,
},
},
],
},
],
},
],
},
})

editor?.destroy()
getEditorEl()?.remove()
expect(editor.getHTML()).to.not.include(url)
// Unfortunately, if the content is provided as JSON, it stays in the editor instance until it's destroyed
// At least, it cannot be outputted as HTML into a page
// expect(JSON.stringify(editor.getJSON())).to.not.include(url)

editor?.destroy()
getEditorEl()?.remove()
})
})

it('does not output href for :javascript links in HTML schema', () => {
invalidUrls.forEach(url => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Link,
],
content: `<p><a href="${url}">hello world!</a></p>`,
})

expect(editor.getHTML()).to.not.include(url)
expect(JSON.stringify(editor.getJSON())).to.not.include(url)

editor?.destroy()
getEditorEl()?.remove()
})
})
})