Skip to content

Commit e37cd74

Browse files
wenfixadonesky1
andauthored
fix: normalize ReactNative URLS (#190)
Co-authored-by: Alex Donesky <adonesky@gmail.com>
1 parent 6900091 commit e37cd74

4 files changed

Lines changed: 139 additions & 0 deletions

File tree

packages/connect-multichain/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323

2424
### Changed
2525

26+
- Normalize non-http(s) dapp URLs to valid https URLs on React Native to prevent RPC middleware crashes ([#190](https://github.com/MetaMask/connect-monorepo/pull/190))
2627
- **BREAKING** `createMultichainClient()` now returns a singleton. Any incoming constructor params on subsequent calls to `createMultichainClient()` will be applied to the existing singleton instance except for the `dapp`, `storage`, and `ui.factory` param options. ([#157](https://github.com/MetaMask/connect-monorepo/pull/157))
2728
- **BREAKING** `ConnectMultichain.openDeeplinkIfNeeded()` is renamed to `openSimpleDeeplinkIfNeeded()` ([#176](https://github.com/MetaMask/connect-monorepo/pull/176))
2829
- `ConnectMultichain.connect()` now throws an `'Existing connection is pending. Please check your MetaMask Mobile app to continue.'` error if there is already an pending connection attempt. Previously it would abort that ongoing connection in favor of a new one. ([#157](https://github.com/MetaMask/connect-monorepo/pull/157))

packages/connect-multichain/src/domain/multichain/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export type { SessionData } from '@metamask/multichain-api-client';
2828
export type DappSettings = {
2929
name: string;
3030
url?: string;
31+
/** The original non-http(s) URL before normalization on React Native platforms */
32+
nativeScheme?: string;
3133
} & ({ iconUrl?: string } | { base64Icon?: string });
3234

3335
export type ConnectionRequest = {

packages/connect-multichain/src/multichain/utils/index.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,92 @@ t.describe('Utils', () => {
263263
);
264264
},
265265
);
266+
267+
t.it(
268+
'should normalize non-http URL to https on ReactNative platform',
269+
() => {
270+
const mockGetPlatformType = t.vi.mocked(getPlatformType);
271+
mockGetPlatformType.mockReturnValue(PlatformType.ReactNative);
272+
options.dapp.url = 'react-native-playground://';
273+
options.dapp.name = 'playground';
274+
275+
const result = utils.setupDappMetadata(options);
276+
277+
t.expect(result.dapp.url).toBe(
278+
'https://react-native-playground.rn.dapp.local',
279+
);
280+
t.expect(result.dapp.nativeScheme).toBe('react-native-playground://');
281+
},
282+
);
283+
284+
t.it(
285+
'should normalize custom scheme URL with path on ReactNative platform',
286+
() => {
287+
const mockGetPlatformType = t.vi.mocked(getPlatformType);
288+
mockGetPlatformType.mockReturnValue(PlatformType.ReactNative);
289+
options.dapp.url = 'myapp://path';
290+
options.dapp.name = 'myapp';
291+
292+
const result = utils.setupDappMetadata(options);
293+
294+
t.expect(result.dapp.url).toBe('https://myapp.rn.dapp.local');
295+
t.expect(result.dapp.nativeScheme).toBe('myapp://path');
296+
},
297+
);
298+
299+
t.it(
300+
'should sanitize scheme with dots and uppercase on ReactNative platform',
301+
() => {
302+
const mockGetPlatformType = t.vi.mocked(getPlatformType);
303+
mockGetPlatformType.mockReturnValue(PlatformType.ReactNative);
304+
options.dapp.url = 'My.App://';
305+
options.dapp.name = 'myapp';
306+
307+
const result = utils.setupDappMetadata(options);
308+
309+
t.expect(result.dapp.url).toBe('https://my-app.rn.dapp.local');
310+
t.expect(result.dapp.nativeScheme).toBe('My.App://');
311+
},
312+
);
313+
314+
t.it(
315+
'should use "unknown" subdomain when scheme sanitizes to empty string on ReactNative platform',
316+
() => {
317+
const mockGetPlatformType = t.vi.mocked(getPlatformType);
318+
mockGetPlatformType.mockReturnValue(PlatformType.ReactNative);
319+
options.dapp.url = '://';
320+
options.dapp.name = 'test';
321+
322+
const result = utils.setupDappMetadata(options);
323+
324+
t.expect(result.dapp.url).toBe('https://unknown.rn.dapp.local');
325+
t.expect(result.dapp.nativeScheme).toBe('://');
326+
},
327+
);
328+
329+
t.it('should not normalize http(s) URLs on ReactNative platform', () => {
330+
const mockGetPlatformType = t.vi.mocked(getPlatformType);
331+
mockGetPlatformType.mockReturnValue(PlatformType.ReactNative);
332+
options.dapp.url = 'https://example.com';
333+
options.dapp.name = 'test';
334+
335+
const result = utils.setupDappMetadata(options);
336+
337+
t.expect(result.dapp.url).toBe('https://example.com');
338+
t.expect(result.dapp.nativeScheme).toBeUndefined();
339+
});
340+
341+
t.it('should not normalize URLs on non-ReactNative platforms', () => {
342+
const mockGetPlatformType = t.vi.mocked(getPlatformType);
343+
mockGetPlatformType.mockReturnValue(PlatformType.NonBrowser);
344+
options.dapp.url = 'custom-scheme://';
345+
options.dapp.name = 'test';
346+
347+
const result = utils.setupDappMetadata(options);
348+
349+
t.expect(result.dapp.url).toBe('custom-scheme://');
350+
t.expect(result.dapp.nativeScheme).toBeUndefined();
351+
});
266352
});
267353

268354
t.describe('isSameScopesAndAccounts', () => {

packages/connect-multichain/src/multichain/utils/index.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,40 @@ export const extractFavicon = () => {
204204
return favicon;
205205
};
206206

207+
/**
208+
* Normalizes a non-http(s) URL from a React Native app into a valid https URL.
209+
* Extracts the scheme, sanitizes it to a DNS-safe label, and builds a .rn.dapp.local URL.
210+
*
211+
* @param url - The original URL to normalize
212+
* @returns An object with the normalized URL and original scheme, or undefined if no normalization needed
213+
*/
214+
function normalizeNativeUrl(
215+
url: string,
216+
): { url: string; nativeScheme: string } | undefined {
217+
// Matches "http://" or "https://"
218+
const httpPattern = /^https?:\/\//u;
219+
if (httpPattern.test(url)) {
220+
return undefined;
221+
}
222+
223+
// Captures the scheme before "://" — e.g. "myapp" from "myapp://path"
224+
const schemeMatch = url.match(/^([^:]*):\/\//u);
225+
const rawScheme = schemeMatch?.[1] ?? url;
226+
const sanitized = rawScheme
227+
.toLowerCase()
228+
// Replace non-DNS chars with hyphens — e.g. "My.App" -> "my-app"
229+
.replace(/[^a-z0-9-]/gu, '-')
230+
// Strip leading/trailing hyphens — e.g. "-my-app-" -> "my-app"
231+
.replace(/^-+|-+$/gu, '');
232+
233+
const subdomain = (sanitized || 'unknown').slice(0, 63).replace(/-+$/u, '');
234+
235+
return {
236+
url: `https://${subdomain}.rn.dapp.local`,
237+
nativeScheme: url,
238+
};
239+
}
240+
207241
/**
208242
*
209243
* @param options
@@ -229,6 +263,22 @@ export function setupDappMetadata(
229263
if (!options.dapp?.url) {
230264
throw new Error('You must provide dapp url');
231265
}
266+
267+
// Normalize non-http(s) URLs on React Native platforms
268+
if (platform === PlatformType.ReactNative && options.dapp.url) {
269+
const normalized = normalizeNativeUrl(options.dapp.url);
270+
if (normalized) {
271+
console.info(
272+
`Normalizing dapp URL for React Native: "${options.dapp.url}" -> "${normalized.url}"`,
273+
);
274+
options.dapp = {
275+
...options.dapp,
276+
url: normalized.url,
277+
nativeScheme: normalized.nativeScheme,
278+
};
279+
}
280+
}
281+
232282
const BASE_64_ICON_MAX_LENGTH = 163400;
233283
// Check if iconUrl and url are valid
234284
const urlPattern = /^(http|https):\/\/[^\s]*$/u; // Regular expression for URLs starting with http:// or https://

0 commit comments

Comments
 (0)