diff --git a/app/src/screens/shared/WebViewScreen.tsx b/app/src/screens/shared/WebViewScreen.tsx index 0e0aa0eaa..ad93cab38 100644 --- a/app/src/screens/shared/WebViewScreen.tsx +++ b/app/src/screens/shared/WebViewScreen.tsx @@ -17,6 +17,7 @@ import type { WebViewNavigation } from 'react-native-webview/lib/WebViewTypes'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { charcoal, slate200, @@ -27,6 +28,7 @@ import { WebViewNavBar } from '@/components/navbar/WebViewNavBar'; import { WebViewFooter } from '@/components/WebViewFooter'; import { selfUrl } from '@/consts/links'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; +import { handleUrl, parseAndValidateUrlParams } from '@/navigation/deeplinks'; import type { SharedRoutesParamList } from '@/navigation/types'; import { DISALLOWED_SCHEMES, @@ -35,6 +37,7 @@ import { isTrustedDomain, isUserInitiatedTopFrameNavigation, shouldAlwaysOpenExternally, + type WebViewRequestWithIosProps, } from '@/utils/webview'; export interface WebViewScreenParams { @@ -73,6 +76,7 @@ const styles = StyleSheet.create({ export const WebViewScreen: React.FC = ({ route }) => { const navigation = useNavigation(); + const selfClient = useSelfClient(); const params = route?.params as WebViewScreenParams | undefined; const safeParams: WebViewScreenParams = params ?? { url: defaultUrl }; const { url, title } = safeParams; @@ -136,6 +140,37 @@ export const WebViewScreen: React.FC = ({ route }) => { [], ); + const interceptSelfRedirect = useCallback( + (req: WebViewNavigation & WebViewRequestWithIosProps) => { + const validatedParams = parseAndValidateUrlParams(req.url); + const hasSelfParams = Boolean( + validatedParams.selfApp || validatedParams.sessionId, + ); + if (!hasSelfParams) { + return false; + } + + try { + const hostname = new URL(req.url).hostname; + const isSelfHost = + hostname === 'self.xyz' || hostname.endsWith('.self.xyz'); + if (!isSelfHost || !isTrustedDomain(req.url)) { + return false; + } + } catch { + return false; + } + + if (!isUserInitiatedTopFrameNavigation(req)) { + return false; + } + + handleUrl(selfClient, req.url); + return true; + }, + [selfClient], + ); + const openUrl = useCallback(async (targetUrl: string) => { // Block disallowed schemes (blacklist approach) // Allow everything else - more practical than maintaining a whitelist @@ -247,6 +282,10 @@ export const WebViewScreen: React.FC = ({ route }) => { onShouldStartLoadWithRequest={req => { const isHttps = /^https:\/\//i.test(req.url); + if (interceptSelfRedirect(req)) { + return false; + } + // Allow about:blank/srcdoc during trusted sessions (some wallets use this before redirecting) if (isSessionTrusted && isAllowedAboutUrl(req.url)) { return true; diff --git a/app/src/screens/verification/ProofRequestStatusScreen.tsx b/app/src/screens/verification/ProofRequestStatusScreen.tsx index 6a7af0e93..8e940a3ec 100644 --- a/app/src/screens/verification/ProofRequestStatusScreen.tsx +++ b/app/src/screens/verification/ProofRequestStatusScreen.tsx @@ -5,7 +5,7 @@ import type { LottieViewProps } from 'lottie-react-native'; import LottieView from 'lottie-react-native'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Linking, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { SystemBars } from 'react-native-edge-to-edge'; import { ScrollView, Spinner } from 'tamagui'; import { useIsFocused, useNavigation } from '@react-navigation/native'; @@ -36,6 +36,7 @@ import type { RootStackParamList } from '@/navigation'; import { getWhiteListedDisclosureAddresses } from '@/services/points/utils'; import { useProofHistoryStore } from '@/stores/proofHistoryStore'; import { ProofStatus } from '@/stores/proofTypes'; +import { handleDeeplinkCallbackNavigation } from '@/utils/deeplinkCallbacks'; const SuccessScreen: React.FC = () => { const selfClient = useSelfClient(); @@ -95,6 +96,21 @@ const SuccessScreen: React.FC = () => { setCountdown(null); } + const navigateWithDeeplinkCallback = useCallback( + async (deeplink: string) => { + try { + await handleDeeplinkCallbackNavigation({ + deeplinkCallback: deeplink, + navigation, + }); + } catch (error) { + console.error('Failed to open deep link:', error); + onOkPress(); + } + }, + [navigation, onOkPress], + ); + useEffect(() => { if (isFocused) { } @@ -191,13 +207,15 @@ const SuccessScreen: React.FC = () => { } else { setCountdown(null); if (selfApp?.deeplinkCallback) { - Linking.openURL(selfApp.deeplinkCallback).catch(err => { - console.error('Failed to open deep link:', err); - onOkPress(); - }); + navigateWithDeeplinkCallback(selfApp.deeplinkCallback); } } - }, [countdown, selfApp?.deeplinkCallback, onOkPress]); + }, [ + countdown, + selfApp?.deeplinkCallback, + navigateWithDeeplinkCallback, + onOkPress, + ]); useEffect(() => { if (!isFocused) { diff --git a/app/src/services/points/utils.ts b/app/src/services/points/utils.ts index 938670c91..3f37d5852 100644 --- a/app/src/services/points/utils.ts +++ b/app/src/services/points/utils.ts @@ -6,7 +6,7 @@ import { v4 } from 'uuid'; import { SelfAppBuilder } from '@selfxyz/common/utils/appType'; -import { selfLogoReverseUrl } from '@/consts/links'; +import { appsUrl, selfLogoReverseUrl } from '@/consts/links'; import { getOrGeneratePointsAddress } from '@/providers/authProvider'; import { POINTS_API_BASE_URL } from '@/services/points/constants'; import type { IncomingPoints } from '@/services/points/types'; @@ -166,18 +166,30 @@ export const hasUserDoneThePointsDisclosure = async (): Promise => { } }; +const buildPointsDisclosureMetadata = (walletAddress: string) => ({ + action: 'points-disclosure', + issuedAt: new Date().toISOString(), + nonce: v4(), + wallet: walletAddress.toLowerCase(), +}); + export const pointsSelfApp = async () => { const endpoint = '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0'; + const pointsAddress = (await getPointsAddress()).toLowerCase(); + const disclosureMetadata = buildPointsDisclosureMetadata(pointsAddress); const builder = new SelfAppBuilder({ appName: '✨ Self Points', endpoint: endpoint.toLowerCase(), endpointType: 'celo', scope: 'minimal-disclosure-quest', - userId: v4(), - userIdType: 'uuid', + userId: pointsAddress, + userIdType: 'hex', disclosures: {}, logoBase64: selfLogoReverseUrl, header: '', + deeplinkCallback: appsUrl, + userDefinedData: JSON.stringify(disclosureMetadata), + selfDefinedData: pointsAddress, }); return builder.build(); diff --git a/app/src/utils/deeplinkCallbacks.ts b/app/src/utils/deeplinkCallbacks.ts new file mode 100644 index 000000000..4687bbb7a --- /dev/null +++ b/app/src/utils/deeplinkCallbacks.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { Linking } from 'react-native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { RootStackParamList } from '@/navigation'; + +const isSelfHostname = (hostname: string) => { + return hostname === 'self.xyz' || hostname.endsWith('.self.xyz'); +}; + +const isSelfHostedUrl = (deeplink: string): boolean => { + try { + const url = new URL(deeplink); + if (url.protocol !== 'https:') { + return false; + } + return isSelfHostname(url.hostname); + } catch { + return false; + } +}; + +export const handleDeeplinkCallbackNavigation = async ({ + deeplinkCallback, + navigation, +}: { + deeplinkCallback: string; + navigation: NativeStackNavigationProp; +}) => { + if (isSelfHostedUrl(deeplinkCallback)) { + navigation.navigate('WebView', { + url: deeplinkCallback, + title: 'Explore Apps', + }); + return; + } + + await Linking.openURL(deeplinkCallback); +}; + +export const isSelfHostedDeeplink = isSelfHostedUrl; diff --git a/app/tests/src/screens/WebViewScreen.test.tsx b/app/tests/src/screens/WebViewScreen.test.tsx index 76aa0801b..33ccc5da3 100644 --- a/app/tests/src/screens/WebViewScreen.test.tsx +++ b/app/tests/src/screens/WebViewScreen.test.tsx @@ -86,11 +86,29 @@ const mockPlatform = jest.requireMock('react-native').Platform as { select: (specifics: { ios?: unknown; android?: unknown }) => unknown; }; +const { handleUrl: mockHandleUrl, parseAndValidateUrlParams } = + jest.requireMock('@/navigation/deeplinks') as { + handleUrl: jest.Mock; + parseAndValidateUrlParams: jest.Mock; + }; +const { useSelfClient: mockUseSelfClient } = jest.requireMock( + '@selfxyz/mobile-sdk-alpha', +) as { useSelfClient: jest.Mock }; + jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), useFocusEffect: jest.fn(), })); +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + useSelfClient: jest.fn(() => ({ getSelfAppState: jest.fn() })), +})); + +jest.mock('@/navigation/deeplinks', () => ({ + handleUrl: jest.fn(), + parseAndValidateUrlParams: jest.fn(() => ({})), +})); + jest.mock('@/components/navbar/WebViewNavBar', () => ({ WebViewNavBar: ({ children, onBackPress, ...props }: any) => ( @@ -155,10 +173,13 @@ describe('WebViewScreen URL sanitization and navigation interception', () => { goBack: mockGoBack, canGoBack: () => true, }); + mockUseSelfClient.mockReturnValue({ getSelfAppState: jest.fn() }); jest.spyOn(console, 'error').mockImplementation(() => {}); mockAlert.alert.mockClear(); mockLinking.canOpenURL.mockReset(); mockLinking.openURL.mockReset(); + parseAndValidateUrlParams.mockReturnValue({}); + mockHandleUrl.mockClear(); }); afterEach(() => { @@ -297,6 +318,24 @@ describe('WebViewScreen URL sanitization and navigation interception', () => { expect(String(msg)).toContain('Failed to open externally'); expect(String(msg)).not.toMatch(/Failed to open URL externally/); }); + + it('intercepts trusted redirect self links carrying selfApp payloads', async () => { + const deeplinkUrl = 'https://redirect.self.xyz?selfApp=%7B%7D'; + parseAndValidateUrlParams.mockReturnValue({ + selfApp: '{}', + }); + render(); + const webview = screen.getByTestId('webview'); + + const result = await webview.props.onShouldStartLoadWithRequest?.({ + url: deeplinkUrl, + isTopFrame: true, + navigationType: 'click', + }); + + expect(result).toBe(false); + expect(mockHandleUrl).toHaveBeenCalledWith(expect.any(Object), deeplinkUrl); + }); }); describe('WebViewScreen same-origin security', () => { @@ -321,10 +360,12 @@ describe('WebViewScreen same-origin security', () => { goBack: jest.fn(), canGoBack: () => true, }); + mockUseSelfClient.mockReturnValue({ getSelfAppState: jest.fn() }); jest.spyOn(console, 'error').mockImplementation(() => {}); mockAlert.alert.mockClear(); mockLinking.canOpenURL.mockReset(); mockLinking.openURL.mockReset(); + parseAndValidateUrlParams.mockReturnValue({}); }); afterEach(() => { diff --git a/app/tests/src/services/points/utils.test.ts b/app/tests/src/services/points/utils.test.ts new file mode 100644 index 000000000..0626cf05d --- /dev/null +++ b/app/tests/src/services/points/utils.test.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { pointsSelfApp } from '@/services/points/utils'; + +jest.mock('uuid', () => ({ + v4: jest.fn(), +})); + +jest.mock('@/providers/authProvider', () => ({ + getOrGeneratePointsAddress: jest.fn(), +})); + +const mockUuid = jest.requireMock('uuid').v4 as jest.Mock; +const mockGetOrGeneratePointsAddress = jest.requireMock( + '@/providers/authProvider', +).getOrGeneratePointsAddress as jest.Mock; + +describe('pointsSelfApp', () => { + beforeEach(() => { + mockUuid.mockReset(); + mockGetOrGeneratePointsAddress.mockReset(); + }); + + it('builds a SelfApp with wallet-bound metadata and deeplink callback', async () => { + mockUuid + .mockImplementationOnce(() => 'nonce-uuid') + .mockImplementationOnce(() => 'session-uuid'); + mockGetOrGeneratePointsAddress.mockResolvedValue( + '0xABCDEF1234567890ABCDEF1234567890ABCDEF12', + ); + + const selfApp = await pointsSelfApp(); + const metadata = JSON.parse(selfApp.userDefinedData); + + expect(selfApp.userIdType).toBe('hex'); + expect(selfApp.userId).toBe('abcdef1234567890abcdef1234567890abcdef12'); + expect(selfApp.deeplinkCallback).toBe('https://apps.self.xyz'); + expect(metadata.nonce).toBe('nonce-uuid'); + expect(metadata.wallet).toBe('0xabcdef1234567890abcdef1234567890abcdef12'); + expect(metadata.action).toBe('points-disclosure'); + expect(new Date(metadata.issuedAt).toString()).not.toBe('Invalid Date'); + expect(selfApp.selfDefinedData).toBe( + '0xabcdef1234567890abcdef1234567890abcdef12', + ); + }); +}); diff --git a/app/tests/src/utils/deeplinkCallbacks.test.ts b/app/tests/src/utils/deeplinkCallbacks.test.ts new file mode 100644 index 000000000..e9abef489 --- /dev/null +++ b/app/tests/src/utils/deeplinkCallbacks.test.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { Linking } from 'react-native'; + +import { + handleDeeplinkCallbackNavigation, + isSelfHostedDeeplink, +} from '@/utils/deeplinkCallbacks'; + +jest.mock('react-native', () => ({ + Linking: { + openURL: jest.fn(), + }, +})); + +describe('deeplinkCallbacks', () => { + const mockNavigation = { + navigate: jest.fn(), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('routes self-hosted https callbacks through in-app WebView', async () => { + await handleDeeplinkCallbackNavigation({ + deeplinkCallback: 'https://apps.self.xyz/proof/done', + navigation: mockNavigation, + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('WebView', { + url: 'https://apps.self.xyz/proof/done', + title: 'Explore Apps', + }); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + + it('opens non-self callbacks externally', async () => { + await handleDeeplinkCallbackNavigation({ + deeplinkCallback: 'https://example.com/next', + navigation: mockNavigation, + }); + + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/next'); + }); + + it('treats malformed callbacks as external fallbacks', async () => { + await handleDeeplinkCallbackNavigation({ + deeplinkCallback: 'not-a-url', + navigation: mockNavigation, + }); + + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + expect(Linking.openURL).toHaveBeenCalledWith('not-a-url'); + }); + + it('detects self-hosted https deeplinks', () => { + expect(isSelfHostedDeeplink('https://apps.self.xyz/foo')).toBe(true); + expect(isSelfHostedDeeplink('https://malicious.com/foo')).toBe(false); + expect(isSelfHostedDeeplink('ftp://apps.self.xyz/foo')).toBe(false); + }); +});