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

This commit is contained in:
Christofer Roth 2024-05-15 16:00:46 +02:00
parent ef7d195311
commit 980b54f62b
2 changed files with 189 additions and 38 deletions

View File

@ -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
@ -161,12 +170,11 @@ export const Link = Mark.create<LinkOptions>({
},
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:')) {
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]
},

View File

@ -20,45 +20,188 @@ 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',
]
validUrls.forEach(url => {
it('does output href tag for valid schemas', () => {
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)
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
expect(editor.getHTML()).to.not.include('javascript:alert(window.origin)')
'javascript:alert(window.origin)',
editor?.destroy()
getEditorEl()?.remove()
// 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
'javascript\x0a:alert(window.origin)',
// eslint-disable-next-line no-script-url
'javascript\x0d:alert(window.origin)',
]
invalidUrls.forEach(url => {
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: {
href: url,
},
},
],
},
],
},
],
},
})
expect(editor.getHTML()).to.not.include(url)
editor?.destroy()
getEditorEl()?.remove()
})
})
})