diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 0f85892b16f..8dcbce68396 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -97,6 +97,7 @@ jobs: - name: Install dependencies env: SSH_AUTH_SOCK: /tmp/ssh_agent_sandbox.sock + SANDBOX_BRANCH: '@janic/emit-close-on-blocked-ws' run: | yarn install && yarn setup diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index e6220c31d0e..20b6abfeaa3 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -93,6 +93,7 @@ jobs: - name: Install dependencies env: SSH_AUTH_SOCK: /tmp/ssh_agent_sandbox.sock + SANDBOX_BRANCH: '@janic/emit-close-on-blocked-ws' run: yarn install && yarn setup - name: Rock Remote Build - iOS simulator diff --git a/e2e/README.md b/e2e/README.md index 2ff0c481236..714f24786fd 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -24,7 +24,7 @@ Logs of the test run are saved in Github Actions artifacts. To access them go to ### E2E test commands -To speedup getting the app into a specific state, we implement some commands. This is a deep link that we send to the app so it performs certain actions. The actions are implemented in `src/components/TestDeeplinkHandler.tsx`, and can be launched by using the following yaml. +To speedup getting the app into a specific state, we implement some commands. This is a deep link that we send to the app so it performs certain actions. The actions are implemented in `src/features/e2e/ui/TestDeeplinkHandler.tsx`, and can be launched by using the following yaml. ```yaml - openLink: rainbow://e2e/?param1=value1¶m2=value2 diff --git a/e2e/flows/security/SandboxTest.yaml b/e2e/flows/security/SandboxTest.yaml new file mode 100644 index 00000000000..b12e04f45f6 --- /dev/null +++ b/e2e/flows/security/SandboxTest.yaml @@ -0,0 +1,28 @@ +appId: ${APP_ID} +tags: + - security + - sandbox + - parallel +--- +- launchApp: + clearState: true + clearKeychain: true + arguments: + isE2ETest: true +- runFlow: ../../utils/Prepare.yaml +- extendedWaitUntil: + visible: + id: welcome-screen + timeout: 60000 +- openLink: rainbow://e2e/sandbox-test +- runFlow: + when: + visible: 'Open in .*' + platform: iOS + commands: + - tapOn: 'Open' +- extendedWaitUntil: + visible: + id: sandbox-test-results + timeout: 15000 +- assertVisible: 'SANDBOX_TEST_PASSED' diff --git a/src/App.tsx b/src/App.tsx index 50417785838..4df0e0d01e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { OfflineToast } from '@/components/toasts'; import { reactNativeDisableYellowBox, showNetworkRequests, showNetworkResponses } from '@/config/debug'; import monitorNetwork from '@/debugging/network'; import { DANGER_INSTALL_SOURCE, IS_DEV, IS_PROD, IS_TEST } from '@/env'; +import { TestDeeplinkHandler } from '@/features/e2e/ui/TestDeeplinkHandler'; import RainbowContextWrapper from '@/helpers/RainbowContext'; import { useApplicationSetup } from '@/hooks/useApplicationSetup'; import { logger, RainbowError } from '@/logger'; @@ -45,7 +46,6 @@ import { MainThemeProvider } from '@/theme/ThemeContext'; import { configure as configureDelegationClient } from '@rainbow-me/delegation'; import { AbsolutePortalRoot } from './components/AbsolutePortal'; -import { TestDeeplinkHandler } from './components/TestDeeplinkHandler'; import { PerformanceReports, PerformanceReportSegments, PerformanceTracking } from './performance/tracking'; if (IS_DEV) { @@ -83,10 +83,10 @@ function AppComponent() { )} + {IS_TEST && } - {IS_TEST && } diff --git a/src/features/e2e/core/sandboxSecurityTest.ts b/src/features/e2e/core/sandboxSecurityTest.ts new file mode 100644 index 00000000000..6db224e0f9b --- /dev/null +++ b/src/features/e2e/core/sandboxSecurityTest.ts @@ -0,0 +1,105 @@ +export interface SandboxTestResult { + name: string; + passed: boolean; + detail: string; +} + +export interface WebViewTests { + initialLoad: { promise: Promise; onError: () => void }; + jsNavigation: { promise: Promise; onMessage: (event: { nativeEvent: { data: string } }) => void }; +} + +async function testHttpBlocked(): Promise { + try { + const response = await fetch('https://example.com'); + if (response.status === 500) { + return { name: 'http_blocked', passed: true, detail: 'blocked with status 500' }; + } + return { name: 'http_blocked', passed: false, detail: `unexpected status ${response.status}` }; + } catch (e) { + return { name: 'http_blocked', passed: false, detail: `unexpected error: ${(e as Error).message}` }; + } +} + +async function testHttpAllowed(): Promise { + try { + const response = await fetch('https://rainbow.me'); + if (response.ok) { + return { name: 'http_allowed', passed: true, detail: `allowed with status ${response.status}` }; + } + return { name: 'http_allowed', passed: false, detail: `unexpected status ${response.status}` }; + } catch (e) { + return { name: 'http_allowed', passed: false, detail: `blocked with error: ${(e as Error).message}` }; + } +} + +function testWsBlocked(): Promise { + return new Promise(resolve => { + let errorFired = false; + + try { + const ws = new WebSocket('wss://example.com'); + ws.onopen = () => { + ws.close(); + resolve({ name: 'ws_blocked', passed: false, detail: 'connection opened' }); + }; + ws.onerror = () => { + errorFired = true; + resolve({ name: 'ws_blocked', passed: false, detail: 'not blocked (server rejected upgrade)' }); + }; + ws.onclose = () => { + if (errorFired) return; + resolve({ name: 'ws_blocked', passed: true, detail: 'blocked (connection closed)' }); + }; + } catch (e) { + resolve({ name: 'ws_blocked', passed: false, detail: `unexpected error: ${(e as Error).message}` }); + } + }); +} + +/** + * Creates two WebView tests: + * 1. initialLoad — loads a blocked URL directly, expects onError + * 2. jsNavigation — loads an allowed page, attempts JS navigation to blocked URL, + * then reports current URL via onMessage. If blocked, URL stays on allowed domain. + */ +export function createWebViewTests(): WebViewTests { + let resolveInitial: (result: SandboxTestResult) => void; + const initialPromise = new Promise(r => { + resolveInitial = r; + }); + + let resolveJs: (result: SandboxTestResult) => void; + const jsPromise = new Promise(r => { + resolveJs = r; + }); + + return { + initialLoad: { + promise: initialPromise, + onError: () => { + resolveInitial({ name: 'webview_initial_blocked', passed: true, detail: 'blocked by onError' }); + }, + }, + jsNavigation: { + promise: jsPromise, + onMessage: (event: { nativeEvent: { data: string } }) => { + const url = event.nativeEvent.data; + const blocked = !url.includes('example.com'); + resolveJs({ + name: 'webview_js_nav_blocked', + passed: blocked, + detail: blocked ? `blocked: still on ${url}` : `not blocked: navigated to ${url}`, + }); + }, + }, + }; +} + +export async function runSandboxTests(): Promise { + const results: SandboxTestResult[] = []; + results.push(await testHttpBlocked()); + results.push(await testHttpAllowed()); + results.push(await testWsBlocked()); + return results; +} diff --git a/src/features/e2e/ui/SandboxSecurityResults.tsx b/src/features/e2e/ui/SandboxSecurityResults.tsx new file mode 100644 index 00000000000..dc246a4ad42 --- /dev/null +++ b/src/features/e2e/ui/SandboxSecurityResults.tsx @@ -0,0 +1,48 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { type SandboxTestResult } from '../core/sandboxSecurityTest'; + +interface Props { + results: SandboxTestResult[]; + allDone: boolean; +} + +export function SandboxSecurityResults({ results, allDone }: Props) { + const allPassed = allDone && results.every(r => r.passed); + + return ( + + + {!allDone ? 'SANDBOX_TEST_RUNNING' : allPassed ? 'SANDBOX_TEST_PASSED' : 'SANDBOX_TEST_FAILED'} + + {results.map(r => ( + + {`${r.name}: ${r.passed ? 'PASS' : 'FAIL'} - ${r.detail}`} + + ))} + + ); +} + +const sx = StyleSheet.create({ + overlay: { + backgroundColor: 'white', + borderRadius: 12, + elevation: 9999, + left: 20, + padding: 16, + position: 'absolute', + right: 20, + top: 120, + zIndex: 9999, + }, + result: { + color: '#333', + fontSize: 13, + marginTop: 6, + }, + status: { + fontSize: 16, + fontWeight: 'bold', + }, +}); diff --git a/src/features/e2e/ui/SandboxWebViewTest.tsx b/src/features/e2e/ui/SandboxWebViewTest.tsx new file mode 100644 index 00000000000..23a99a0180b --- /dev/null +++ b/src/features/e2e/ui/SandboxWebViewTest.tsx @@ -0,0 +1,47 @@ +import { StyleSheet, View } from 'react-native'; + +import WebView from 'react-native-webview'; + +import { type WebViewTests } from '../core/sandboxSecurityTest'; + +// Navigates to a blocked domain after the allowed page loads +const NAVIGATE_JS = ` + setTimeout(function() { + window.location.href = 'https://example.com'; + }, 2000); + setTimeout(function() { + window.ReactNativeWebView.postMessage(window.location.href); + }, 4000); + true; +`; + +export function SandboxWebViewTest({ initialLoad, jsNavigation }: WebViewTests) { + return ( + + + + + ); +} + +const sx = StyleSheet.create({ + container: { + flexDirection: 'row', + left: 20, + position: 'absolute', + right: 20, + top: 300, + }, + webview: { + borderColor: 'red', + borderWidth: 2, + flex: 1, + height: 200, + }, +}); diff --git a/src/components/TestDeeplinkHandler.tsx b/src/features/e2e/ui/TestDeeplinkHandler.tsx similarity index 51% rename from src/components/TestDeeplinkHandler.tsx rename to src/features/e2e/ui/TestDeeplinkHandler.tsx index 4eacbb6d956..d90baa29df7 100644 --- a/src/components/TestDeeplinkHandler.tsx +++ b/src/features/e2e/ui/TestDeeplinkHandler.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Linking } from 'react-native'; import URL from 'url-parse'; @@ -9,10 +9,18 @@ import Navigation from '@/navigation/Navigation'; import Routes from '@/navigation/routesNames'; import { initializeWallet } from '@/state/wallets/initializeWallet'; +import { createWebViewTests, runSandboxTests, type SandboxTestResult, type WebViewTests } from '../core/sandboxSecurityTest'; +import { SandboxSecurityResults } from './SandboxSecurityResults'; +import { SandboxWebViewTest } from './SandboxWebViewTest'; + /** * Handles E2E test commands. See e2e/README.md:31 for usage. */ export function TestDeeplinkHandler() { + const [results, setResults] = useState(null); + const [webViewTests, setWebViewTests] = useState(); + const [webViewDone, setWebViewDone] = useState(false); + useEffect(() => { const listener = Linking.addListener('url', async ({ url }) => { const { protocol, host, pathname, query } = new URL(url, true); @@ -34,6 +42,17 @@ export function TestDeeplinkHandler() { screen: Routes.WALLET_SCREEN, }); break; + case 'sandbox-test': { + const wvTests = createWebViewTests(); + setWebViewTests(wvTests); + Promise.all([wvTests.initialLoad.promise, wvTests.jsNavigation.promise]).then(wvResults => { + setResults(prev => (prev ? [...prev, ...wvResults] : wvResults)); + setWebViewDone(true); + }); + const syncResults = await runSandboxTests(); + setResults(syncResults); + break; + } default: logger.debug(`[TestDeeplinkHandler]: unknown path`, { url }); break; @@ -43,5 +62,15 @@ export function TestDeeplinkHandler() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + if (results) { + return ( + <> + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + {webViewTests && } + + ); + } + return null; }