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,
defaultProtocol: 'https',
protocols: ['http', 'https'],
validate: (url, ctx) => {
isAllowedUri: (url, ctx) => {
try {
// construct URL
const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`)

View File

@ -38,52 +38,79 @@ export interface LinkOptions {
* @default true
* @example false
*/
autolink: boolean
autolink: boolean;
/**
* An array of custom protocols to be registered with linkifyjs.
* @default []
* @example ['ftp', 'git']
*/
protocols: Array<LinkProtocolOptions | string>
protocols: Array<LinkProtocolOptions | string>;
/**
* Default protocol to use when no protocol is specified.
* @default 'http'
*/
defaultProtocol: string
defaultProtocol: string;
/**
* If enabled, links will be opened on click.
* @default true
* @example false
*/
openOnClick: boolean | DeprecatedOpenWhenNotEditable
openOnClick: boolean | DeprecatedOpenWhenNotEditable;
/**
* Adds a link to the current selection if the pasted content only contains an url.
* @default true
* @example false
*/
linkOnPaste: boolean
linkOnPaste: boolean;
/**
* HTML attributes to add to the link element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
HTMLAttributes: Record<string, any>;
/**
* A validation function that modifies link verification.
*
* @param {string} url - The URL to be validated.
* @param {Object} ctx - An object containing:
* @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.
* @deprecated Use the `shouldAutoLink` option instead.
* A validation function that modifies link verification for the auto linker.
* @param url - The url to be validated.
* @returns - 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.
@ -91,7 +118,7 @@ export interface LinkOptions {
* @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.
*/
shouldAutoLink: (url: string) => boolean
shouldAutoLink: (url: string) => boolean;
}
declare module '@tiptap/core' {
@ -102,19 +129,29 @@ declare module '@tiptap/core' {
* @param attributes The link attributes
* @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
* @param attributes The link attributes
* @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
* @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
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) {
protocols.forEach(protocol => {
const nextProtocol = (typeof protocol === 'string' ? protocol : protocol.scheme)
const nextProtocol = typeof protocol === 'string' ? protocol : protocol.scheme
if (nextProtocol) {
allowedProtocols.push(nextProtocol)
@ -136,8 +184,18 @@ function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocol
})
}
return (
!uri
|| uri
.replace(ATTR_WHITESPACE, '')
.match(
new RegExp(
// eslint-disable-next-line no-useless-escape
return !uri || uri.replace(ATTR_WHITESPACE, '').match(new RegExp(`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))`, 'i'))
`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))`,
'i',
),
)
)
}
/**
@ -154,6 +212,13 @@ export const Link = Mark.create<LinkOptions>({
exitable: true,
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 => {
if (typeof protocol === 'string') {
registerCustomProtocol(protocol)
@ -183,7 +248,8 @@ export const Link = Mark.create<LinkOptions>({
rel: 'noopener noreferrer nofollow',
class: null,
},
validate: (url, ctx) => !!isAllowedUri(url, ctx.protocols),
isAllowedUri: (url, ctx) => !!isAllowedUri(url, ctx.protocols),
validate: url => !!url,
shouldAutoLink: url => !!url,
}
},
@ -209,25 +275,44 @@ export const Link = Mark.create<LinkOptions>({
},
parseHTML() {
return [{
return [
{
tag: 'a[href]',
getAttrs: dom => {
const href = (dom as HTMLElement).getAttribute('href')
// 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 (
!href
|| !this.options.isAllowedUri(href, {
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
protocols: this.options.protocols,
defaultProtocol: this.options.defaultProtocol,
})
) {
return false
}
return null
},
}]
},
]
},
renderHTML({ HTMLAttributes }) {
// 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
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]
@ -265,17 +350,24 @@ export const Link = Mark.create<LinkOptions>({
const foundLinks: PasteRuleMatch[] = []
if (text) {
const { validate, protocols, defaultProtocol } = this.options
const links = find(text).filter(item => item.isLink && validate(item.value, { defaultValidate: href => !!isAllowedUri(href, protocols), protocols, defaultProtocol }))
const { protocols, defaultProtocol } = this.options
const links = find(text).filter(
item => item.isLink
&& this.options.isAllowedUri(item.value, {
defaultValidate: href => !!isAllowedUri(href, protocols),
protocols,
defaultProtocol,
}),
)
if (links.length) {
links.forEach(link => (foundLinks.push({
links.forEach(link => foundLinks.push({
text: link.value,
data: {
href: link.href,
},
index: link.start,
})))
}))
}
}
@ -293,14 +385,18 @@ export const Link = Mark.create<LinkOptions>({
addProseMirrorPlugins() {
const plugins: Plugin[] = []
const { validate, protocols, defaultProtocol } = this.options
const { protocols, defaultProtocol } = this.options
if (this.options.autolink) {
plugins.push(
autolink({
type: this.type,
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,
}),
)