Skip to content
Closed
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
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export {
MarkdownAsync,
MarkdownHooks,
Markdown as default,
createUrlTransform,
defaultUrlTransform
} from './lib/index.js'
91 changes: 59 additions & 32 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) whats in disallowed elements (default: `false`);
* normally when say `strong` is not allowed, it and its 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`)
Expand Down Expand Up @@ -121,9 +121,9 @@ const changelog =
const emptyPlugins = []
/** @type {Readonly<RemarkRehypeOptions>} */
const emptyRemarkRehypeOptions = {allowDangerousHtml: true}
const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i
const defaultSafeProtocol = /^(https?|ircs?|mailto|xmpp)$/i

// Mutable because we `delete` any time its used and a message is sent.
// Mutable because we `delete` any time it's used and a message is sent.
/** @type {ReadonlyArray<Readonly<Deprecation>>} */
const deprecations = [
{from: 'astPlugins', id: 'remove-buggy-html-in-markdown-parser'},
Expand Down Expand Up @@ -409,40 +409,67 @@ 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<Element>} _node
* Node (unused).
* @returns {string}
* Safe URL.
*/
return function (value, _key, _node) {
// Same as:
// <https://github.com/micromark/micromark/blob/929275e/packages/micromark-util-sanitize-uri/dev/index.js#L34>
// 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.
*
* 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 {@linkcode createUrlTransform} instead.
*
* @satisfies {UrlTransform}
* @param {string} value
* URL.
* @returns {string}
* Safe URL.
* @type {UrlTransform}
*/
export function defaultUrlTransform(value) {
// Same as:
// <https://github.com/micromark/micromark/blob/929275e/packages/micromark-util-sanitize-uri/dev/index.js#L34>
// 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()
31 changes: 31 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = <Markdown urlTransform={urlTransform}>{'[call](tel:+1-555-0100)'}</Markdown>
```

###### 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.
Expand All @@ -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`)
Expand Down Expand Up @@ -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
Expand Down
93 changes: 92 additions & 1 deletion test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
])
Expand Down Expand Up @@ -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(
<Markdown urlTransform={transform}>
{'[call](tel:+1-555-0100)'}
</Markdown>
),
'<p><a href="tel:+1-555-0100">call</a></p>'
)
})

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 () {
Expand Down
Loading