Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/android-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ios-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<command>?param1=value1&param2=value2
Expand Down
28 changes: 28 additions & 0 deletions e2e/flows/security/SandboxTest.yaml
Original file line number Diff line number Diff line change
@@ -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'
4 changes: 2 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -83,10 +83,10 @@ function AppComponent() {
)}
<OfflineToast />
<Toaster />
{IS_TEST && <TestDeeplinkHandler />}
</View>
<NotificationsHandler />
<DeeplinkHandler initialRoute={initialRoute} />
{IS_TEST && <TestDeeplinkHandler />}
<BackupsSync />
<AbsolutePortalRoot />
</>
Expand Down
105 changes: 105 additions & 0 deletions src/features/e2e/core/sandboxSecurityTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
export interface SandboxTestResult {
name: string;
passed: boolean;
detail: string;
}

export interface WebViewTests {
initialLoad: { promise: Promise<SandboxTestResult>; onError: () => void };
jsNavigation: { promise: Promise<SandboxTestResult>; onMessage: (event: { nativeEvent: { data: string } }) => void };
}

async function testHttpBlocked(): Promise<SandboxTestResult> {
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<SandboxTestResult> {
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<SandboxTestResult> {
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<SandboxTestResult>(r => {
resolveInitial = r;
});

let resolveJs: (result: SandboxTestResult) => void;
const jsPromise = new Promise<SandboxTestResult>(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');
Comment thread Dismissed
resolveJs({
name: 'webview_js_nav_blocked',
passed: blocked,
detail: blocked ? `blocked: still on ${url}` : `not blocked: navigated to ${url}`,
});
},
},
};
}

export async function runSandboxTests(): Promise<SandboxTestResult[]> {
const results: SandboxTestResult[] = [];
results.push(await testHttpBlocked());
results.push(await testHttpAllowed());
results.push(await testWsBlocked());
return results;
}
48 changes: 48 additions & 0 deletions src/features/e2e/ui/SandboxSecurityResults.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={sx.overlay} testID={allDone ? 'sandbox-test-results' : undefined}>
<Text style={sx.status} testID="sandbox-test-status">
{!allDone ? 'SANDBOX_TEST_RUNNING' : allPassed ? 'SANDBOX_TEST_PASSED' : 'SANDBOX_TEST_FAILED'}
</Text>
{results.map(r => (
<Text key={r.name} style={sx.result} testID={`sandbox-${r.name}`}>
{`${r.name}: ${r.passed ? 'PASS' : 'FAIL'} - ${r.detail}`}
</Text>
))}
</View>
);
}

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',
},
});
47 changes: 47 additions & 0 deletions src/features/e2e/ui/SandboxWebViewTest.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={sx.container}>
<WebView source={{ uri: 'https://example.com' }} sandbox style={sx.webview} onError={initialLoad.onError} />
<WebView
source={{ uri: 'https://rainbow.me' }}
sandbox
style={sx.webview}
injectedJavaScript={NAVIGATE_JS}
onMessage={jsNavigation.onMessage}
/>
</View>
);
}

const sx = StyleSheet.create({
container: {
flexDirection: 'row',
left: 20,
position: 'absolute',
right: 20,
top: 300,
},
webview: {
borderColor: 'red',
borderWidth: 2,
flex: 1,
height: 200,
},
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Linking } from 'react-native';

import URL from 'url-parse';
Expand All @@ -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<SandboxTestResult[] | null>(null);
const [webViewTests, setWebViewTests] = useState<WebViewTests | undefined>();
const [webViewDone, setWebViewDone] = useState(false);

useEffect(() => {
const listener = Linking.addListener('url', async ({ url }) => {
const { protocol, host, pathname, query } = new URL(url, true);
Expand All @@ -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;
Expand All @@ -43,5 +62,15 @@ export function TestDeeplinkHandler() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

if (results) {
return (
<>
<SandboxSecurityResults results={results} allDone={webViewDone} />
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
{webViewTests && <SandboxWebViewTest {...webViewTests} />}
</>
);
}

return null;
}
Loading