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 {