fix(link): add backwards compat by deprecating validate and using isAllowedUri instead (#5812)

This commit is contained in:
Nick Perez 2024-11-07 13:18:01 +01:00 committed by GitHub
parent 88371561bb
commit 62c6dddf80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 141 additions and 45 deletions

View File

@ -21,7 +21,7 @@ export default () => {
autolink: true, autolink: true,
defaultProtocol: 'https', defaultProtocol: 'https',
protocols: ['http', 'https'], protocols: ['http', 'https'],
validate: (url, ctx) => { isAllowedUri: (url, ctx) => {
try { try {
// construct URL // construct URL
const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`) const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`)

View File

@ -38,52 +38,79 @@ export interface LinkOptions {
* @default true * @default true
* @example false * @example false
*/ */
autolink: boolean autolink: boolean;
/** /**
* An array of custom protocols to be registered with linkifyjs. * An array of custom protocols to be registered with linkifyjs.
* @default [] * @default []
* @example ['ftp', 'git'] * @example ['ftp', 'git']
*/ */
protocols: Array<LinkProtocolOptions | string> protocols: Array<LinkProtocolOptions | string>;
/** /**
* Default protocol to use when no protocol is specified. * Default protocol to use when no protocol is specified.
* @default 'http' * @default 'http'
*/ */
defaultProtocol: string defaultProtocol: string;
/** /**
* If enabled, links will be opened on click. * If enabled, links will be opened on click.
* @default true * @default true
* @example false * @example false
*/ */
openOnClick: boolean | DeprecatedOpenWhenNotEditable openOnClick: boolean | DeprecatedOpenWhenNotEditable;
/** /**
* Adds a link to the current selection if the pasted content only contains an url. * Adds a link to the current selection if the pasted content only contains an url.
* @default true * @default true
* @example false * @example false
*/ */
linkOnPaste: boolean linkOnPaste: boolean;
/** /**
* HTML attributes to add to the link element. * HTML attributes to add to the link element.
* @default {} * @default {}
* @example { class: 'foo' } * @example { class: 'foo' }
*/ */
HTMLAttributes: Record<string, any> HTMLAttributes: Record<string, any>;
/** /**
* A validation function that modifies link verification. * @deprecated Use the `shouldAutoLink` option instead.
* * A validation function that modifies link verification for the auto linker.
* @param {string} url - The URL to be validated. * @param url - The url to be validated.
* @param {Object} ctx - An object containing: * @returns - True if the url is valid, false otherwise.
* @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, ctx: { defaultValidate: (url: string) => boolean, protocols: Array<LinkProtocolOptions | string>, defaultProtocol: string }) => boolean validate: (url: string) => boolean;
/**
* A validation function which is used for configuring link verification for preventing XSS attacks.
* Only modify this if you know what you're doing.
*
* @returns {boolean} `true` if the URL is valid, `false` otherwise.
*
* @example
* isAllowedUri: (url, { defaultValidate, protocols, defaultProtocol }) => {
* return url.startsWith('./') || defaultValidate(url)
* }
*/
isAllowedUri: (
/**
* The URL to be validated.
*/
url: string,
ctx: {
/**
* The default validation function.
*/
defaultValidate: (url: string) => boolean;
/**
* An array of allowed protocols for the URL (e.g., "http", "https"). As defined in the `protocols` option.
*/
protocols: Array<LinkProtocolOptions | string>;
/**
* A string that represents the default protocol (e.g., 'http'). As defined in the `defaultProtocol` option.
*/
defaultProtocol: string;
}
) => boolean;
/** /**
* Determines whether a valid link should be automatically linked in the content. * Determines whether a valid link should be automatically linked in the content.
@ -91,7 +118,7 @@ export interface LinkOptions {
* @param {string} url - The URL that has already been validated. * @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. * @returns {boolean} - True if the link should be auto-linked; false if it should not be auto-linked.
*/ */
shouldAutoLink: (url: string) => boolean shouldAutoLink: (url: string) => boolean;
} }
declare module '@tiptap/core' { declare module '@tiptap/core' {
@ -102,19 +129,29 @@ declare module '@tiptap/core' {
* @param attributes The link attributes * @param attributes The link attributes
* @example editor.commands.setLink({ href: 'https://tiptap.dev' }) * @example editor.commands.setLink({ href: 'https://tiptap.dev' })
*/ */
setLink: (attributes: { href: string; target?: string | null; rel?: string | null; class?: string | null }) => ReturnType setLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/** /**
* Toggle a link mark * Toggle a link mark
* @param attributes The link attributes * @param attributes The link attributes
* @example editor.commands.toggleLink({ href: 'https://tiptap.dev' }) * @example editor.commands.toggleLink({ href: 'https://tiptap.dev' })
*/ */
toggleLink: (attributes: { href: string; target?: string | null; rel?: string | null; class?: string | null }) => ReturnType toggleLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/** /**
* Unset a link mark * Unset a link mark
* @example editor.commands.unsetLink() * @example editor.commands.unsetLink()
*/ */
unsetLink: () => ReturnType unsetLink: () => ReturnType;
} };
} }
} }
@ -124,11 +161,22 @@ declare module '@tiptap/core' {
const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g
function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocols']) { function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocols']) {
const allowedProtocols: string[] = ['http', 'https', 'ftp', 'ftps', 'mailto', 'tel', 'callto', 'sms', 'cid', 'xmpp'] const allowedProtocols: string[] = [
'http',
'https',
'ftp',
'ftps',
'mailto',
'tel',
'callto',
'sms',
'cid',
'xmpp',
]
if (protocols) { if (protocols) {
protocols.forEach(protocol => { protocols.forEach(protocol => {
const nextProtocol = (typeof protocol === 'string' ? protocol : protocol.scheme) const nextProtocol = typeof protocol === 'string' ? protocol : protocol.scheme
if (nextProtocol) { if (nextProtocol) {
allowedProtocols.push(nextProtocol) allowedProtocols.push(nextProtocol)
@ -136,8 +184,18 @@ function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocol
}) })
} }
// eslint-disable-next-line no-useless-escape return (
return !uri || uri.replace(ATTR_WHITESPACE, '').match(new RegExp(`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))`, 'i')) !uri
|| uri
.replace(ATTR_WHITESPACE, '')
.match(
new RegExp(
// eslint-disable-next-line no-useless-escape
`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))`,
'i',
),
)
)
} }
/** /**
@ -154,6 +212,13 @@ export const Link = Mark.create<LinkOptions>({
exitable: true, exitable: true,
onCreate() { onCreate() {
if (this.options.validate && !this.options.shouldAutoLink) {
// Copy the validate function to the shouldAutoLink option
this.options.shouldAutoLink = this.options.validate
console.warn(
'The `validate` option is deprecated. Rename to the `shouldAutoLink` option instead.',
)
}
this.options.protocols.forEach(protocol => { this.options.protocols.forEach(protocol => {
if (typeof protocol === 'string') { if (typeof protocol === 'string') {
registerCustomProtocol(protocol) registerCustomProtocol(protocol)
@ -183,7 +248,8 @@ export const Link = Mark.create<LinkOptions>({
rel: 'noopener noreferrer nofollow', rel: 'noopener noreferrer nofollow',
class: null, class: null,
}, },
validate: (url, ctx) => !!isAllowedUri(url, ctx.protocols), isAllowedUri: (url, ctx) => !!isAllowedUri(url, ctx.protocols),
validate: url => !!url,
shouldAutoLink: url => !!url, shouldAutoLink: url => !!url,
} }
}, },
@ -209,25 +275,44 @@ export const Link = Mark.create<LinkOptions>({
}, },
parseHTML() { parseHTML() {
return [{ return [
tag: 'a[href]', {
getAttrs: dom => { tag: 'a[href]',
const href = (dom as HTMLElement).getAttribute('href') getAttrs: dom => {
const href = (dom as HTMLElement).getAttribute('href')
// prevent XSS attacks // prevent XSS attacks
if (!href || !this.options.validate(href, { defaultValidate: url => !!isAllowedUri(url, this.options.protocols), protocols: this.options.protocols, defaultProtocol: this.options.defaultProtocol })) { if (
return false !href
} || !this.options.isAllowedUri(href, {
return null defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
protocols: this.options.protocols,
defaultProtocol: this.options.defaultProtocol,
})
) {
return false
}
return null
},
}, },
}] ]
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
// prevent XSS attacks // prevent XSS attacks
if (!this.options.validate(HTMLAttributes.href, { defaultValidate: href => !!isAllowedUri(href, this.options.protocols), protocols: this.options.protocols, defaultProtocol: this.options.defaultProtocol })) { if (
!this.options.isAllowedUri(HTMLAttributes.href, {
defaultValidate: href => !!isAllowedUri(href, this.options.protocols),
protocols: this.options.protocols,
defaultProtocol: this.options.defaultProtocol,
})
) {
// strip out the href // strip out the href
return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0] return [
'a',
mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }),
0,
]
} }
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
@ -265,17 +350,24 @@ export const Link = Mark.create<LinkOptions>({
const foundLinks: PasteRuleMatch[] = [] const foundLinks: PasteRuleMatch[] = []
if (text) { if (text) {
const { validate, protocols, defaultProtocol } = this.options const { protocols, defaultProtocol } = this.options
const links = find(text).filter(item => item.isLink && validate(item.value, { defaultValidate: href => !!isAllowedUri(href, protocols), protocols, defaultProtocol })) const links = find(text).filter(
item => item.isLink
&& this.options.isAllowedUri(item.value, {
defaultValidate: href => !!isAllowedUri(href, protocols),
protocols,
defaultProtocol,
}),
)
if (links.length) { if (links.length) {
links.forEach(link => (foundLinks.push({ links.forEach(link => foundLinks.push({
text: link.value, text: link.value,
data: { data: {
href: link.href, href: link.href,
}, },
index: link.start, index: link.start,
}))) }))
} }
} }
@ -293,14 +385,18 @@ export const Link = Mark.create<LinkOptions>({
addProseMirrorPlugins() { addProseMirrorPlugins() {
const plugins: Plugin[] = [] const plugins: Plugin[] = []
const { validate, protocols, defaultProtocol } = this.options const { protocols, defaultProtocol } = this.options
if (this.options.autolink) { if (this.options.autolink) {
plugins.push( plugins.push(
autolink({ autolink({
type: this.type, type: this.type,
defaultProtocol: this.options.defaultProtocol, defaultProtocol: this.options.defaultProtocol,
validate: url => validate(url, { defaultValidate: href => !!isAllowedUri(href, protocols), protocols, defaultProtocol }), validate: url => this.options.isAllowedUri(url, {
defaultValidate: href => !!isAllowedUri(href, protocols),
protocols,
defaultProtocol,
}),
shouldAutoLink: this.options.shouldAutoLink, shouldAutoLink: this.options.shouldAutoLink,
}), }),
) )