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(link): add backwards compat by deprecating validate and using isAllowedUri instead #5812

Merged
merged 1 commit into from
Nov 7, 2024
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
2 changes: 1 addition & 1 deletion demos/src/Marks/Link/React/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
182 changes: 139 additions & 43 deletions packages/extension-link/src/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,60 +38,87 @@ 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.
* @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) => 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.
*
* @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.
*
* @returns {boolean} True if the URL is valid, false otherwise.
* @example
* isAllowedUri: (url, { defaultValidate, protocols, defaultProtocol }) => {
* return url.startsWith('./') || defaultValidate(url)
* }
*/
validate: (url: string, ctx: { defaultValidate: (url: string) => boolean, protocols: Array<LinkProtocolOptions | string>, defaultProtocol: string }) => boolean
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.
*
* @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' {
Expand All @@ -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;
};
}
}

Expand All @@ -124,20 +161,41 @@ 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)
}
})
}

// 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'))
return (
!uri
|| uri
.replace(ATTR_WHITESPACE, '')
.match(
new RegExp(
// eslint-disable-next-line no-useless-escape
`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))`,
'i',
),
)
)
}

/**
Expand All @@ -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)
Expand Down Expand Up @@ -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,
}
},
Expand All @@ -209,25 +275,44 @@ export const Link = Mark.create<LinkOptions>({
},

parseHTML() {
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 })) {
return false
}
return null
return [
{
tag: 'a[href]',
getAttrs: dom => {
const href = (dom as HTMLElement).getAttribute('href')

// prevent XSS attacks
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]
Expand Down Expand Up @@ -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,
})))
}))
}
}

Expand All @@ -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,
}),
)
Expand Down