diff --git a/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.test.tsx b/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.test.tsx new file mode 100644 index 00000000000..a76f4d95523 --- /dev/null +++ b/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.test.tsx @@ -0,0 +1,77 @@ +import React from 'react' + +import { HookDappType, HookToDappMatch, CowHookDetails } from '@cowprotocol/hook-dapp-lib' + +import { i18n } from '@lingui/core' +import { I18nProvider } from '@lingui/react' +import { fireEvent, render, screen } from '@testing-library/react' +import { ThemeProvider as StyledComponentsThemeProvider } from 'styled-components/macro' +import { getCowswapTheme } from 'theme' + +import { useSimulationData } from 'modules/tenderly/hooks/useSimulationData' + +import { HookItem } from './index' + +jest.mock('modules/tenderly/hooks/useSimulationData', () => ({ + useSimulationData: jest.fn(), +})) + +const mockUseSimulationData = useSimulationData as jest.MockedFunction + +i18n.load('en-US', {}) +i18n.activate('en-US') + +const details: CowHookDetails = { + uuid: 'hook-1', + hook: { + target: '0x1111111111111111111111111111111111111111', + callData: '0xdeadbeef', + gasLimit: '100000', + dappId: 'test-hook', + }, +} + +const item: HookToDappMatch = { + dapp: { + id: 'test-hook', + name: 'Test hook', + descriptionShort: 'Test description', + type: HookDappType.IFRAME, + version: '1.0.0', + website: 'https://cow.fi', + image: 'https://cow.fi/icon.png', + }, + hook: details.hook, +} + +function renderComponent(): ReturnType { + return render( + + + + + , + ) +} + +describe('HookItem', () => { + beforeEach(() => { + mockUseSimulationData.mockReturnValue({ + id: 'simulation-1', + link: 'javascript:alert(1)', + status: false, + cumulativeBalancesDiff: {}, + stateDiff: [], + gasUsed: '0', + }) + }) + + it('renders an invalid simulation link as inert text', () => { + renderComponent() + + fireEvent.click(screen.getByText('Test hook')) + + expect(screen.queryByRole('link', { name: 'Simulation failed' })).toBeNull() + expect(screen.getByText('Simulation failed')).not.toBeNull() + }) +}) diff --git a/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.tsx b/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.tsx index f4c202c8449..f87a0ec7f6f 100644 --- a/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.tsx +++ b/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-restricted-imports */ // TODO: Don't use 'modules' import import { ReactNode, useState } from 'react' +import { getSafeAbsoluteUrl } from '@cowprotocol/common-utils' import { CowHookDetails, HookToDappMatch } from '@cowprotocol/hook-dapp-lib' import { t } from '@lingui/core/macro' @@ -28,6 +29,9 @@ export function HookItem({ const simulationData = useSimulationData(details?.uuid) const dappName = item.dapp?.name || t`Unknown Hook` + const safeWebsiteUrl = item.dapp ? getSafeAbsoluteUrl(item.dapp.website) : null + const websiteHostname = safeWebsiteUrl ? new URL(safeWebsiteUrl).hostname : null + const safeSimulationUrl = simulationData ? getSafeAbsoluteUrl(simulationData.link) : null return ( @@ -66,18 +70,24 @@ export function HookItem({ Simulation: - - {simulationData.status ? Simulation successful : Simulation failed} - + {safeSimulationUrl ? ( + + {simulationData.status ? Simulation successful : Simulation failed} + + ) : ( + + {simulationData.status ? Simulation successful : Simulation failed} + + )}

)} @@ -97,18 +107,22 @@ export function HookItem({ Website: {' '} - - {item.dapp.website} - + {safeWebsiteUrl && websiteHostname ? ( + + {item.dapp.website} + + ) : ( + item.dapp.website + )}

)} diff --git a/apps/cowswap-frontend/src/legacy/state/application/localWarning.ts b/apps/cowswap-frontend/src/legacy/state/application/localWarning.ts index f89b5293c95..af77d45d7ab 100644 --- a/apps/cowswap-frontend/src/legacy/state/application/localWarning.ts +++ b/apps/cowswap-frontend/src/legacy/state/application/localWarning.ts @@ -1,12 +1,12 @@ -import { PINATA_API_KEY, PINATA_SECRET_API_KEY } from '@cowprotocol/common-const' +import { PINATA_API_KEY } from '@cowprotocol/common-const' import { isLocal } from '@cowprotocol/common-utils' let warningMsg -if ((!PINATA_SECRET_API_KEY || !PINATA_API_KEY) && isLocal) { +if (!PINATA_API_KEY && isLocal) { warningMsg = - "Pinata env vars not set. Order appData upload won't work! " + - 'Set REACT_APP_PINATA_API_KEY and REACT_APP_PINATA_SECRET_API_KEY' + "Pinata public env var not set. Order appData upload won't work! " + + 'Set REACT_APP_PINATA_API_KEY and configure the required server-side Pinata credentials separately.' } export const localWarning = warningMsg diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index e6a627db37c..803489d16ec 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -144,6 +144,7 @@ msgstr "Swap anyway" msgid "The hash for this Safe transaction." msgstr "The hash for this Safe transaction." +#: apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.tsx #: apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.tsx #: apps/cowswap-frontend/src/common/containers/OrderHooksDetails/index.tsx #: apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx @@ -5665,6 +5666,10 @@ msgstr "N/A" msgid "This transaction can be simulated before execution to ensure that it will be succeed, generating a detailed report of the transaction execution." msgstr "This transaction can be simulated before execution to ensure that it will be succeed, generating a detailed report of the transaction execution." +#: apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/constants.tsx +msgid "Invalid website URL in manifest. Only http(s) URLs are allowed, with http limited to local development." +msgstr "Invalid website URL in manifest. Only http(s) URLs are allowed, with http limited to local development." + #: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/QuoteErrorsButton/quoteErrors.utils.ts msgid "Sell amount too small to bridge" msgstr "Sell amount too small to bridge" @@ -6759,6 +6764,7 @@ msgstr "Network fees and costs" msgid "NEW" msgstr "NEW" +#: apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.tsx #: apps/cowswap-frontend/src/common/containers/OrderHooksDetails/HookItem/index.tsx #: apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx msgid "Simulation successful" diff --git a/apps/cowswap-frontend/src/modules/accountProxy/containers/AccountProxyRecoverPage/index.tsx b/apps/cowswap-frontend/src/modules/accountProxy/containers/AccountProxyRecoverPage/index.tsx index 5ac90ed2404..0a553cac785 100644 --- a/apps/cowswap-frontend/src/modules/accountProxy/containers/AccountProxyRecoverPage/index.tsx +++ b/apps/cowswap-frontend/src/modules/accountProxy/containers/AccountProxyRecoverPage/index.tsx @@ -2,7 +2,7 @@ import { ReactNode, useCallback, useState } from 'react' import { useUpdateTokenBalance } from '@cowprotocol/balances-and-allowances' import { useComponentDestroyedRef } from '@cowprotocol/common-hooks' -import { getIsNativeToken, isFractionFalsy } from '@cowprotocol/common-utils' +import { getIsNativeToken, isAddress, isFractionFalsy } from '@cowprotocol/common-utils' import { areAddressesEqual } from '@cowprotocol/cow-sdk' import { TokenLogo } from '@cowprotocol/tokens' import { ButtonSize, CenteredDots, FiatAmount, Loader, TokenSymbol } from '@cowprotocol/ui' @@ -37,19 +37,22 @@ export function AccountProxyRecoverPage(): ReactNode { const [txInProgress, setTxInProgress] = useState(false) const navigateBack = useNavigateBack() - const { balance, usdValue } = useTokenBalanceAndUsdValue(tokenAddress) + const proxies = useAccountProxies() + const ownedProxy = proxies?.find((p) => proxyAddress && areAddressesEqual(p.account, proxyAddress)) + const validProxyAddress = ownedProxy?.account + const validTokenAddress = tokenAddress && isAddress(tokenAddress) ? tokenAddress : undefined + const { balance, usdValue } = useTokenBalanceAndUsdValue(validTokenAddress) const destroyedRef = useComponentDestroyedRef() - const proxies = useAccountProxies() const updateTokenBalance = useUpdateTokenBalance() - const proxyVersion = proxies?.find((p) => areAddressesEqual(p.account, proxyAddress))?.version + const proxyVersion = ownedProxy?.version const recoverFundsContext = useRecoverFundsFromProxy( - proxyAddress, + validProxyAddress, proxyVersion, - tokenAddress, + validTokenAddress, balance, - !!tokenAddress && getIsNativeToken(chainId, tokenAddress), + !!validTokenAddress && getIsNativeToken(chainId, validTokenAddress), ) const { txSigningStep } = recoverFundsContext @@ -70,11 +73,11 @@ export function AccountProxyRecoverPage(): ReactNode { // When tx is successfully mined () => { navigateBack() - tokenAddress && updateTokenBalance(tokenAddress, 0n) + validTokenAddress && updateTokenBalance(validTokenAddress, 0n) }, ) }) - }, [recoverCallback, navigateBack, updateTokenBalance, tokenAddress, destroyedRef]) + }, [recoverCallback, navigateBack, updateTokenBalance, validTokenAddress, destroyedRef]) return ( @@ -104,7 +107,7 @@ export function AccountProxyRecoverPage(): ReactNode { diff --git a/apps/cowswap-frontend/src/modules/application/containers/AppContainer/CowSpeechBubble/CowSpeechBubbleNotificationBanner.tsx b/apps/cowswap-frontend/src/modules/application/containers/AppContainer/CowSpeechBubble/CowSpeechBubbleNotificationBanner.tsx index 0e42186676f..f44d228bfed 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/AppContainer/CowSpeechBubble/CowSpeechBubbleNotificationBanner.tsx +++ b/apps/cowswap-frontend/src/modules/application/containers/AppContainer/CowSpeechBubble/CowSpeechBubbleNotificationBanner.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' +import { getSafeSameOriginOrAbsoluteUrl } from '@cowprotocol/common-utils' import type { NotificationModel } from '@cowprotocol/core' import { UI } from '@cowprotocol/ui' @@ -58,16 +59,18 @@ export function CowSpeechBubbleNotificationBanner({ } const { title, description, url, id } = currentNotification - const linkTarget = url && isInternal(url) ? '_parent' : '_blank' + const safeLink = + typeof window !== 'undefined' && url ? getSafeSameOriginOrAbsoluteUrl(url, window.location.origin) : null + const linkTarget = safeLink?.isExternal ? '_blank' : '_parent' return ( {title} {description} - {url && ( + {safeLink && ( ) } - -function isInternal(href: string): boolean { - if (href.startsWith('/')) return true - - try { - return new URL(href).hostname === window.location.hostname - } catch { - return false - } -} diff --git a/apps/cowswap-frontend/src/modules/bridge/pure/TransactionLink/TransactionLinkDisplay.tsx b/apps/cowswap-frontend/src/modules/bridge/pure/TransactionLink/TransactionLinkDisplay.tsx index 8c7e7b6a278..341b15b3380 100644 --- a/apps/cowswap-frontend/src/modules/bridge/pure/TransactionLink/TransactionLinkDisplay.tsx +++ b/apps/cowswap-frontend/src/modules/bridge/pure/TransactionLink/TransactionLinkDisplay.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react' import iconReceiptSrc from '@cowprotocol/assets/cow-swap/icon-receipt.svg' +import { getSafeAbsoluteUrl } from '@cowprotocol/common-utils' import { ExternalLink } from '@cowprotocol/ui' import { ConfirmDetailsItem } from 'modules/trade' @@ -14,6 +15,12 @@ interface TransactionLinkDisplayProps { } export function TransactionLinkDisplay({ link, label, linkText }: TransactionLinkDisplayProps): ReactNode { + const safeLink = getSafeAbsoluteUrl(link) + + if (!safeLink) { + return null + } + return ( } > - {linkText} + {linkText} ) } diff --git a/apps/cowswap-frontend/src/modules/bridge/pure/contents/BridgingProgressContent/index.tsx b/apps/cowswap-frontend/src/modules/bridge/pure/contents/BridgingProgressContent/index.tsx index 1d4c068f3d7..056907775ac 100644 --- a/apps/cowswap-frontend/src/modules/bridge/pure/contents/BridgingProgressContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/bridge/pure/contents/BridgingProgressContent/index.tsx @@ -1,6 +1,6 @@ import { ReactNode } from 'react' -import { isFractionFalsy } from '@cowprotocol/common-utils' +import { getSafeAbsoluteUrl, isFractionFalsy } from '@cowprotocol/common-utils' import { BridgeStatusResult } from '@cowprotocol/sdk-bridging' import { FailedBridgingContent } from './FailedBridgingContent' @@ -32,6 +32,7 @@ export function BridgingProgressContent(props: BridgingContentProps): ReactNode statusResult, explorerUrl, } = props + const safeExplorerUrl = getSafeAbsoluteUrl(explorerUrl) || undefined return ( @@ -42,14 +43,18 @@ export function BridgingProgressContent(props: BridgingContentProps): ReactNode destinationChainId={destinationChainId} receivedAmount={receivedAmount} receivedAmountUsd={receivedAmountUsd} - explorerUrl={explorerUrl} + explorerUrl={safeExplorerUrl} /> ) : isRefunded ? ( ) : isFailed ? ( ) : ( - + )} ) diff --git a/apps/cowswap-frontend/src/modules/bridge/updaters/PendingBridgeOrdersUpdater.tsx b/apps/cowswap-frontend/src/modules/bridge/updaters/PendingBridgeOrdersUpdater.tsx index b2a3a45eaee..6a51b1f3392 100644 --- a/apps/cowswap-frontend/src/modules/bridge/updaters/PendingBridgeOrdersUpdater.tsx +++ b/apps/cowswap-frontend/src/modules/bridge/updaters/PendingBridgeOrdersUpdater.tsx @@ -1,7 +1,7 @@ import { ReactNode, useEffect, useRef } from 'react' import { GtmEvent, useCowAnalytics } from '@cowprotocol/analytics' -import { timeSinceInSeconds } from '@cowprotocol/common-utils' +import { getSafeAbsoluteUrl, timeSinceInSeconds } from '@cowprotocol/common-utils' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { BridgeStatus, CrossChainOrder } from '@cowprotocol/sdk-bridging' import { UiOrderType } from '@cowprotocol/types' @@ -19,10 +19,11 @@ import { CowSwapAnalyticsCategory } from 'common/analytics/types' const APPZI_CHECK_INTERVAL = 60_000 function processExecutedBridging(crossChainOrder: CrossChainOrder): void { + const safeExplorerUrl = getSafeAbsoluteUrl(crossChainOrder.explorerUrl) || undefined const { provider: _, ...eventPayload } = crossChainOrder // Display snackbar - emitBridgingSuccessEvent(eventPayload) + emitBridgingSuccessEvent({ ...eventPayload, explorerUrl: safeExplorerUrl }) // Play sound getCowSoundSuccess().play() @@ -31,7 +32,7 @@ function processExecutedBridging(crossChainOrder: CrossChainOrder): void { triggerAppziSurvey( { isBridging: true, - explorerUrl: crossChainOrder.explorerUrl, + explorerUrl: safeExplorerUrl, chainId: crossChainOrder.chainId, orderType: UiOrderType.SWAP, account: crossChainOrder.order.owner, @@ -48,6 +49,7 @@ function sendBridgeStatusAnalytics( const { sourceChainId, destinationChainId } = crossChainOrder.bridgingParams const { depositTxHash, fillTxHash, status } = crossChainOrder.statusResult const providerInfo = crossChainOrder.provider.info + const safeExplorerUrl = getSafeAbsoluteUrl(crossChainOrder.explorerUrl) || undefined const payload = { category: CowSwapAnalyticsCategory.Bridge, @@ -60,7 +62,7 @@ function sendBridgeStatusAnalytics( sourceChainId, destinationChainId, bridgeStatus: status, - explorerUrl: crossChainOrder.explorerUrl, + explorerUrl: safeExplorerUrl, depositTxHash, fillTxHash, providerName: providerInfo.name, @@ -100,7 +102,7 @@ function PendingOrderUpdater({ chainId, orderUid, openSince }: PendingOrderUpdat // Start counting from bridge creation timestamp triggerAppziSurvey({ isBridging: true, - explorerUrl: crossChainOrder.explorerUrl, + explorerUrl: getSafeAbsoluteUrl(crossChainOrder.explorerUrl) || undefined, chainId: crossChainOrder.chainId, orderType: UiOrderType.SWAP, account: crossChainOrder.order.owner, diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/constants.tsx b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/constants.tsx index 52d0af5fbde..9cd1b6cdc93 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/constants.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/constants.tsx @@ -14,6 +14,7 @@ export const ERROR_MESSAGES = { INVALID_MANIFEST: msg`Invalid manifest format: Missing "cow_hook_dapp" property in manifest.json`, SMART_CONTRACT_INCOMPATIBLE: msg`This hook is not compatible with smart contract wallets. It only supports EOA wallets.`, INVALID_HOOK_ID: msg`Invalid hook dapp ID format. The ID must be a 64-character hexadecimal string.`, + INVALID_WEBSITE_URL: msg`Invalid website URL in manifest. Only http(s) URLs are allowed, with http limited to local development.`, INVALID_MANIFEST_HTML: ( The URL provided does not return a valid manifest file diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.test.tsx b/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.test.tsx new file mode 100644 index 00000000000..91c35f39d44 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.test.tsx @@ -0,0 +1,102 @@ +import React from 'react' + +import { HookDappType, CowHookDetails } from '@cowprotocol/hook-dapp-lib' + +import { i18n } from '@lingui/core' +import { I18nProvider } from '@lingui/react' +import { render, screen } from '@testing-library/react' +import { ThemeProvider as StyledComponentsThemeProvider } from 'styled-components/macro' +import { getCowswapTheme } from 'theme' + +import { useSimulationData } from 'modules/tenderly/hooks/useSimulationData' +import { useTenderlyBundleSimulation } from 'modules/tenderly/hooks/useTenderlyBundleSimulation' + +import { AppliedHookItem } from './index' + +jest.mock('modules/tenderly/hooks/useSimulationData', () => ({ + useSimulationData: jest.fn(), +})) + +jest.mock('modules/tenderly/hooks/useTenderlyBundleSimulation', () => ({ + useTenderlyBundleSimulation: jest.fn(), +})) + +jest.mock('@cowprotocol/ui', () => ({ + InfoTooltip: () => null, +})) + +jest.mock('react-inlinesvg', () => ({ + __esModule: true, + default: () => null, +})) + +const mockUseSimulationData = useSimulationData as jest.MockedFunction +const mockUseTenderlyBundleSimulation = useTenderlyBundleSimulation as jest.MockedFunction< + typeof useTenderlyBundleSimulation +> + +i18n.load('en-US', {}) +i18n.activate('en-US') + +const hookDetails: CowHookDetails = { + uuid: 'hook-1', + hook: { + target: '0x1111111111111111111111111111111111111111', + callData: '0xdeadbeef', + gasLimit: '100000', + dappId: 'test-hook', + }, +} + +const dapp = { + id: 'test-hook', + name: 'Test hook', + descriptionShort: 'Test description', + type: HookDappType.IFRAME, + version: '1.0.0', + website: 'https://cow.fi', + image: 'https://cow.fi/icon.png', + url: 'https://cow.fi/dapp', +} + +function renderComponent(): ReturnType { + return render( + + + + + , + ) +} + +describe('AppliedHookItem', () => { + beforeEach(() => { + mockUseTenderlyBundleSimulation.mockReturnValue({ + isValidating: false, + mutate: jest.fn(), + } as ReturnType) + mockUseSimulationData.mockReturnValue({ + id: 'simulation-1', + link: 'javascript:alert(1)', + status: false, + cumulativeBalancesDiff: {}, + stateDiff: [], + gasUsed: '0', + }) + }) + + it('renders an invalid simulation link as inert text', () => { + renderComponent() + + expect(screen.queryByRole('link', { name: 'Simulation failed' })).toBeNull() + expect(screen.getByText('Simulation failed')).not.toBeNull() + }) +}) diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx b/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx index c1640bb2752..3165cba4013 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx @@ -1,7 +1,10 @@ +import { ReactElement } from 'react' + import svgCheckSingularSrc from '@cowprotocol/assets/cow-swap/check-singular.svg' import svgGridSrc from '@cowprotocol/assets/cow-swap/grid.svg' import svgTenderlySrc from '@cowprotocol/assets/cow-swap/tenderly-logo.svg' import svgXSrc from '@cowprotocol/assets/cow-swap/x.svg' +import { getSafeAbsoluteUrl } from '@cowprotocol/common-utils' import { CowHookDetails } from '@cowprotocol/hook-dapp-lib' import { InfoTooltip } from '@cowprotocol/ui' @@ -32,6 +35,39 @@ interface HookItemProp { // TODO: refactor tu use single simulation as fallback const isBundleSimulationReady = true +interface BundleSimulationStatusProps { + isSuccessful: boolean + safeSimulationUrl: string | null + simulationStatus: string + simulationTooltip: string +} + +function BundleSimulationStatus({ + isSuccessful, + safeSimulationUrl, + simulationStatus, + simulationTooltip, +}: BundleSimulationStatusProps): ReactElement { + return ( + + {isSuccessful ? ( + + ) : ( + + )} + {safeSimulationUrl ? ( + + {simulationStatus} + + + ) : ( + {simulationStatus} + )} + + + ) +} + // TODO: Break down this large function into smaller functions // TODO: Add proper return type annotation // TODO: Reduce function complexity by extracting logic @@ -56,6 +92,7 @@ export function AppliedHookItem({ : t`The Tenderly simulation failed. Please review your transaction.` const dAppName = dapp?.name ? i18n._(dapp.name) : '' + const safeSimulationUrl = simulationData ? getSafeAbsoluteUrl(simulationData.link) : null return ( @@ -87,22 +124,12 @@ export function AppliedHookItem({ {account && isBundleSimulationReady && simulationData && ( - - {simulationData.status ? ( - - ) : ( - - )} - {simulationData.link ? ( - - {simulationStatus} - - - ) : ( - {simulationStatus} - )} - - + )} {!isBundleSimulationReady && ( diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/HookDappDetails/index.tsx b/apps/cowswap-frontend/src/modules/hooksStore/pure/HookDappDetails/index.tsx index fb0c3f41f87..853f6064e4f 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/pure/HookDappDetails/index.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/HookDappDetails/index.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' +import { getSafeAbsoluteUrl } from '@cowprotocol/common-utils' import { HookDappType, HookDappWalletCompatibility } from '@cowprotocol/hook-dapp-lib' import { Command } from '@cowprotocol/types' import { HelpTooltip } from '@cowprotocol/ui' @@ -25,6 +26,7 @@ export function HookDappDetails({ dapp, onSelect, walletType }: HookDappDetailsP const { i18n } = useLingui() const tags = useMemo(() => { const { version, website, type, conditions } = dapp + const safeWebsiteUrl = getSafeAbsoluteUrl(website) const walletCompatibility = conditions?.walletCompatibility || [] // TODO: Add proper return type annotation @@ -51,7 +53,7 @@ export function HookDappDetails({ dapp, onSelect, walletType }: HookDappDetailsP return [ { label: t`Hook version`, value: version }, - { label: t`Website`, link: website }, + { label: t`Website`, value: website, link: safeWebsiteUrl || undefined }, { label: t`Type`, value: typeLabel, diff --git a/apps/cowswap-frontend/src/modules/hooksStore/validateHookDappManifest.tsx b/apps/cowswap-frontend/src/modules/hooksStore/validateHookDappManifest.tsx index 0cbaaa0a077..040a0a30823 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/validateHookDappManifest.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/validateHookDappManifest.tsx @@ -1,6 +1,7 @@ import { ReactElement } from 'react' import { getChainInfo } from '@cowprotocol/common-const' +import { getSafeAbsoluteUrl } from '@cowprotocol/common-utils' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { HOOK_DAPP_ID_LENGTH, HookDappBase, HookDappWalletCompatibility } from '@cowprotocol/hook-dapp-lib' @@ -47,6 +48,10 @@ export function validateHookDappManifest( return i18n._(ERROR_MESSAGES.INVALID_HOOK_ID) } + if (!getSafeAbsoluteUrl(dapp.website)) { + return i18n._(ERROR_MESSAGES.INVALID_WEBSITE_URL) + } + if (chainId && conditions.supportedNetworks && !conditions.supportedNetworks.includes(chainId)) { return ERROR_MESSAGES.NETWORK_COMPATIBILITY_ERROR( chainId, diff --git a/apps/cowswap-frontend/src/modules/orders/containers/BridgingSuccessNotification/index.tsx b/apps/cowswap-frontend/src/modules/orders/containers/BridgingSuccessNotification/index.tsx index 1aa305e884e..9a9b9052fcf 100644 --- a/apps/cowswap-frontend/src/modules/orders/containers/BridgingSuccessNotification/index.tsx +++ b/apps/cowswap-frontend/src/modules/orders/containers/BridgingSuccessNotification/index.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react' +import { getSafeAbsoluteUrl } from '@cowprotocol/common-utils' import { OnBridgingSuccessPayload, ToastMessageType } from '@cowprotocol/events' import { ExternalLink } from '@cowprotocol/ui' @@ -16,6 +17,7 @@ interface BridgingSuccessNotificationProps { export function BridgingSuccessNotification({ payload }: BridgingSuccessNotificationProps): ReactNode { const { chainId, order } = payload + const safeExplorerUrl = getSafeAbsoluteUrl(payload.explorerUrl) return (
- + View on Bridge Explorer ↗ diff --git a/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useSetupTradeStateFromUrl.ts b/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useSetupTradeStateFromUrl.ts index e7e216788e8..ae0e5627b60 100644 --- a/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useSetupTradeStateFromUrl.ts +++ b/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useSetupTradeStateFromUrl.ts @@ -24,11 +24,10 @@ export function useSetupTradeStateFromUrl(): null { const stringifiedParams = JSON.stringify(params) const setState = useSetAtom(tradeStateFromUrlAtom) - const { chainId, recipient, recipientAddress, targetChainId, inputCurrencyId, outputCurrencyId } = useMemo(() => { + const { chainId, recipient, targetChainId, inputCurrencyId, outputCurrencyId } = useMemo(() => { const searchParams = new URLSearchParams(location.search) const targetChainId = searchParams.get('targetChainId') const recipient = searchParams.get('recipient') - const recipientAddress = searchParams.get('recipientAddress') const { chainId, inputCurrencyId, outputCurrencyId } = JSON.parse(stringifiedParams) return { @@ -36,7 +35,6 @@ export function useSetupTradeStateFromUrl(): null { inputCurrencyId: inputCurrencyId ?? null, outputCurrencyId: outputCurrencyId ?? null, recipient, - recipientAddress, targetChainId: getChainId(targetChainId), } }, [location.search, stringifiedParams]) @@ -53,11 +51,10 @@ export function useSetupTradeStateFromUrl(): null { inputCurrencyId, outputCurrencyId, ...(recipient ? { recipient } : undefined), - ...(recipientAddress ? { recipientAddress } : undefined), } setState(state) - }, [chainId, recipient, recipientAddress, setState, targetChainId, inputCurrencyId, outputCurrencyId]) + }, [chainId, recipient, setState, targetChainId, inputCurrencyId, outputCurrencyId]) return null } diff --git a/apps/cowswap-frontend/src/modules/trade/hooks/useWithRecipient.test.ts b/apps/cowswap-frontend/src/modules/trade/hooks/useWithRecipient.test.ts index 068f726e692..6c905279ee8 100644 --- a/apps/cowswap-frontend/src/modules/trade/hooks/useWithRecipient.test.ts +++ b/apps/cowswap-frontend/src/modules/trade/hooks/useWithRecipient.test.ts @@ -85,4 +85,10 @@ describe('useIsWithRecipient', () => { expect(render(true)).toBe(true) }) }) + + it('does not show when only recipientAddress exists without recipient', () => { + mockTradeStateFromUrl.mockReturnValue({ recipientAddress: '0xrecipient' } as ReturnType) + + expect(render(false)).toBe(false) + }) }) diff --git a/libs/common-const/src/ipfs.ts b/libs/common-const/src/ipfs.ts index d3c4cc98510..fc5769c9796 100644 --- a/libs/common-const/src/ipfs.ts +++ b/libs/common-const/src/ipfs.ts @@ -1,2 +1 @@ export const PINATA_API_KEY = process.env['REACT_APP_PINATA_API_KEY'] as string -export const PINATA_SECRET_API_KEY = process.env['REACT_APP_PINATA_SECRET_API_KEY'] as string diff --git a/libs/common-utils/src/getExplorerLink.test.ts b/libs/common-utils/src/getExplorerLink.test.ts index b41af0a5540..9dae2dea3e1 100644 --- a/libs/common-utils/src/getExplorerLink.test.ts +++ b/libs/common-utils/src/getExplorerLink.test.ts @@ -13,4 +13,10 @@ describe('#getExplorerLink', () => { it('unrecognized chain id defaults to mainnet', () => { expect(getExplorerLink(2, 'abc', ExplorerDataType.ADDRESS)).toEqual('https://etherscan.io/address/abc') }) + + it('does not introduce double slashes for bare explorer origins', () => { + expect(getExplorerLink(1, 'abc', ExplorerDataType.TRANSACTION, 'https://etherscan.io')).toEqual( + 'https://etherscan.io/tx/abc', + ) + }) }) diff --git a/libs/common-utils/src/getExplorerLink.ts b/libs/common-utils/src/getExplorerLink.ts index 4c736bf434f..8d59df0e1c1 100644 --- a/libs/common-utils/src/getExplorerLink.ts +++ b/libs/common-utils/src/getExplorerLink.ts @@ -1,6 +1,8 @@ import { CHAIN_INFO } from '@cowprotocol/common-const' import { isBtcChain, isSolanaChain, SupportedChainId } from '@cowprotocol/cow-sdk' +import { getSafeAbsoluteUrl } from './safeLink' + export enum ExplorerDataType { TRANSACTION = 'transaction', TOKEN = 'token', @@ -78,7 +80,12 @@ export function getExplorerLink( defaultPrefix = 'https://etherscan.io', ): string { // Allow override via environment variable for local development (e.g., Otterscan) - const prefix = BLOCK_EXPLORER_URL_OVERRIDE || CHAIN_INFO[chainId as SupportedChainId]?.explorer || defaultPrefix + const prefix = + getSafeAbsoluteUrl(BLOCK_EXPLORER_URL_OVERRIDE) || + getSafeAbsoluteUrl(CHAIN_INFO[chainId as SupportedChainId]?.explorer) || + getSafeAbsoluteUrl(defaultPrefix) + + if (!prefix) return '' if (isBtcChain(chainId)) return getBtcExplorerData(prefix, data, type) if (isSolanaChain(chainId)) return getSolExplorerData(prefix, data, type) diff --git a/libs/common-utils/src/index.ts b/libs/common-utils/src/index.ts index 8a38f71a5aa..20ddece22ee 100644 --- a/libs/common-utils/src/index.ts +++ b/libs/common-utils/src/index.ts @@ -54,6 +54,7 @@ export * from './rawToTokenAmount' export * from './request' export * from './resolveENSContentHash' export * from './retry' +export * from './safeLink' export * from './sentry' export * from './time' export * from './toggleBodyClass' diff --git a/libs/common-utils/src/legacyAddressUtils.test.ts b/libs/common-utils/src/legacyAddressUtils.test.ts index 4895ffdf745..2ea16fb0bcd 100644 --- a/libs/common-utils/src/legacyAddressUtils.test.ts +++ b/libs/common-utils/src/legacyAddressUtils.test.ts @@ -1,4 +1,4 @@ -import { isAddress, shortenAddress } from './legacyAddressUtils' +import { getBlockExplorerUrl, isAddress, shortenAddress } from './legacyAddressUtils' describe('utils', () => { describe('#isAddress', () => { @@ -33,4 +33,10 @@ describe('utils', () => { expect(shortenAddress('0x2E1b342132A67Ea578e4E3B814bae2107dc254CC'.toLowerCase())).toBe('0x2E1b...54CC') }) }) + + describe('#getBlockExplorerUrl', () => { + it('does not introduce double slashes for bare explorer origins', () => { + expect(getBlockExplorerUrl(1, 'transaction', 'abc', 'https://etherscan.io')).toBe('https://etherscan.io/tx/abc') + }) + }) }) diff --git a/libs/common-utils/src/legacyAddressUtils.ts b/libs/common-utils/src/legacyAddressUtils.ts index 030b6cd1a6c..b8fa0ed028b 100644 --- a/libs/common-utils/src/legacyAddressUtils.ts +++ b/libs/common-utils/src/legacyAddressUtils.ts @@ -13,6 +13,7 @@ import { t } from '@lingui/core/macro' import { getAddress } from 'viem' import { getExplorerOrderLink } from './explorer' +import { getSafeAbsoluteUrl } from './safeLink' /** * Environment variable to override the block explorer URL. @@ -172,7 +173,10 @@ function getBtcExplorerUrl(basePath: string, data: string, type: BlockExplorerLi function getEtherscanUrl(chainId: TargetChainId, data: string, type: BlockExplorerLinkType, base?: string): string { // Allow override via environment variable for local development (e.g., Otterscan) - const basePath = BLOCK_EXPLORER_URL_OVERRIDE || base || CHAIN_INFO[chainId]?.explorer + const basePath = + getSafeAbsoluteUrl(BLOCK_EXPLORER_URL_OVERRIDE) || + getSafeAbsoluteUrl(base) || + getSafeAbsoluteUrl(CHAIN_INFO[chainId]?.explorer) if (!basePath) return '' diff --git a/libs/common-utils/src/safeLink.test.ts b/libs/common-utils/src/safeLink.test.ts new file mode 100644 index 00000000000..4c866bbcb4f --- /dev/null +++ b/libs/common-utils/src/safeLink.test.ts @@ -0,0 +1,58 @@ +import { getSafeAbsoluteUrl, getSafeSameOriginOrAbsoluteUrl } from './safeLink' + +describe('safeLink', () => { + describe('getSafeAbsoluteUrl', () => { + const originalNodeEnv = process.env.NODE_ENV + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv + }) + + it('accepts https urls', () => { + expect(getSafeAbsoluteUrl('https://cow.fi/learn')).toBe('https://cow.fi/learn') + }) + + it('keeps bare origins slash-stable', () => { + expect(getSafeAbsoluteUrl('https://etherscan.io')).toBe('https://etherscan.io') + }) + + it('accepts IPv6 localhost urls in development', () => { + process.env.NODE_ENV = 'development' + + expect(getSafeAbsoluteUrl('http://[::1]:8003/path')).toBe('http://[::1]:8003/path') + }) + + it('rejects unsafe schemes', () => { + expect(getSafeAbsoluteUrl('javascript:alert(1)')).toBeNull() + expect(getSafeAbsoluteUrl('data:text/html,boom')).toBeNull() + expect(getSafeAbsoluteUrl('blob:https://cow.fi/id')).toBeNull() + }) + + it('rejects urls with credentials', () => { + expect(getSafeAbsoluteUrl('https://user:pass@cow.fi/learn')).toBeNull() + }) + }) + + describe('getSafeSameOriginOrAbsoluteUrl', () => { + const currentOrigin = 'https://swap.cow.fi' + + it('accepts same-origin relative urls', () => { + expect(getSafeSameOriginOrAbsoluteUrl('/learn/article', currentOrigin)).toEqual({ + href: '/learn/article', + isExternal: false, + }) + }) + + it('accepts external https urls', () => { + expect(getSafeSameOriginOrAbsoluteUrl('https://cow.fi/learn', currentOrigin)).toEqual({ + href: 'https://cow.fi/learn', + isExternal: true, + }) + }) + + it('rejects protocol-relative and unsafe urls', () => { + expect(getSafeSameOriginOrAbsoluteUrl('//attacker.example', currentOrigin)).toBeNull() + expect(getSafeSameOriginOrAbsoluteUrl('javascript:alert(1)', currentOrigin)).toBeNull() + }) + }) +}) diff --git a/libs/common-utils/src/safeLink.ts b/libs/common-utils/src/safeLink.ts new file mode 100644 index 00000000000..d03ac300dae --- /dev/null +++ b/libs/common-utils/src/safeLink.ts @@ -0,0 +1,58 @@ +import { isDevelopmentEnv } from './env' + +const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']) + +export interface SafeLinkResult { + href: string + isExternal: boolean +} + +function isLocalDevHostname(hostname: string): boolean { + const normalizedHostname = + hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + + return LOCALHOST_HOSTNAMES.has(normalizedHostname) || normalizedHostname.endsWith('.localhost') +} + +function isAllowedHttpUrl(url: URL): boolean { + if (url.username || url.password) return false + if (url.protocol === 'https:') return true + + return url.protocol === 'http:' && isDevelopmentEnv() && isLocalDevHostname(url.hostname) +} + +export function getSafeAbsoluteUrl(href: string | null | undefined): string | null { + if (!href) return null + + try { + const url = new URL(href) + + if (!isAllowedHttpUrl(url)) return null + + const isBareOrigin = url.pathname === '/' && !url.search && !url.hash + + return isBareOrigin ? url.origin : url.toString() + } catch { + return null + } +} + +export function getSafeSameOriginOrAbsoluteUrl( + href: string | null | undefined, + currentOrigin: string, +): SafeLinkResult | null { + if (!href) return null + if (href.startsWith('//')) return null + + if (href.startsWith('/')) { + return { href, isExternal: false } + } + + const safeAbsoluteUrl = getSafeAbsoluteUrl(href) + if (!safeAbsoluteUrl) return null + + return { + href: safeAbsoluteUrl, + isExternal: new URL(safeAbsoluteUrl).origin !== currentOrigin, + } +} diff --git a/libs/widget-lib/src/IframeSafeSdkBridge.ts b/libs/widget-lib/src/IframeSafeSdkBridge.ts index 9b280ee1abc..4f177433cda 100644 --- a/libs/widget-lib/src/IframeSafeSdkBridge.ts +++ b/libs/widget-lib/src/IframeSafeSdkBridge.ts @@ -3,21 +3,36 @@ export class IframeSafeSdkBridge { constructor( private appWindow: Window, - private iframeWidow: Window, + private iframeWindow: Window, + private iframeOrigin: string, + private parentOrigin: string | null, ) { this.forwardSdkMessage = (event: MessageEvent) => { if (!isSafeMessage(event.data)) { return } - if (typeof window !== 'undefined' && event.origin === window.location.origin) { - return - } - if (isSafeMessageRequest(event.data)) { - this.appWindow.parent.postMessage(event.data, '*') + if ( + event.source !== this.iframeWindow || + event.origin !== this.iframeOrigin || + !this.parentOrigin || + this.appWindow.parent === this.appWindow + ) { + return + } + + this.appWindow.parent.postMessage(event.data, this.parentOrigin) } else if (isSafeMessageResponse(event.data)) { - this.iframeWidow.postMessage(event.data, '*') + if ( + event.source !== this.appWindow.parent || + event.origin !== this.parentOrigin || + this.appWindow.parent === this.appWindow + ) { + return + } + + this.iframeWindow.postMessage(event.data, this.iframeOrigin) } } @@ -33,6 +48,18 @@ export class IframeSafeSdkBridge { } } +export function getTrustedParentOrigin(appWindow: Window): string | null { + if (appWindow.parent === appWindow || !appWindow.document.referrer) { + return null + } + + try { + return new URL(appWindow.document.referrer).origin + } catch { + return null + } +} + function isSafeMessage(obj: unknown): obj is SafeMessage { return typeof obj === 'object' && obj !== null && 'id' in obj && typeof obj.id === 'string' } diff --git a/libs/widget-lib/src/cowSwapWidget.ts b/libs/widget-lib/src/cowSwapWidget.ts index d7903e135dc..35c7462526a 100644 --- a/libs/widget-lib/src/cowSwapWidget.ts +++ b/libs/widget-lib/src/cowSwapWidget.ts @@ -4,7 +4,7 @@ import { IframeRpcProviderBridge } from '@cowprotocol/iframe-transport' import { isAllowedWindowOpenUrl } from './allowedWindowOpenUrl' import { WIDGET_IFRAME_ALLOW, WIDGET_IFRAME_REFERRER_POLICY, WIDGET_IFRAME_SANDBOX } from './cowSwapWidget.constants' import { IframeCowEventEmitter } from './IframeCowEventEmitter' -import { IframeSafeSdkBridge } from './IframeSafeSdkBridge' +import { getTrustedParentOrigin, IframeSafeSdkBridge } from './IframeSafeSdkBridge' import { logWidget } from './logger' import { CowSwapWidgetParams, @@ -144,7 +144,9 @@ export function createCowSwapWidget(container: HTMLElement, props: CowSwapWidget }) // 10. Listen for Safe SDK messages from the iframe only when explicitly enabled by the host. - const iframeSafeSdkBridge = enableSafeSdkBridge ? new IframeSafeSdkBridge(window, iframeWindow) : null + const iframeSafeSdkBridge = enableSafeSdkBridge + ? new IframeSafeSdkBridge(window, iframeWindow, iframeOrigin, getTrustedParentOrigin(window)) + : null // 11. Return the handler, so the widget, listeners, and provider can be updated return {