Skip to content

Commit

Permalink
fix(extension-link): use whitelist for allowed href values (#5160)
Browse files Browse the repository at this point in the history
  • Loading branch information
nperez0111 committed May 16, 2024
2 parents b3899ba + 738c436 commit 9df8737
Show file tree
Hide file tree
Showing 2 changed files with 247 additions and 38 deletions.
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:info@example.com',
'ftp://info@example.com',
]

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()
})
})
})

0 comments on commit 9df8737

Please sign in to comment.