diff --git a/README.md b/README.md index eb8dc8a..0165b5d 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ setClassNamePrefix('📦') ### Safe `href`s -By default `ui-box` does not ensure that urls use safe protocols when passed to an element. But we built this functionality into `ui-box` to protect the end users of the products you are building. You can alter this by using `configureSafeHref({enabled?: boolean, origin?: string})`. This will ensure that only safe protocols are used (`http:`, `https:`, `mailto:`, `tel:`, and `data:`) and that the correct `rel` values are added (`noopener`, `noreferrer`(for external links)). +By default `ui-box` does not ensure that urls use safe protocols when passed to an element. But we built this functionality into `ui-box` to protect the end users of the products you are building. You can alter this by using `configureSafeHref({enabled?: boolean, origin?: string, additionalProtocols?: string[]})`. This will ensure that only safe protocols are used (`http:`, `https:`, `mailto:`, and `tel:`), that the correct `rel` values are added (`noopener`, `noreferrer`(for external links)), and any additional protocols passed are treated as safe. ```js import { configureSafeHref } from 'ui-box' @@ -320,14 +320,16 @@ configureSafeHref({ import { configureSafeHref } from 'ui-box' configureSafeHref({ enabled: true - origin: 'https://app.segmentio.com', + origin: 'https://app.segmentio.com', + additionalProtocols: ['data:'], }) ``` -Additionally you can overwrite the behavoir on an individual component basis using the prop `allowUnsafeHref` +Additionally you can overwrite the behavoir on an individual component basis using the prop `allowUnsafeHref` and `allowProtocols`. Setting `allowUnsafeHref` completely bypasses all safeHref functionality (protocol checks, rel checks). Setting `allowProtocols` adds the contents of a string array to the allowed protocols. ```jsx This is unsafe +This is unsafe ``` ### Server side rendering diff --git a/src/box.tsx b/src/box.tsx index 3e4d331..ffe77ad 100644 --- a/src/box.tsx +++ b/src/box.tsx @@ -3,9 +3,9 @@ import PropTypes from 'prop-types' import {BoxComponent} from './types/box-types' import {propTypes} from './enhancers' import enhanceProps from './enhance-props' -import {extractAnchorProps, getUseSafeHref} from './utils/safeHref' +import {extractAnchorProps, getUseSafeHref, HrefData} from './utils/safeHref' -const Box: BoxComponent = ({ is = 'div', innerRef, children, allowUnsafeHref, ...props }) => { +const Box: BoxComponent = ({ is = 'div', innerRef, children, allowUnsafeHref, allowProtocols, ...props }) => { // Convert the CSS props to class names (and inject the styles) const {className, enhancedProps: parsedProps} = enhanceProps(props) @@ -22,7 +22,16 @@ const Box: BoxComponent = ({ is = 'div', innerRef, children, allowUnsafeHref, .. */ const safeHrefEnabled = (typeof allowUnsafeHref === 'boolean' ? !allowUnsafeHref : getUseSafeHref()) && is === 'a' && parsedProps.href if (safeHrefEnabled) { - const {safeHref, safeRel} = extractAnchorProps(parsedProps.href, parsedProps.rel) + const hrefData:HrefData = { + href: parsedProps.href, + rel: parsedProps.rel, + } + + if (allowProtocols && allowProtocols.length > 0) { + hrefData.allowProtocols = allowProtocols + } + + const {safeHref, safeRel} = extractAnchorProps(hrefData) parsedProps.href = safeHref parsedProps.rel = safeRel } diff --git a/src/types/box-types.ts b/src/types/box-types.ts index ed7cf67..71ec8aa 100644 --- a/src/types/box-types.ts +++ b/src/types/box-types.ts @@ -59,6 +59,11 @@ export type BoxProps = InheritedProps & * Allows the high level value of safeHref to be overwritten on an individual component basis */ allowUnsafeHref?: boolean + + /** + * Allows additional protocols to be considered safe + */ + allowProtocols?: Array } export interface BoxComponent { diff --git a/src/utils/safeHref.ts b/src/utils/safeHref.ts index 74af1fd..09a8766 100644 --- a/src/utils/safeHref.ts +++ b/src/utils/safeHref.ts @@ -3,13 +3,22 @@ export interface URLInfo { sameOrigin: boolean } +export interface HrefData { + href: string + rel: string + allowProtocols?: string[] +} + export interface SafeHrefConfigObj { enabled?: boolean origin?: string + additionalProtocols?: string[] } const PROTOCOL_REGEX = /^[a-z]+:/ const ORIGIN_REGEX = /^(?:[a-z]+:?:)?(?:\/\/)?([^\/\?]+)/ +const safeProtocols: string[] = ['http:', 'https:', 'mailto:', 'tel:'] +let customProtocols:Array = [] let useSafeHref = false let globalOrigin = typeof window !== 'undefined' ? window.location.origin : false @@ -21,40 +30,45 @@ export function configureSafeHref(configObject: SafeHrefConfigObj) { if (configObject.origin) { globalOrigin = configObject.origin } + + if (configObject.additionalProtocols && configObject.additionalProtocols.length) { + customProtocols.push(...configObject.additionalProtocols) + } } export function getUseSafeHref(): boolean { return useSafeHref } -export function getURLInfo(url: string): URLInfo { - /** - * An array of the safely allowed url protocols - */ - const safeProtocols = ['http:', 'https:', 'mailto:', 'tel:', 'data:'] +export function resetCustomProtocols() { + customProtocols = [] +} +export function getURLInfo(url: string, allowProtocols: Array): URLInfo { /** * - Find protocol of URL or set to 'relative' * - Find origin of URL * - Determine if sameOrigin * - Determine if protocol of URL is safe */ - const protocolResult = url.match(PROTOCOL_REGEX) - const originResult = url.match(ORIGIN_REGEX) + const cleanedUrl = url.trim() + const protocolResult = cleanedUrl.match(PROTOCOL_REGEX) + const originResult = cleanedUrl.match(ORIGIN_REGEX) const urlProtocol = protocolResult ? protocolResult[0] : 'relative' let sameOrigin = urlProtocol === 'relative' if (!sameOrigin && globalOrigin) { sameOrigin = globalOrigin === (originResult && originResult[0]) } - const isSafeProtocol = sameOrigin ? true : safeProtocols.includes(urlProtocol) + const allowedProtocols = [...safeProtocols, ...customProtocols, ...allowProtocols] + const isSafeProtocol = sameOrigin ? true : allowedProtocols.includes(urlProtocol) if (!isSafeProtocol) { /** * If the url is unsafe, put a error in the console, and return the URLInfo object * with the value of url being `undefined` */ console.error( - '📦 `href` passed to anchor tag is unsafe. Because of this, the `href` on the element was not set. Please review the safe href documentation if you have questions.', + '📦 ui-box: `href` passed to anchor tag is unsafe. Because of this, the `href` on the element was not set. Please review the safe href documentation if you have questions.', 'https://www.github.com/segmentio/ui-box' ) return { @@ -67,16 +81,19 @@ export function getURLInfo(url: string): URLInfo { * If the url is safe, return the url and origin */ return { - url, + url: cleanedUrl, sameOrigin } } -export function extractAnchorProps(href: string, rel: string) { +export function extractAnchorProps(hrefData: HrefData) { + const {href, rel} = hrefData + const allowProtocols = hrefData.allowProtocols && hrefData.allowProtocols.length ? hrefData.allowProtocols : [] + /** * Get url info and update href */ - const urlInfo = getURLInfo(href) + const urlInfo = getURLInfo(href, allowProtocols) const safeHref = urlInfo.url /** diff --git a/test/utils/safeHref.ts b/test/utils/safeHref.ts new file mode 100644 index 0000000..7d2ec19 --- /dev/null +++ b/test/utils/safeHref.ts @@ -0,0 +1,58 @@ +import test from 'ava' +import {extractAnchorProps, configureSafeHref, resetCustomProtocols} from '../../src/utils/safeHref' + +test('Allows safe protocols', t => { + configureSafeHref({ + enabled: true + }) + + const {safeHref} = extractAnchorProps({ + href: 'https://www.apple.com', + rel: '' + }) + + t.assert(safeHref === 'https://www.apple.com') +}) + +test('Rejects unsafe protocols', t => { + const {safeHref} = extractAnchorProps({ + href: 'javascript:alert("hi")', + rel: '' + }) + + t.assert(safeHref === undefined) +}) + +test('Rejects unsafe protocols with whitespace', t => { + const {safeHref} = extractAnchorProps({ + href: ' javascript:alert("hi")', + rel: '' + }) + + t.assert(safeHref === undefined) +}) + +test('Allows custom protocol', t => { + configureSafeHref({ + additionalProtocols: ['data:'] + }) + + const {safeHref} = extractAnchorProps({ + href: 'data:text/html,

Hi

', + rel: '' + }) + + resetCustomProtocols() + + t.assert(safeHref === 'data:text/html,

Hi

') +}) + +test('Allows individual level custom protocol', t => { + const {safeHref} = extractAnchorProps({ + href: 'data:text/html,

Hi

', + rel: '', + allowProtocols: ['data:'] + }) + + t.assert(safeHref === 'data:text/html,

Hi

') +}) diff --git a/tools/story.tsx b/tools/story.tsx index 06abee0..7fd30b2 100644 --- a/tools/story.tsx +++ b/tools/story.tsx @@ -36,7 +36,7 @@ storiesOf('Box', module) }) .add('safe `href`', () => { configureSafeHref({ - enabled: true + enabled: true, }) return ( @@ -45,6 +45,8 @@ storiesOf('Box', module) Same Origin Link External Link Javascript protocol Link + Data protocol Link + Allow protocol Link Overwride Safe Href )