From dc54e03625ae0e202ba9594da93a820149f82985 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Mon, 12 Dec 2016 11:34:23 +0000 Subject: [PATCH] [add] Linking API Adds support for opening external URLs in a new tab/window. Includes patches to 'Text' to improve accessibility and 'createDOMElement' to improve external link security. Fix #198 --- examples/apis/Linking/LinkingExample.js | 29 +++++++++++++++ src/apis/Linking/index.js | 36 +++++++++++++++++++ .../__snapshots__/index-test.js.snap | 33 +++++++++++++++-- src/components/Text/__tests__/index-test.js | 6 ++++ src/components/Text/index.js | 23 +++++++++--- src/index.js | 2 ++ .../__snapshots__/index-test.js.snap | 25 ++++++++----- .../createDOMElement/__tests__/index-test.js | 21 +++++++---- src/modules/createDOMElement/index.js | 2 ++ 9 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 examples/apis/Linking/LinkingExample.js create mode 100644 src/apis/Linking/index.js diff --git a/examples/apis/Linking/LinkingExample.js b/examples/apis/Linking/LinkingExample.js new file mode 100644 index 00000000..b8c8c9f9 --- /dev/null +++ b/examples/apis/Linking/LinkingExample.js @@ -0,0 +1,29 @@ +import { Linking, StyleSheet, Text, View } from 'react-native' +import React, { Component } from 'react'; +import { storiesOf, action } from '@kadira/storybook'; + +class LinkingExample extends Component { + render() { + return ( + + { Linking.openURL('https://mathiasbynens.github.io/rel-noopener/malicious.html'); }} style={styles.text}> + Linking.openURL (Expect: "The previous tab is safe and intact") + + + target="_blank" (Expect: "The previous tab is safe and intact") + + + ); + } +} + +const styles = StyleSheet.create({ + text: { + marginVertical: 10 + } +}); + +storiesOf('api: Linking', module) + .add('Safe linking', () => ( + + )); diff --git a/src/apis/Linking/index.js b/src/apis/Linking/index.js new file mode 100644 index 00000000..ca8df184 --- /dev/null +++ b/src/apis/Linking/index.js @@ -0,0 +1,36 @@ +const Linking = { + addEventListener() {}, + removeEventListener() {}, + canOpenUrl() { return true; }, + getInitialUrl() { return ''; }, + openURL(url) { + iframeOpen(url); + } +}; + +/** + * Tabs opened using JavaScript may redirect the parent tab using + * `window.opener.location`, ignoring cross-origin restrictions and enabling + * phishing attacks. + * + * Safari requires that we open the url by injecting a hidden iframe that calls + * window.open(), then removes the iframe from the DOM. + * + * https://mathiasbynens.github.io/rel-noopener/ + */ +const iframeOpen = (url) => { + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + document.body.appendChild(iframe); + + const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; + const script = iframeDoc.createElement('script'); + script.text = ` + window.parent = null; window.top = null; window.frameElement = null; + var child = window.open("${url}"); child.opener = null; + `; + iframeDoc.body.appendChild(script); + document.body.removeChild(iframe); +}; + +module.exports = Linking; diff --git a/src/components/Text/__tests__/__snapshots__/index-test.js.snap b/src/components/Text/__tests__/__snapshots__/index-test.js.snap index e3d38c4e..1357cd61 100644 --- a/src/components/Text/__tests__/__snapshots__/index-test.js.snap +++ b/src/components/Text/__tests__/__snapshots__/index-test.js.snap @@ -1,7 +1,6 @@ exports[`components/Text prop "children" 1`] = ` `; -exports[`components/Text prop "selectable" 1`] = ` +exports[`components/Text prop "onPress" 1`] = ` +`; + +exports[`components/Text prop "selectable" 1`] = ` + { test('prop "numberOfLines"'); + test('prop "onPress"', () => { + const onPress = (e) => {}; + const component = renderer.create(); + expect(component.toJSON()).toMatchSnapshot(); + }); + test('prop "selectable"', () => { let component = renderer.create(); expect(component.toJSON()).toMatchSnapshot(); diff --git a/src/components/Text/index.js b/src/components/Text/index.js index c2294219..7c906fdc 100644 --- a/src/components/Text/index.js +++ b/src/components/Text/index.js @@ -29,6 +29,7 @@ class Text extends Component { render() { const { numberOfLines, + onPress, selectable, style, /* eslint-disable */ @@ -37,26 +38,35 @@ class Text extends Component { ellipsizeMode, minimumFontScale, onLayout, - onPress, suppressHighlighting, /* eslint-enable */ ...other } = this.props; + if (onPress) { + other.onClick = onPress; + other.onKeyDown = this._createEnterHandler(onPress); + other.tabIndex = 0; + } + return createDOMElement('span', { ...other, - onClick: this._onPress, style: [ styles.initial, style, !selectable && styles.notSelectable, - numberOfLines === 1 && styles.singleLineStyle + numberOfLines === 1 && styles.singleLineStyle, + onPress && styles.pressable ] }); } - _onPress = (e) => { - if (this.props.onPress) { this.props.onPress(e); } + _createEnterHandler(fn) { + return (e) => { + if (e.keyCode === 13) { + fn && fn(e); + } + }; } } @@ -74,6 +84,9 @@ const styles = StyleSheet.create({ notSelectable: { userSelect: 'none' }, + pressable: { + cursor: 'pointer' + }, singleLineStyle: { maxWidth: '100%', overflow: 'hidden', diff --git a/src/index.js b/src/index.js index 6edaa077..3a2d79e1 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ import Dimensions from './apis/Dimensions'; import Easing from 'animated/lib/Easing'; import I18nManager from './apis/I18nManager'; import InteractionManager from './apis/InteractionManager'; +import Linking from './apis/Linking'; import NetInfo from './apis/NetInfo'; import PanResponder from './apis/PanResponder'; import PixelRatio from './apis/PixelRatio'; @@ -60,6 +61,7 @@ const ReactNative = { Easing, I18nManager, InteractionManager, + Linking, NetInfo, PanResponder, PixelRatio, diff --git a/src/modules/createDOMElement/__tests__/__snapshots__/index-test.js.snap b/src/modules/createDOMElement/__tests__/__snapshots__/index-test.js.snap index e0643671..41596743 100644 --- a/src/modules/createDOMElement/__tests__/__snapshots__/index-test.js.snap +++ b/src/modules/createDOMElement/__tests__/__snapshots__/index-test.js.snap @@ -12,14 +12,7 @@ exports[`modules/createDOMElement prop "accessibilityLiveRegion" 1`] = ` style={Object {}} /> `; -exports[`modules/createDOMElement prop "accessibilityRole" 1`] = ` -
-`; - -exports[`modules/createDOMElement prop "accessibilityRole" 2`] = ` +exports[`modules/createDOMElement prop "accessibilityRole" button 1`] = `