Skip to content

Commit ec6fbfe

Browse files
committed
Run query param sanitization
1 parent b042a46 commit ec6fbfe

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 { logger } from '@/bridge/util/logger';
@@ -99,6 +101,19 @@ export async function sanitizeAndRedirect(
99101
[key: string]: string | string[] | undefined;
100102
},
101103
baseUrl: string,
104+
) {
105+
const redirectPath = await getSanitizedRedirectPath(searchParams, baseUrl);
106+
107+
if (redirectPath) {
108+
redirect(redirectPath);
109+
}
110+
}
111+
112+
export async function getSanitizedRedirectPath(
113+
searchParams: {
114+
[key: string]: string | string[] | undefined;
115+
},
116+
baseUrl: string,
102117
) {
103118
const sourceChainId = decodeChainQueryParam(searchParams.sourceChain);
104119
const destinationChainId = decodeChainQueryParam(searchParams.destinationChain);
@@ -115,7 +130,7 @@ export async function sanitizeAndRedirect(
115130

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

121136
if (!isProductionEnvironment || isE2eTestingEnvironment) {
@@ -126,19 +141,32 @@ export async function sanitizeAndRedirect(
126141
sourceChainId,
127142
destinationChainId,
128143
});
144+
const sanitizedToken = sanitizeTokenQueryParam({
145+
token,
146+
sourceChainId: sanitizedChainIds.sourceChainId,
147+
destinationChainId: sanitizedChainIds.destinationChainId,
148+
});
149+
let sanitizedDestinationToken = sanitizeTokenQueryParam({
150+
token: destinationToken,
151+
sourceChainId: sanitizedChainIds.sourceChainId,
152+
destinationChainId: sanitizedChainIds.destinationChainId,
153+
});
154+
155+
// Reuse the same default selection behavior as setSelectedToken(null) for ApeChain -> Superposition.
156+
if (
157+
sanitizedChainIds.sourceChainId === ChainId.ApeChain &&
158+
sanitizedChainIds.destinationChainId === ChainId.Superposition &&
159+
typeof token === 'undefined' &&
160+
typeof destinationToken === 'undefined'
161+
) {
162+
sanitizedDestinationToken = constants.AddressZero;
163+
}
164+
129165
const sanitized = {
130166
...sanitizedChainIds,
131167
experiments: sanitizeExperimentalFeaturesQueryParam(experiments),
132-
token: sanitizeTokenQueryParam({
133-
token,
134-
sourceChainId: sanitizedChainIds.sourceChainId,
135-
destinationChainId: sanitizedChainIds.destinationChainId,
136-
}),
137-
destinationToken: sanitizeTokenQueryParam({
138-
token: destinationToken,
139-
sourceChainId: sanitizedChainIds.sourceChainId,
140-
destinationChainId: sanitizedChainIds.destinationChainId,
141-
}),
168+
token: sanitizedToken,
169+
destinationToken: sanitizedDestinationToken,
142170
tab: sanitizeTabQueryParam(tab),
143171
disabledFeatures: DisabledFeaturesParam.decode(disabledFeatures),
144172
};
@@ -161,6 +189,8 @@ export async function sanitizeAndRedirect(
161189
`[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)`,
162190
);
163191

164-
redirect(getDestinationWithSanitizedQueryParams(sanitized, searchParams, baseUrl));
192+
return getDestinationWithSanitizedQueryParams(sanitized, searchParams, baseUrl);
165193
}
194+
195+
return null;
166196
}

packages/app/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export default defineConfig({
1010
},
1111
resolve: {
1212
alias: {
13+
'@/common': path.resolve(__dirname, '../portal/common'),
1314
'@/bridge': path.resolve(__dirname, '../arb-token-bridge-ui/src'),
1415
},
1516
},

0 commit comments

Comments
 (0)