diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 2fc658019..6d51f83ab 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -301,6 +301,15 @@ "confirmed": { "message": "Confirmed" }, + "confusableUnicode": { + "message": "'$1' is similar to '$2'." + }, + "confusableZeroWidthUnicode": { + "message": "Zero-width character found." + }, + "confusingEnsDomain": { + "message": "We have detected a confusable character in the ENS name. Check the ENS name to avoid a potential scam." + }, "congratulations": { "message": "Congratulations" }, diff --git a/package.json b/package.json index fb998dfff..dfc829560 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "single-call-balance-checker-abi": "^1.0.0", "swappable-obj-proxy": "^1.1.0", "textarea-caret": "^3.0.1", + "unicode-confusables": "^0.1.1", "valid-url": "^1.0.9", "web3": "^0.20.7", "web3-stream-provider": "^4.0.0" diff --git a/ui/app/components/ui/confusable/confusable.component.js b/ui/app/components/ui/confusable/confusable.component.js new file mode 100644 index 000000000..6e1ac6b40 --- /dev/null +++ b/ui/app/components/ui/confusable/confusable.component.js @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { confusables } from 'unicode-confusables'; +import Tooltip from '../tooltip'; +import { useI18nContext } from '../../../hooks/useI18nContext'; + +const Confusable = ({ input }) => { + const t = useI18nContext(); + const confusableData = useMemo(() => { + return confusables(input); + }, [input]); + + return confusableData.map(({ point, similarTo }, index) => { + const zeroWidth = similarTo === ''; + if (similarTo === undefined) { + return point; + } + return ( + + {zeroWidth ? '?' : point} + + ); + }); +}; + +Confusable.propTypes = { + input: PropTypes.string.isRequired, +}; + +export default Confusable; diff --git a/ui/app/components/ui/confusable/index.js b/ui/app/components/ui/confusable/index.js new file mode 100644 index 000000000..e117d0df6 --- /dev/null +++ b/ui/app/components/ui/confusable/index.js @@ -0,0 +1 @@ +export { default } from './confusable.component'; diff --git a/ui/app/components/ui/confusable/index.scss b/ui/app/components/ui/confusable/index.scss new file mode 100644 index 000000000..1178eb55d --- /dev/null +++ b/ui/app/components/ui/confusable/index.scss @@ -0,0 +1,5 @@ +.confusable { + &__point { + color: $Red-500; + } +} diff --git a/ui/app/components/ui/confusable/test/confusable.component.test.js b/ui/app/components/ui/confusable/test/confusable.component.test.js new file mode 100644 index 000000000..d3166ccd7 --- /dev/null +++ b/ui/app/components/ui/confusable/test/confusable.component.test.js @@ -0,0 +1,26 @@ +import assert from 'assert'; +import React from 'react'; +import { shallow } from 'enzyme'; +import Confusable from '../confusable.component'; + +describe('Confusable component', function () { + it('should detect zero-width unicode', function () { + const wrapper = shallow(); + assert.ok(wrapper.find('.confusable__point').length === 1); + }); + + it('should detect homoglyphic unicode points', function () { + const wrapper = shallow(); + assert.ok(wrapper.find('.confusable__point').length === 1); + }); + + it('should detect multiple homoglyphic unicode points', function () { + const wrapper = shallow(); + assert.ok(wrapper.find('.confusable__point').length === 5); + }); + + it('should not detect emoji', function () { + const wrapper = shallow(); + assert.ok(wrapper.find('.confusable__point').length === 0); + }); +}); diff --git a/ui/app/components/ui/tooltip/tooltip.js b/ui/app/components/ui/tooltip/tooltip.js index 9ae60d2a5..19d244737 100644 --- a/ui/app/components/ui/tooltip/tooltip.js +++ b/ui/app/components/ui/tooltip/tooltip.js @@ -17,6 +17,7 @@ export default class Tooltip extends PureComponent { trigger: 'mouseenter focus', wrapperClassName: undefined, theme: '', + tag: 'div', }; static propTypes = { @@ -36,6 +37,7 @@ export default class Tooltip extends PureComponent { style: PropTypes.object, theme: PropTypes.string, tabIndex: PropTypes.number, + tag: PropTypes.string, }; render() { @@ -56,34 +58,36 @@ export default class Tooltip extends PureComponent { style, theme, tabIndex, + tag, } = this.props; if (!title && !html) { return
{children}
; } - return ( -
- - {children} - -
+ return React.createElement( + tag, + { className: wrapperClassName }, + + {children} + , ); } } diff --git a/ui/app/components/ui/ui-components.scss b/ui/app/components/ui/ui-components.scss index cc8effe24..96d0c684a 100644 --- a/ui/app/components/ui/ui-components.scss +++ b/ui/app/components/ui/ui-components.scss @@ -12,6 +12,7 @@ @import 'chip/chip'; @import 'circle-icon/index'; @import 'color-indicator/color-indicator'; +@import 'confusable/index'; @import 'currency-display/index'; @import 'currency-input/index'; @import 'definition-list/definition-list'; diff --git a/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js b/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js index a2126b552..a13a4a666 100644 --- a/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js +++ b/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js @@ -8,6 +8,7 @@ import ContactList from '../../../../components/app/contact-list'; import RecipientGroup from '../../../../components/app/contact-list/recipient-group/recipient-group.component'; import { ellipsify } from '../../send.utils'; import Button from '../../../../components/ui/button'; +import Confusable from '../../../../components/ui/confusable'; export default class AddRecipient extends Component { static propTypes = { @@ -128,7 +129,7 @@ export default class AddRecipient extends Component {
- {name || ellipsify(address)} + {name ? : ellipsify(address)}
{name && (
diff --git a/ui/app/pages/send/send-content/add-recipient/add-recipient.js b/ui/app/pages/send/send-content/add-recipient/add-recipient.js index 9a423e974..195da89cb 100644 --- a/ui/app/pages/send/send-content/add-recipient/add-recipient.js +++ b/ui/app/pages/send/send-content/add-recipient/add-recipient.js @@ -1,16 +1,19 @@ import ethUtil from 'ethereumjs-util'; import contractMap from '@metamask/contract-metadata'; +import { isConfusing } from 'unicode-confusables'; import { REQUIRED_ERROR, INVALID_RECIPIENT_ADDRESS_ERROR, KNOWN_RECIPIENT_ADDRESS_ERROR, INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, + CONFUSING_ENS_ERROR, } from '../../send.constants'; import { isValidAddress, isEthNetwork, checkExistingAddresses, + isValidDomainName, } from '../../../../helpers/utils/util'; export function getToErrorObject(to, hasHexData = false, network) { @@ -36,6 +39,9 @@ export function getToWarningObject(to, tokens = [], sendToken = null) { checkExistingAddresses(to, tokens)) ) { toWarning = KNOWN_RECIPIENT_ADDRESS_ERROR; + } else if (isValidDomainName(to) && isConfusing(to)) { + toWarning = CONFUSING_ENS_ERROR; } + return { to: toWarning }; } diff --git a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js index 9d4947107..cdf1a0c36 100644 --- a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js +++ b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js @@ -6,6 +6,7 @@ import { REQUIRED_ERROR, INVALID_RECIPIENT_ADDRESS_ERROR, KNOWN_RECIPIENT_ADDRESS_ERROR, + CONFUSING_ENS_ERROR, } from '../../../send.constants'; const stubs = { @@ -93,5 +94,17 @@ describe('add-recipient utils', function () { }, ); }); + + it('should warn if name is a valid domain and confusable', function () { + assert.deepEqual(getToWarningObject('vita‍lik.eth'), { + to: CONFUSING_ENS_ERROR, + }); + }); + + it('should not warn if name is a valid domain and not confusable', function () { + assert.deepEqual(getToWarningObject('vitalik.eth'), { + to: null, + }); + }); }); }); diff --git a/ui/app/pages/send/send.constants.js b/ui/app/pages/send/send.constants.js index 7b1a1f1a3..5584a6c99 100644 --- a/ui/app/pages/send/send.constants.js +++ b/ui/app/pages/send/send.constants.js @@ -35,6 +35,7 @@ const INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR = 'invalidAddressRecipientNotEthNetwork'; const REQUIRED_ERROR = 'required'; const KNOWN_RECIPIENT_ADDRESS_ERROR = 'knownAddressRecipient'; +const CONFUSING_ENS_ERROR = 'confusingEnsDomain'; const SIMPLE_GAS_COST = '0x5208'; // Hex for 21000, cost of a simple send. const BASE_TOKEN_GAS_COST = '0x186a0'; // Hex for 100000, a base estimate for token transfers. @@ -53,6 +54,7 @@ export { MIN_GAS_TOTAL, NEGATIVE_ETH_ERROR, REQUIRED_ERROR, + CONFUSING_ENS_ERROR, SIMPLE_GAS_COST, TOKEN_TRANSFER_FUNCTION_SIGNATURE, BASE_TOKEN_GAS_COST, diff --git a/yarn.lock b/yarn.lock index 95a33254a..1f7a9175e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24436,6 +24436,11 @@ unicode-canonical-property-names-ecmascript@^1.0.4: resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ== +unicode-confusables@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/unicode-confusables/-/unicode-confusables-0.1.1.tgz#17f14e8dc53ff81c12e92fd86e836ebdf14ea0c2" + integrity sha512-XTPBWmT88BDpXz9NycWk4KxDn+/AJmJYYaYBwuIH9119sopwk2E9GxU9azc+JNbhEsfiPul78DGocEihCp6MFQ== + unicode-match-property-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"