From 1f9b88d181bb156035949ae4916faa7a4a7231a2 Mon Sep 17 00:00:00 2001 From: Aimee1608 Date: Thu, 28 May 2026 16:43:22 +0800 Subject: [PATCH] feat: add createUrlTransform factory for custom safe protocols Introduce `createUrlTransform(safeProtocol?)` which returns a URL transform function identical to `defaultUrlTransform` but with a user-supplied protocol allowlist. This lets callers extend the default set (e.g. add `tel:`) without reimplementing the full sanitization logic. `defaultUrlTransform` is now defined as `createUrlTransform()`, keeping all existing behaviour unchanged. Closes #941 --- index.js | 1 + lib/index.js | 91 ++++++++++++++++++++++++++++++++------------------ readme.md | 31 ++++++++++++++++++ test.jsx | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 183 insertions(+), 33 deletions(-) diff --git a/index.js b/index.js index d0fc80e..71813c5 100644 --- a/index.js +++ b/index.js @@ -11,5 +11,6 @@ export { MarkdownAsync, MarkdownHooks, Markdown as default, + createUrlTransform, defaultUrlTransform } from './lib/index.js' diff --git a/lib/index.js b/lib/index.js index 06f07e1..fb55580 100644 --- a/lib/index.js +++ b/lib/index.js @@ -70,8 +70,8 @@ * @property {boolean | null | undefined} [skipHtml=false] * Ignore HTML in markdown completely (default: `false`). * @property {boolean | null | undefined} [unwrapDisallowed=false] - * Extract (unwrap) what’s in disallowed elements (default: `false`); - * normally when say `strong` is not allowed, it and it’s children are dropped, + * Extract (unwrap) what's in disallowed elements (default: `false`); + * normally when say `strong` is not allowed, it and it's children are dropped, * with `unwrapDisallowed` the element itself is replaced by its children. * @property {UrlTransform | null | undefined} [urlTransform] * Change URLs (default: `defaultUrlTransform`) @@ -121,9 +121,9 @@ const changelog = const emptyPlugins = [] /** @type {Readonly} */ const emptyRemarkRehypeOptions = {allowDangerousHtml: true} -const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i +const defaultSafeProtocol = /^(https?|ircs?|mailto|xmpp)$/i -// Mutable because we `delete` any time it’s used and a message is sent. +// Mutable because we `delete` any time it's used and a message is sent. /** @type {ReadonlyArray>} */ const deprecations = [ {from: 'astPlugins', id: 'remove-buggy-html-in-markdown-parser'}, @@ -409,6 +409,57 @@ function post(tree, options) { } } +/** + * Create a URL transform function that allows a custom set of safe protocols. + * + * This is useful when you want to extend the default set of allowed protocols + * (such as adding `tel:` or `sms:`) without reimplementing the full URL + * sanitization logic. + * + * @param {RegExp} [safeProtocol] + * Pattern of safe protocols (default: + * `/^(https?|ircs?|mailto|xmpp)$/i`). + * @returns {UrlTransform} + * URL transform function. + */ +export function createUrlTransform(safeProtocol = defaultSafeProtocol) { + /** + * @satisfies {UrlTransform} + * @param {string} value + * URL. + * @param {string} _key + * Property name (unused). + * @param {Readonly} _node + * Node (unused). + * @returns {string} + * Safe URL. + */ + return function (value, _key, _node) { + // Same as: + // + // But without the `encode` part. + const colon = value.indexOf(':') + const questionMark = value.indexOf('?') + const numberSign = value.indexOf('#') + const slash = value.indexOf('/') + + if ( + // If there is no protocol, it's relative. + colon === -1 || + // If the first colon is after a `?`, `#`, or `/`, it's not a protocol. + (slash !== -1 && colon > slash) || + (questionMark !== -1 && colon > questionMark) || + (numberSign !== -1 && colon > numberSign) || + // It is a protocol, it should be allowed. + safeProtocol.test(value.slice(0, colon)) + ) { + return value + } + + return '' + } +} + /** * Make a URL safe. * @@ -416,33 +467,9 @@ function post(tree, options) { * It allows the protocols `http`, `https`, `irc`, `ircs`, `mailto`, and `xmpp`, * and URLs relative to the current protocol (such as `/something`). * + * To allow additional protocols, use {@linkcode createUrlTransform} instead. + * * @satisfies {UrlTransform} - * @param {string} value - * URL. - * @returns {string} - * Safe URL. + * @type {UrlTransform} */ -export function defaultUrlTransform(value) { - // Same as: - // - // But without the `encode` part. - const colon = value.indexOf(':') - const questionMark = value.indexOf('?') - const numberSign = value.indexOf('#') - const slash = value.indexOf('/') - - if ( - // If there is no protocol, it’s relative. - colon === -1 || - // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol. - (slash !== -1 && colon > slash) || - (questionMark !== -1 && colon > questionMark) || - (numberSign !== -1 && colon > numberSign) || - // It is a protocol, it should be allowed. - safeProtocol.test(value.slice(0, colon)) - ) { - return value - } - - return '' -} +export const defaultUrlTransform = createUrlTransform() diff --git a/readme.md b/readme.md index 949b180..7e905c7 100644 --- a/readme.md +++ b/readme.md @@ -34,6 +34,7 @@ React component to render markdown. * [`Markdown`](#markdown) * [`MarkdownAsync`](#markdownasync) * [`MarkdownHooks`](#markdownhooks) + * [`createUrlTransform(safeProtocol?)`](#createurltransformsafeprotocol) * [`defaultUrlTransform(url)`](#defaulturltransformurl) * [`AllowElement`](#allowelement) * [`Components`](#components) @@ -238,6 +239,31 @@ see [`MarkdownAsync`][api-markdown-async]. React node (`ReactNode`). +### `createUrlTransform(safeProtocol?)` + +Create a URL transform function with a custom set of allowed protocols. + +This is useful when you want to extend the default allowed protocols without +reimplementing the full sanitization logic. +For example, to also allow `tel:` links: + +```js +import Markdown, {createUrlTransform} from 'react-markdown' + +const urlTransform = createUrlTransform(/^(https?|ircs?|mailto|xmpp|tel)$/i) + +const element = {'[call](tel:+1-555-0100)'} +``` + +###### Parameters + +* `safeProtocol` (`RegExp`, default: `/^(https?|ircs?|mailto|xmpp)$/i`) + — pattern of allowed protocols + +###### Returns + +URL transform function ([`UrlTransform`][api-url-transform]). + ### `defaultUrlTransform(url)` Make a URL safe. @@ -246,6 +272,9 @@ This follows how GitHub works. It allows the protocols `http`, `https`, `irc`, `ircs`, `mailto`, and `xmpp`, and URLs relative to the current protocol (such as `/something`). +To allow additional protocols, +use [`createUrlTransform`][api-create-url-transform]. + ###### Parameters * `url` (`string`) @@ -829,6 +858,8 @@ abide by its terms. [api-components]: #components +[api-create-url-transform]: #createurltransformsafeprotocol + [api-default-url-transform]: #defaulturltransformurl [api-extra-props]: #extraprops diff --git a/test.jsx b/test.jsx index 1b254db..aa9e0a1 100644 --- a/test.jsx +++ b/test.jsx @@ -24,7 +24,12 @@ import {render, waitFor} from '@testing-library/react' import concatStream from 'concat-stream' import {Component} from 'react' import {renderToPipeableStream, renderToStaticMarkup} from 'react-dom/server' -import Markdown, {MarkdownAsync, MarkdownHooks} from 'react-markdown' +import Markdown, { + MarkdownAsync, + MarkdownHooks, + createUrlTransform, + defaultUrlTransform +} from 'react-markdown' import rehypeRaw from 'rehype-raw' import rehypeStarryNight from 'rehype-starry-night' import remarkGfm from 'remark-gfm' @@ -38,6 +43,7 @@ test('react-markdown (core)', async function (t) { assert.deepEqual(Object.keys(await import('react-markdown')).sort(), [ 'MarkdownAsync', 'MarkdownHooks', + 'createUrlTransform', 'default', 'defaultUrlTransform' ]) @@ -1058,6 +1064,91 @@ test('Markdown', async function (t) { }) }) +test('createUrlTransform', async function (t) { + /** @type {import('hast').Element} */ + const mockNode = {type: 'element', tagName: 'a', properties: {}, children: []} + + await t.test( + 'should behave identically to `defaultUrlTransform` by default', + function () { + const transform = createUrlTransform() + assert.equal( + transform('https://example.com', 'href', mockNode), + 'https://example.com' + ) + assert.equal( + transform('http://example.com', 'href', mockNode), + 'http://example.com' + ) + assert.equal( + transform('mailto:user@example.com', 'href', mockNode), + 'mailto:user@example.com' + ) + assert.equal(transform('vbscript:alert(1)', 'href', mockNode), '') + assert.equal( + transform('/relative/path', 'href', mockNode), + '/relative/path' + ) + } + ) + + await t.test('should allow custom protocols via a RegExp', function () { + const transform = createUrlTransform(/^(https?|ircs?|mailto|xmpp|tel)$/i) + assert.equal( + transform('tel:+1-555-0100', 'href', mockNode), + 'tel:+1-555-0100' + ) + assert.equal( + transform('https://example.com', 'href', mockNode), + 'https://example.com' + ) + assert.equal(transform('vbscript:alert(1)', 'href', mockNode), '') + }) + + await t.test('should block protocols not in the custom set', function () { + const transform = createUrlTransform(/^https?$/i) + assert.equal(transform('mailto:user@example.com', 'href', mockNode), '') + assert.equal( + transform('https://example.com', 'href', mockNode), + 'https://example.com' + ) + }) + + await t.test('should work as a `urlTransform` prop', function () { + const transform = createUrlTransform(/^(https?|ircs?|mailto|xmpp|tel)$/i) + assert.equal( + renderToStaticMarkup( + + {'[call](tel:+1-555-0100)'} + + ), + '

call

' + ) + }) + + await t.test( + 'should match behavior of `defaultUrlTransform` as a constant', + function () { + // Both operate on the URL string; key and node are unused here. + /** @type {import('hast').Element} */ + const node = { + type: 'element', + tagName: 'a', + properties: {}, + children: [] + } + assert.equal( + defaultUrlTransform('https://example.com', 'href', node), + createUrlTransform()('https://example.com', 'href', node) + ) + assert.equal( + defaultUrlTransform('vbscript:alert(1)', 'href', node), + createUrlTransform()('vbscript:alert(1)', 'href', node) + ) + } + ) +}) + test('MarkdownAsync', async function (t) { await t.test('should support `MarkdownAsync` (1)', async function () { assert.throws(function () {