Skip to content

Commit 3f4e4de

Browse files
committed
Run query param sanitization
1 parent cb0807e commit 3f4e4de

10 files changed

Lines changed: 634 additions & 226 deletions
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { PathnameEnum } from '@/bridge/constants';
4+
5+
import { initializeBridgePage } from '../bridgePageUtils';
6+
7+
const { redirectMock, registerLocalNetworkMock } = vi.hoisted(() => {
8+
process.env.NEXT_PUBLIC_INFURA_KEY ||= 'test-infura-key';
9+
process.env.NEXT_PUBLIC_FEATURE_FLAG_LIFI = 'true';
10+
11+
return {
12+
redirectMock: vi.fn(),
13+
registerLocalNetworkMock: vi.fn(async () => undefined),
14+
};
15+
});
16+
17+
vi.mock('next/navigation', () => ({
18+
redirect: redirectMock,
19+
}));
20+
21+
vi.mock('@/bridge/util/networks', async (importActual) => {
22+
const actual = await importActual<typeof import('@/bridge/util/networks')>();
23+
24+
return {
25+
...actual,
26+
registerLocalNetwork: registerLocalNetworkMock,
27+
};
28+
});
29+
30+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
31+
32+
function getRedirectedUrl() {
33+
const [redirectTarget] = redirectMock.mock.calls.at(-1) ?? [];
34+
35+
if (!redirectTarget) {
36+
throw new Error('Expected redirect to be called.');
37+
}
38+
39+
return new URL(redirectTarget, 'https://example.com');
40+
}
41+
42+
describe('initializeBridgePage sanitization', () => {
43+
beforeEach(() => {
44+
redirectMock.mockClear();
45+
registerLocalNetworkMock.mockClear();
46+
});
47+
48+
it('sets destinationToken to zero address for first-load apechain -> superposition', async () => {
49+
await initializeBridgePage({
50+
searchParams: {
51+
sourceChain: 'apechain',
52+
destinationChain: 'superposition',
53+
},
54+
redirectPath: PathnameEnum.BRIDGE,
55+
});
56+
57+
expect(redirectMock).toHaveBeenCalledTimes(1);
58+
const redirected = getRedirectedUrl();
59+
60+
expect(redirected.pathname).toBe(PathnameEnum.BRIDGE);
61+
expect(redirected.searchParams.get('sourceChain')).toBe('apechain');
62+
expect(redirected.searchParams.get('destinationChain')).toBe('superposition');
63+
expect(redirected.searchParams.get('destinationToken')).toBe(ZERO_ADDRESS);
64+
expect(redirected.searchParams.get('sanitized')).toBe('true');
65+
});
66+
67+
it('sets token and destinationToken to zero address for first-load superposition -> apechain', async () => {
68+
await initializeBridgePage({
69+
searchParams: {
70+
sourceChain: 'superposition',
71+
destinationChain: 'apechain',
72+
},
73+
redirectPath: PathnameEnum.BRIDGE,
74+
});
75+
76+
expect(redirectMock).toHaveBeenCalledTimes(1);
77+
const redirected = getRedirectedUrl();
78+
79+
expect(redirected.pathname).toBe(PathnameEnum.BRIDGE);
80+
expect(redirected.searchParams.get('sourceChain')).toBe('superposition');
81+
expect(redirected.searchParams.get('destinationChain')).toBe('apechain');
82+
expect(redirected.searchParams.get('token')).toBe(ZERO_ADDRESS);
83+
expect(redirected.searchParams.get('destinationToken')).toBe(ZERO_ADDRESS);
84+
expect(redirected.searchParams.get('sanitized')).toBe('true');
85+
});
86+
});
Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { redirect } from 'next/navigation';
2+
13
import { onrampServices } from '@/bridge/components/BuyPanel/utils';
24
import { PathnameEnum } from '@/bridge/constants';
35

46
import { addOrbitChainsToArbitrumSDK } from '../initialization';
5-
import { sanitizeAndRedirect } from './sanitizeAndRedirect';
7+
import { getSanitizedRedirectPath } from './sanitizeAndRedirect';
68

79
export type Slug = (typeof onrampServices)[number]['slug'];
810

@@ -11,14 +13,31 @@ export interface BridgePageProps {
1113
redirectPath: PathnameEnum | `${PathnameEnum.BUY}/${Slug}` | `${PathnameEnum.EMBED_BUY}/${Slug}`;
1214
}
1315

