diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 5e35df6f1..3fe91b173 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2606,6 +2606,9 @@ "openSea": { "message": "OpenSea (Beta)" }, + "openSeaNew": { + "message": "OpenSea" + }, "optional": { "message": "Optional" }, @@ -2953,6 +2956,24 @@ "replace": { "message": "replace" }, + "requestFlaggedAsMaliciousFallbackCopyReason": { + "message": "The security provider has not shared additional details" + }, + "requestFlaggedAsMaliciousFallbackCopyReasonTitle": { + "message": "Request flagged as malicious" + }, + "requestMayNotBeSafe": { + "message": "Request may not be safe" + }, + "requestMayNotBeSafeError": { + "message": "The security provider didn't detect any known malicious activity, but it still may not be safe to continue." + }, + "requestNotVerified": { + "message": "Request not verified" + }, + "requestNotVerifiedError": { + "message": "Because of an error, this request was not verified by the security provider. Proceed with caution." + }, "requestsAwaitingAcknowledgement": { "message": "requests waiting to be acknowledged" }, @@ -4002,6 +4023,9 @@ "thingsToKeep": { "message": "Things to keep in mind:" }, + "thisIsBasedOn": { + "message": "This is based on information from " + }, "thisServiceIsExperimental": { "message": "This service is experimental" }, diff --git a/app/scripts/lib/security-provider-helpers.js b/app/scripts/lib/security-provider-helpers.js index d3fa3c6af..db66c462d 100644 --- a/app/scripts/lib/security-provider-helpers.js +++ b/app/scripts/lib/security-provider-helpers.js @@ -1,5 +1,8 @@ +import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; import { MESSAGE_TYPE } from '../../../shared/constants/app'; +const fetchWithTimeout = getFetchWithTimeout(); + export async function securityProviderCheck( requestData, methodName, @@ -47,7 +50,7 @@ export async function securityProviderCheck( }; } - const response = await fetch( + const response = await fetchWithTimeout( 'https://eos9d7dmfj.execute-api.us-east-1.amazonaws.com/metamask/validate', { method: 'POST', diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js index 50061912f..05f8ff32d 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js @@ -11,6 +11,8 @@ import Typography from '../../../ui/typography'; import { TypographyVariant } from '../../../../helpers/constants/design-system'; import DepositPopover from '../../deposit-popover/deposit-popover'; +import SecurityProviderBannerMessage from '../../security-provider-banner-message/security-provider-banner-message'; +import { SECURITY_PROVIDER_MESSAGE_SEVERITIES } from '../../security-provider-banner-message/security-provider-banner-message.constants'; import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from '.'; export default class ConfirmPageContainerContent extends Component { @@ -55,6 +57,7 @@ export default class ConfirmPageContainerContent extends Component { toAddress: PropTypes.string, transactionType: PropTypes.string, isBuyableChain: PropTypes.bool, + txData: PropTypes.object, }; state = { @@ -166,6 +169,7 @@ export default class ConfirmPageContainerContent extends Component { toAddress, transactionType, isBuyableChain, + txData, } = this.props; const { t } = this.context; @@ -187,6 +191,15 @@ export default class ConfirmPageContainerContent extends Component { {ethGasPriceWarning && ( )} + {(txData?.securityProviderResponse?.flagAsDangerous !== undefined && + txData?.securityProviderResponse?.flagAsDangerous !== + SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_MALICIOUS) || + (txData?.securityProviderResponse && + Object.keys(txData.securityProviderResponse).length === 0) ? ( + + ) : null} { @@ -47,6 +48,13 @@ describe('Confirm Page Container Content', () => { disabled: true, origin: 'http://localhost:4200', hideTitle: false, + txData: { + securityProviderResponse: { + flagAsDangerous: '?', + reason: 'Some reason...', + reason_header: 'Some reason header...', + }, + }, }; }); @@ -126,4 +134,40 @@ describe('Confirm Page Container Content', () => { expect(queryByText('Address Book Account 1')).not.toBeInTheDocument(); }); + + it('should render SecurityProviderBannerMessage component properly', () => { + const { queryByText } = renderWithProvider( + , + store, + ); + + expect(queryByText('Request not verified')).toBeInTheDocument(); + expect( + queryByText( + 'Because of an error, this request was not verified by the security provider. Proceed with caution.', + ), + ).toBeInTheDocument(); + expect( + queryByText('This is based on information from'), + ).toBeInTheDocument(); + }); + + it('should not render SecurityProviderBannerMessage component when flagAsDangerous is not malicious', () => { + props.txData.securityProviderResponse = { + flagAsDangerous: SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_MALICIOUS, + }; + + const { queryByText } = renderWithProvider( + , + store, + ); + + expect(queryByText('Request not verified')).toBeNull(); + expect( + queryByText( + 'Because of an error, this request was not verified by the security provider. Proceed with caution.', + ), + ).toBeNull(); + expect(queryByText('This is based on information from')).toBeNull(); + }); }); diff --git a/ui/components/app/confirm-page-container/confirm-page-container.component.js b/ui/components/app/confirm-page-container/confirm-page-container.component.js index 2a8065850..9f5212e4d 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container.component.js @@ -86,9 +86,7 @@ const ConfirmPageContainer = (props) => { currentTransaction, supportsEIP1559, nativeCurrency, - ///: BEGIN:ONLY_INCLUDE_IN(flask) txData, - ///: END:ONLY_INCLUDE_IN(flask) assetStandard, isApprovalOrRejection, } = props; @@ -223,6 +221,7 @@ const ConfirmPageContainer = (props) => { toAddress={toAddress} transactionType={currentTransaction.type} isBuyableChain={isBuyableChain} + txData={txData} /> )} {shouldDisplayWarning && errorKey === INSUFFICIENT_FUNDS_ERROR_KEY && ( @@ -346,9 +345,7 @@ ConfirmPageContainer.propTypes = { dataComponent: PropTypes.node, dataHexComponent: PropTypes.node, detailsComponent: PropTypes.node, - ///: BEGIN:ONLY_INCLUDE_IN(flask) txData: PropTypes.object, - ///: END:ONLY_INCLUDE_IN(flask) tokenAddress: PropTypes.string, nonce: PropTypes.string, warning: PropTypes.string, diff --git a/ui/components/app/security-provider-banner-message/index.js b/ui/components/app/security-provider-banner-message/index.js new file mode 100644 index 000000000..eea8398f0 --- /dev/null +++ b/ui/components/app/security-provider-banner-message/index.js @@ -0,0 +1 @@ +export { default } from './security-provider-banner-message'; diff --git a/ui/components/app/security-provider-banner-message/security-provider-banner-message.constants.js b/ui/components/app/security-provider-banner-message/security-provider-banner-message.constants.js new file mode 100644 index 000000000..fc30d0b5c --- /dev/null +++ b/ui/components/app/security-provider-banner-message/security-provider-banner-message.constants.js @@ -0,0 +1,5 @@ +export const SECURITY_PROVIDER_MESSAGE_SEVERITIES = { + NOT_MALICIOUS: 0, + MALICIOUS: 1, + NOT_SAFE: 2, +}; diff --git a/ui/components/app/security-provider-banner-message/security-provider-banner-message.js b/ui/components/app/security-provider-banner-message/security-provider-banner-message.js new file mode 100644 index 000000000..58c7bf3d2 --- /dev/null +++ b/ui/components/app/security-provider-banner-message/security-provider-banner-message.js @@ -0,0 +1,74 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { + Color, + SEVERITIES, + Size, + TypographyVariant, +} from '../../../helpers/constants/design-system'; +import { I18nContext } from '../../../../.storybook/i18n'; +import { BannerAlert, ButtonLink } from '../../component-library'; +import Typography from '../../ui/typography/typography'; +import { SECURITY_PROVIDER_MESSAGE_SEVERITIES } from './security-provider-banner-message.constants'; + +export default function SecurityProviderBannerMessage({ + securityProviderResponse, +}) { + const t = useContext(I18nContext); + + let messageTitle; + let messageText; + let severity; + + if ( + securityProviderResponse.flagAsDangerous === + SECURITY_PROVIDER_MESSAGE_SEVERITIES.MALICIOUS + ) { + messageTitle = + securityProviderResponse.reason_header === '' + ? t('requestFlaggedAsMaliciousFallbackCopyReasonTitle') + : securityProviderResponse.reason_header; + messageText = + securityProviderResponse.reason === '' + ? t('requestFlaggedAsMaliciousFallbackCopyReason') + : securityProviderResponse.reason; + severity = SEVERITIES.DANGER; + } else if ( + securityProviderResponse.flagAsDangerous === + SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_SAFE + ) { + messageTitle = t('requestMayNotBeSafe'); + messageText = t('requestMayNotBeSafeError'); + severity = SEVERITIES.WARNING; + } else { + messageTitle = t('requestNotVerified'); + messageText = t('requestNotVerifiedError'); + severity = SEVERITIES.WARNING; + } + + return ( + + {messageText} + + {t('thisIsBasedOn')} + + {t('openSeaNew')} + + + + ); +} + +SecurityProviderBannerMessage.propTypes = { + securityProviderResponse: PropTypes.object, +}; diff --git a/ui/components/app/security-provider-banner-message/security-provider-banner-message.test.js b/ui/components/app/security-provider-banner-message/security-provider-banner-message.test.js new file mode 100644 index 000000000..ef5b59efd --- /dev/null +++ b/ui/components/app/security-provider-banner-message/security-provider-banner-message.test.js @@ -0,0 +1,189 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import SecurityProviderBannerMessage from './security-provider-banner-message'; +import { SECURITY_PROVIDER_MESSAGE_SEVERITIES } from './security-provider-banner-message.constants'; + +describe('Security Provider Banner Message', () => { + const store = configureMockStore()({}); + + const thisIsBasedOnText = 'This is based on information from'; + + it('should render SecurityProviderBannerMessage component properly when flagAsDangerous is malicious', () => { + const securityProviderResponse = { + flagAsDangerous: SECURITY_PROVIDER_MESSAGE_SEVERITIES.MALICIOUS, + reason: + 'Approval is to an unverified smart contract known for stealing NFTs in the past.', + reason_header: 'This could be a scam', + }; + + const { getByText } = renderWithProvider( + , + store, + ); + + expect(getByText(securityProviderResponse.reason)).toBeInTheDocument(); + expect( + getByText(securityProviderResponse.reason_header), + ).toBeInTheDocument(); + expect(getByText(thisIsBasedOnText)).toBeInTheDocument(); + }); + + it('should render SecurityProviderBannerMessage component properly when flagAsDangerous is not safe', () => { + const securityProviderResponse = { + flagAsDangerous: SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_SAFE, + reason: 'Some reason...', + reason_header: 'Some reason header...', + }; + + const requestMayNotBeSafe = 'Request may not be safe'; + const requestMayNotBeSafeError = + "The security provider didn't detect any known malicious activity, but it still may not be safe to continue."; + + const { getByText } = renderWithProvider( + , + store, + ); + + expect(getByText(requestMayNotBeSafe)).toBeInTheDocument(); + expect(getByText(requestMayNotBeSafeError)).toBeInTheDocument(); + expect(getByText(thisIsBasedOnText)).toBeInTheDocument(); + }); + + it('should render SecurityProviderBannerMessage component properly when flagAsDangerous is undefined', () => { + const securityProviderResponse = { + flagAsDangerous: '?', + reason: 'Some reason...', + reason_header: 'Some reason header...', + }; + + const requestNotVerified = 'Request not verified'; + const requestNotVerifiedError = + 'Because of an error, this request was not verified by the security provider. Proceed with caution.'; + + const { getByText } = renderWithProvider( + , + store, + ); + + expect(getByText(requestNotVerified)).toBeInTheDocument(); + expect(getByText(requestNotVerifiedError)).toBeInTheDocument(); + expect(getByText(thisIsBasedOnText)).toBeInTheDocument(); + }); + + it('should render SecurityProviderBannerMessage component properly when securityProviderResponse is empty', () => { + const securityProviderResponse = {}; + + const requestNotVerified = 'Request not verified'; + const requestNotVerifiedError = + 'Because of an error, this request was not verified by the security provider. Proceed with caution.'; + + const { getByText } = renderWithProvider( + , + store, + ); + + expect(getByText(requestNotVerified)).toBeInTheDocument(); + expect(getByText(requestNotVerifiedError)).toBeInTheDocument(); + expect(getByText(thisIsBasedOnText)).toBeInTheDocument(); + }); + + it('should navigate to the OpenSea web page when clicked on the OpenSea button', () => { + const securityProviderResponse = { + flagAsDangerous: SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_SAFE, + reason: 'Some reason...', + reason_header: 'Some reason header...', + }; + + const { getByText } = renderWithProvider( + , + store, + ); + + const link = getByText('OpenSea'); + + expect(link).toBeInTheDocument(); + + fireEvent.click(link); + + expect(link.closest('a')).toHaveAttribute('href', 'https://opensea.io/'); + }); + + it('should render SecurityProviderBannerMessage component properly, with predefined reason message, when a request is malicious and there is no reason given', () => { + const securityProviderResponse = { + flagAsDangerous: SECURITY_PROVIDER_MESSAGE_SEVERITIES.MALICIOUS, + reason: '', + reason_header: 'Some reason header...', + }; + + const reason = 'The security provider has not shared additional details'; + + const { getByText } = renderWithProvider( + , + store, + ); + + expect( + getByText(securityProviderResponse.reason_header), + ).toBeInTheDocument(); + expect(getByText(reason)).toBeInTheDocument(); + expect(getByText(thisIsBasedOnText)).toBeInTheDocument(); + }); + + it('should render SecurityProviderBannerMessage component properly, with predefined reason_header message, when a request is malicious and there is no reason header given', () => { + const securityProviderResponse = { + flagAsDangerous: SECURITY_PROVIDER_MESSAGE_SEVERITIES.MALICIOUS, + reason: 'Some reason...', + reason_header: '', + }; + + const reasonHeader = 'Request flagged as malicious'; + + const { getByText } = renderWithProvider( + , + store, + ); + + expect(getByText(reasonHeader)).toBeInTheDocument(); + expect(getByText(securityProviderResponse.reason)).toBeInTheDocument(); + expect(getByText(thisIsBasedOnText)).toBeInTheDocument(); + }); + + it('should render SecurityProviderBannerMessage component properly, with predefined reason and reason_header messages, when a request is malicious and there are no reason and reason header given', () => { + const securityProviderResponse = { + flagAsDangerous: SECURITY_PROVIDER_MESSAGE_SEVERITIES.MALICIOUS, + reason: '', + reason_header: '', + }; + + const reasonHeader = 'Request flagged as malicious'; + + const reason = 'The security provider has not shared additional details'; + + const { getByText } = renderWithProvider( + , + store, + ); + + expect(getByText(reasonHeader)).toBeInTheDocument(); + expect(getByText(reason)).toBeInTheDocument(); + expect(getByText(thisIsBasedOnText)).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/signature-request-original/signature-request-original.component.js b/ui/components/app/signature-request-original/signature-request-original.component.js index 570d5821a..99ad1d596 100644 --- a/ui/components/app/signature-request-original/signature-request-original.component.js +++ b/ui/components/app/signature-request-original/signature-request-original.component.js @@ -22,6 +22,8 @@ import { NETWORK_TYPES } from '../../../../shared/constants/network'; import { Numeric } from '../../../../shared/modules/Numeric'; import { EtherDenomination } from '../../../../shared/constants/common'; import ConfirmPageContainerNavigation from '../confirm-page-container/confirm-page-container-navigation'; +import SecurityProviderBannerMessage from '../security-provider-banner-message/security-provider-banner-message'; +import { SECURITY_PROVIDER_MESSAGE_SEVERITIES } from '../security-provider-banner-message/security-provider-banner-message.constants'; import SignatureRequestOriginalWarning from './signature-request-original-warning'; export default class SignatureRequestOriginal extends Component { @@ -135,6 +137,15 @@ export default class SignatureRequestOriginal extends Component { return (
+ {(txData?.securityProviderResponse?.flagAsDangerous !== undefined && + txData?.securityProviderResponse?.flagAsDangerous !== + SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_MALICIOUS) || + (txData?.securityProviderResponse && + Object.keys(txData.securityProviderResponse).length === 0) ? ( + + ) : null}
{ expect(getByText('Message \\u202E test:')).toBeInTheDocument(); expect(getByText('Hi, \\u202E Alice!')).toBeInTheDocument(); }); + + it('should render SecurityProviderBannerMessage component properly', () => { + props.txData.securityProviderResponse = { + flagAsDangerous: '?', + reason: 'Some reason...', + reason_header: 'Some reason header...', + }; + render(); + expect(screen.getByText('Request not verified')).toBeInTheDocument(); + expect( + screen.getByText( + 'Because of an error, this request was not verified by the security provider. Proceed with caution.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText('This is based on information from'), + ).toBeInTheDocument(); + }); + + it('should not render SecurityProviderBannerMessage component when flagAsDangerous is not malicious', () => { + props.txData.securityProviderResponse = { + flagAsDangerous: SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_MALICIOUS, + }; + + render(); + expect(screen.queryByText('Request not verified')).toBeNull(); + expect( + screen.queryByText( + 'Because of an error, this request was not verified by the security provider. Proceed with caution.', + ), + ).toBeNull(); + expect(screen.queryByText('This is based on information from')).toBeNull(); + }); }); diff --git a/ui/components/app/signature-request-siwe/signature-request-siwe.js b/ui/components/app/signature-request-siwe/signature-request-siwe.js index b801be970..49c8e1cc0 100644 --- a/ui/components/app/signature-request-siwe/signature-request-siwe.js +++ b/ui/components/app/signature-request-siwe/signature-request-siwe.js @@ -16,6 +16,8 @@ import { formatMessageParams } from '../../../../shared/modules/siwe'; import { Icon } from '../../component-library/icon/icon'; import { IconColor } from '../../../helpers/constants/design-system'; +import SecurityProviderBannerMessage from '../security-provider-banner-message/security-provider-banner-message'; +import { SECURITY_PROVIDER_MESSAGE_SEVERITIES } from '../security-provider-banner-message/security-provider-banner-message.constants'; import Header from './signature-request-siwe-header'; import Message from './signature-request-siwe-message'; @@ -88,6 +90,15 @@ export default function SignatureRequestSIWE({ isSIWEDomainValid={isSIWEDomainValid} subjectMetadata={targetSubjectMetadata} /> + {(txData?.securityProviderResponse?.flagAsDangerous !== undefined && + txData?.securityProviderResponse?.flagAsDangerous !== + SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_MALICIOUS) || + (txData?.securityProviderResponse && + Object.keys(txData.securityProviderResponse).length === 0) ? ( + + ) : null} {!isMatchingAddress && (
+ {(txData?.securityProviderResponse?.flagAsDangerous !== undefined && + txData?.securityProviderResponse?.flagAsDangerous !== + SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_MALICIOUS) || + (txData?.securityProviderResponse && + Object.keys(txData.securityProviderResponse).length === 0) ? ( + + ) : null}
{ @@ -275,5 +276,88 @@ describe('Signature Request Component', () => { expect(getByText('Reject 2 requests')).toBeInTheDocument(); }); + + it('should render SecurityProviderBannerMessage component properly', () => { + const msgParams = { + data: JSON.stringify(messageData), + version: 'V4', + origin: 'test', + }; + + const { queryByText } = renderWithProvider( + undefined} + cancel={() => undefined} + cancelAll={() => undefined} + mostRecentOverviewPage="/" + showRejectTransactionsConfirmationModal={() => undefined} + history={{ push: '/' }} + sign={() => undefined} + txData={{ + msgParams, + securityProviderResponse: { + flagAsDangerous: '?', + reason: 'Some reason...', + reason_header: 'Some reason header...', + }, + }} + fromAccount={{ address: fromAddress }} + provider={{ type: 'rpc' }} + unapprovedMessagesCount={2} + />, + store, + ); + + expect(queryByText('Request not verified')).toBeInTheDocument(); + expect( + queryByText( + 'Because of an error, this request was not verified by the security provider. Proceed with caution.', + ), + ).toBeInTheDocument(); + expect( + queryByText('This is based on information from'), + ).toBeInTheDocument(); + }); + + it('should not render SecurityProviderBannerMessage component when flagAsDangerous is not malicious', () => { + const msgParams = { + data: JSON.stringify(messageData), + version: 'V4', + origin: 'test', + }; + + const { queryByText } = renderWithProvider( + undefined} + cancel={() => undefined} + cancelAll={() => undefined} + mostRecentOverviewPage="/" + showRejectTransactionsConfirmationModal={() => undefined} + history={{ push: '/' }} + sign={() => undefined} + txData={{ + msgParams, + securityProviderResponse: { + flagAsDangerous: + SECURITY_PROVIDER_MESSAGE_SEVERITIES.NOT_MALICIOUS, + }, + }} + fromAccount={{ address: fromAddress }} + provider={{ type: 'rpc' }} + unapprovedMessagesCount={2} + />, + store, + ); + + expect(queryByText('Request not verified')).toBeNull(); + expect( + queryByText( + 'Because of an error, this request was not verified by the security provider. Proceed with caution.', + ), + ).toBeNull(); + expect(queryByText('This is based on information from')).toBeNull(); + }); }); });