14-
export async function initializeBridgePage({ searchParams, redirectPath }: BridgePageProps) {
16+
export async function getBridgePageSanitizedRedirectPath({
17+
searchParams,
18+
redirectPath,
19+
}: BridgePageProps) {
1520
/**
1621
* This code is run on every query param change,
1722
* we don't want to sanitize every query param change.
1823
* It should only be executed once per user per session.
1924
*/
20-
if (searchParams.sanitized !== 'true') {
21-
addOrbitChainsToArbitrumSDK();
22-
await sanitizeAndRedirect(searchParams, redirectPath);
25+
if (searchParams.sanitized === 'true') {
26+
return null;
27+
}
28+
29+
addOrbitChainsToArbitrumSDK();
30+
31+
return getSanitizedRedirectPath(searchParams, redirectPath);
32+
}
33+
34+
export async function initializeBridgePage({ searchParams, redirectPath }: BridgePageProps) {
35+
const sanitizedRedirectPath = await getBridgePageSanitizedRedirectPath({
36+
searchParams,
37+
redirectPath,
38+
});
39+
40+
if (sanitizedRedirectPath) {
41+
redirect(sanitizedRedirectPath);
2342
}
2443
}

packages/app/src/utils/sanitizeAndRedirect.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { constants } from 'ethers';
12
import { redirect } from 'next/navigation';
23

4+
import { ChainId } from '@/bridge/types/ChainId';
35
import { sanitizeExperimentalFeaturesQueryParam } from '@/bridge/util';
46
import { isE2eTestingEnvironment, isProductionEnvironment } from '@/bridge/util/CommonUtils';
57
import { registerLocalNetwork } from '@/bridge/util/networks';
@@ -98,6 +100,19 @@ export async function sanitizeAndRedirect(
98100
[key: string]: string | string[] | undefined;
99101
},
100102
baseUrl: string,
103+
) {
104+
const redirectPath = await getSanitizedRedirectPath(searchParams, baseUrl);
105+
106+
if (redirectPath) {
107+
redirect(redirectPath);
108+
}
109+
}
110+
111+
export async function getSanitizedRedirectPath(
112+
searchParams: {
113+
[key: string]: string | string[] | undefined;
114+
},
115+
baseUrl: string,
101116
) {
102117
const sourceChainId = decodeChainQueryParam(searchParams.sourceChain);
103118
const destinationChainId = decodeChainQueryParam(searchParams.destinationChain);
@@ -114,7 +129,7 @@ export async function sanitizeAndRedirect(
114129

115130
// If both sourceChain and destinationChain are not present, let the client sync with Metamask
116131
if (!sourceChainId && !destinationChainId) {
117-
return;
132+
return null;
118133
}
119134

120135
if (!isProductionEnvironment || isE2eTestingEnvironment) {
@@ -125,19 +140,32 @@ export async function sanitizeAndRedirect(
125140
sourceChainId,
126141
destinationChainId,
127142
});
143+
const sanitizedToken = sanitizeTokenQueryParam({
144+
token,
145+
sourceChainId: sanitizedChainIds.sourceChainId,
146+
destinationChainId: sanitizedChainIds.destinationChainId,
147+
});
148+
let sanitizedDestinationToken = sanitizeTokenQueryParam({
149+
token: destinationToken,
150+
sourceChainId: sanitizedChainIds.sourceChainId,
151+
destinationChainId: sanitizedChainIds.destinationChainId,
152+
});
153+
154+
// Reuse the same default selection behavior as setSelectedToken(null) for ApeChain -> Superposition.
155+
if (
156+
sanitizedChainIds.sourceChainId === ChainId.ApeChain &&
157+
sanitizedChainIds.destinationChainId === ChainId.Superposition &&
158+
typeof token === 'undefined' &&
159+
typeof destinationToken === 'undefined'
160+
) {
161+
sanitizedDestinationToken = constants.AddressZero;
162+
}
163+
128164
const sanitized = {
129165
...sanitizedChainIds,
130166
experiments: sanitizeExperimentalFeaturesQueryParam(experiments),
131-
token: sanitizeTokenQueryParam({
132-
token,
133-
sourceChainId: sanitizedChainIds.sourceChainId,
134-
destinationChainId: sanitizedChainIds.destinationChainId,
135-
}),
136-
destinationToken: sanitizeTokenQueryParam({
137-
token: destinationToken,
138-
sourceChainId: sanitizedChainIds.sourceChainId,
139-
destinationChainId: sanitizedChainIds.destinationChainId,
140-
}),
167+
token: sanitizedToken,
168+
destinationToken: sanitizedDestinationToken,
141169
tab: sanitizeTabQueryParam(tab),
142170
disabledFeatures: DisabledFeaturesParam.decode(disabledFeatures),
143171
};
@@ -160,6 +188,8 @@ export async function sanitizeAndRedirect(
160188
`[sanitizeAndRedirect] sourceChain=${sanitized.sourceChainId}&destinationChain=${sanitized.destinationChainId}&experiments=${sanitized.experiments}&token=${sanitized.token}&destinationToken=${sanitized.destinationToken}&tab=${sanitized.tab}&disabledFeatures=${sanitized.disabledFeatures}&sanitized=true (after)`,
161189
);
162190

163-
redirect(getDestinationWithSanitizedQueryParams(sanitized, searchParams, baseUrl));
191+
return getDestinationWithSanitizedQueryParams(sanitized, searchParams, baseUrl);
164192
}
193+
194+
return null;
165195
}

packages/app/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default defineConfig({
77
},
88
resolve: {
99
alias: {
10+
'@/common': path.resolve(__dirname, '../portal/common'),
1011
'@/bridge': path.resolve(__dirname, '../arb-token-bridge-ui/src'),
1112
},
1213
},

0 commit comments

Comments
 (0)