import escapeStringRegexp from 'escape-string-regexp'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Linking, View, ActivityIndicator, Text, Platform } from 'react-native'; import { OnShouldStartLoadWithRequest, ShouldStartLoadRequestEvent, WebViewError, WebViewErrorEvent, WebViewMessage, WebViewMessageEvent, WebViewNavigation, WebViewNativeEvent, WebViewNavigationEvent, WebViewOpenWindowEvent, WebViewProgressEvent, WebViewRenderProcessGoneEvent, WebViewTerminatedEvent, } from './WebViewTypes'; import styles from './WebView.styles'; // Exodus: Only allow HTTPS by default for security const defaultOriginWhitelist = ['https://*'] as const; // Exodus: Default protocol schemes for deep linking const defaultDeeplinkWhitelist = ['https:'] as const; const defaultDeeplinkBlocklist = ['http:', 'file:', 'javascript:'] as const; // Exodus: Extract protocol scheme from URL using native URL parsing const urlToProtocolScheme = (url: string): string | null => { try { return new URL(url).protocol; } catch { // Protocol schemes must start with a letter and cannot start with digits, underscores etc. // e.g 0invalid, _invalid, +invalid, -invalid, .invalid will all become null return null; } }; // Exodus: Check if a value exists in a list of strings const matchWithStringList = ( prefixes: readonly string[], value: string ): boolean => { if (typeof value !== 'string') { throw new Error('value was not a string'); } return Array.prototype.includes.call(prefixes, value); }; // Exodus: Convert whitelist string to RegExp with exact matching (^ and $ anchors) const stringWhitelistToRegex = (originWhitelist: string): RegExp => new RegExp(`^${escapeStringRegexp(originWhitelist).replace(/\\\*/g, '.*')}$`); // Exodus: Test value against a list of compiled RegExp patterns const matchWithRegexList = ( compiledRegexList: readonly RegExp[], value: string ): boolean => { return compiledRegexList.some((x) => x.test(value)); }; // Exodus: Compile whitelist strings into RegExp array for efficient matching const compileWhitelist = ( originWhitelist: readonly string[] ): readonly RegExp[] => ['about:blank', ...(originWhitelist || [])].map(stringWhitelistToRegex); // Exodus: Check if URL passes whitelist using native URL API for robust parsing // Falls back to href when origin is null (handles data:, blob:, etc.) const passesWhitelist = ( compiledWhitelist: readonly RegExp[], url: string ): boolean => { try { const { href, origin } = new URL(url); // Check origin first (most common case) if (origin && origin !== 'null') { return matchWithRegexList(compiledWhitelist, origin); } // Fallback to href for URLs where origin is null (data:, blob:, javascript:, etc.) return matchWithRegexList(compiledWhitelist, href); } catch { // Malformed URL - fail closed for security return false; } }; const createOnShouldStartLoadWithRequest = ( loadRequest: ( shouldStart: boolean, url: string, lockIdentifier: number ) => void, originWhitelist: readonly string[], deeplinkWhitelist: readonly string[], onShouldStartLoadWithRequest?: OnShouldStartLoadWithRequest ) => { const compiledWhitelist = compileWhitelist(originWhitelist); return ({ nativeEvent }: ShouldStartLoadRequestEvent) => { let shouldStart = true; const { url, lockIdentifier, isTopFrame } = nativeEvent; // Exodus: Check if the url passes the origin whitelist if (!passesWhitelist(compiledWhitelist, url)) { const protocol = urlToProtocolScheme(url); // Check that the protocol was properly parsed if (protocol !== null) { // Exodus: Check if the protocol passes the hardcoded deeplink blocklist const foundMatchInBlocklist = matchWithStringList( defaultDeeplinkBlocklist, protocol ); if (!foundMatchInBlocklist) { // Exodus: Check if the protocol passes the dynamic deeplink allowlist const foundMatchInAllowlist = matchWithStringList( deeplinkWhitelist, protocol ); if (foundMatchInAllowlist) { Linking.canOpenURL(url) .then((supported) => { // Allow mailto: even if canOpenURL returns false (RN Linking quirk) if ( (supported && isTopFrame) || protocol.startsWith('mailto:') ) { return Linking.openURL(url); } console.warn(`Can't open url: ${url}`); return undefined; }) .catch((e) => { console.warn('Error opening URL: ', e); }); } else { console.warn(`Failed to pass whitelist for deep link url: ${url}`); } } else { console.warn( `Failed to pass default block list for deep link url: ${url}` ); } } shouldStart = false; } else if (onShouldStartLoadWithRequest) { shouldStart = onShouldStartLoadWithRequest(nativeEvent); } loadRequest(shouldStart, url, lockIdentifier); }; }; const defaultRenderLoading = () => ( ); const defaultRenderError = ( errorDomain: string | undefined, errorCode: number, errorDesc: string ) => ( Error loading page {`Domain: ${errorDomain}`} {`Error Code: ${errorCode}`} {`Description: ${errorDesc}`} ); export { defaultOriginWhitelist, defaultDeeplinkWhitelist, createOnShouldStartLoadWithRequest, defaultRenderLoading, defaultRenderError, passesWhitelist, compileWhitelist, }; export const useWebViewLogic = ({ startInLoadingState, onNavigationStateChange, onLoadStart, onLoad, onLoadProgress, onLoadEnd, onError, onLoadSubResourceError, onMessageProp, onOpenWindowProp, onRenderProcessGoneProp, onContentProcessDidTerminateProp, originWhitelist, deeplinkWhitelist, onShouldStartLoadWithRequestProp, onShouldStartLoadWithRequestCallback, validateMeta, validateData, }: { startInLoadingState?: boolean; onNavigationStateChange?: (event: WebViewNavigation) => void; onLoadStart?: (event: WebViewNavigationEvent) => void; onLoad?: (event: WebViewNavigationEvent) => void; onLoadProgress?: (event: WebViewProgressEvent) => void; onLoadEnd?: (event: WebViewNavigationEvent | WebViewErrorEvent) => void; onError?: (event: WebViewErrorEvent) => void; onLoadSubResourceError?: (event: WebViewErrorEvent) => void; onMessageProp?: (event: WebViewMessage) => void; onOpenWindowProp?: (event: WebViewOpenWindowEvent) => void; onRenderProcessGoneProp?: (event: WebViewRenderProcessGoneEvent) => void; onContentProcessDidTerminateProp?: (event: WebViewTerminatedEvent) => void; originWhitelist: readonly string[]; deeplinkWhitelist: readonly string[]; onShouldStartLoadWithRequestProp?: OnShouldStartLoadWithRequest; onShouldStartLoadWithRequestCallback: ( shouldStart: boolean, url: string, lockIdentifier?: number | undefined ) => void; validateMeta: (event: WebViewNativeEvent) => WebViewNativeEvent; validateData: (data: object) => object; }) => { const [viewState, setViewState] = useState<'IDLE' | 'LOADING' | 'ERROR'>( startInLoadingState ? 'LOADING' : 'IDLE' ); const [lastErrorEvent, setLastErrorEvent] = useState( null ); const startUrl = useRef(null); // Exodus: Helper to check if URL passes origin whitelist const passesWhitelistCallback = useCallback( (url: string) => { if (!url || typeof url !== 'string') return false; return passesWhitelist(compileWhitelist(originWhitelist), url); }, [originWhitelist] ); // Exodus: Extract and sanitize metadata from native event const extractMeta = ( nativeEvent: WebViewNativeEvent ): WebViewNativeEvent => ({ url: String(nativeEvent.url), loading: Boolean(nativeEvent.loading), title: String(nativeEvent.title).slice(0, 512), canGoBack: Boolean(nativeEvent.canGoBack), canGoForward: Boolean(nativeEvent.canGoForward), lockIdentifier: Number(nativeEvent.lockIdentifier), }); const updateNavigationState = useCallback( (event: WebViewNavigationEvent) => { onNavigationStateChange?.(event.nativeEvent); }, [onNavigationStateChange] ); const onLoadingStart = useCallback( (event: WebViewNavigationEvent) => { // Needed for android startUrl.current = event.nativeEvent.url; // !Needed for android onLoadStart?.(event); updateNavigationState(event); }, [onLoadStart, updateNavigationState] ); const onLoadingError = useCallback( (event: WebViewErrorEvent) => { event.persist(); if (onError) { onError(event); } else { console.warn('Encountered an error loading page', event.nativeEvent); } onLoadEnd?.(event); if (event.isDefaultPrevented()) { return; } setViewState('ERROR'); setLastErrorEvent(event.nativeEvent); }, [onError, onLoadEnd] ); const onLoadingSubResourceError = useCallback( (event: WebViewErrorEvent) => { onLoadSubResourceError?.(event); }, [onLoadSubResourceError] ); // Android Only const onRenderProcessGone = useCallback( (event: WebViewRenderProcessGoneEvent) => { onRenderProcessGoneProp?.(event); }, [onRenderProcessGoneProp] ); // !Android Only // iOS Only const onContentProcessDidTerminate = useCallback( (event: WebViewTerminatedEvent) => { onContentProcessDidTerminateProp?.(event); }, [onContentProcessDidTerminateProp] ); // !iOS Only const onLoadingFinish = useCallback( (event: WebViewNavigationEvent) => { onLoad?.(event); onLoadEnd?.(event); const { nativeEvent: { url }, } = event; // on Android, only if url === startUrl if (Platform.OS !== 'android' || url === startUrl.current) { setViewState('IDLE'); } // !on Android, only if url === startUrl updateNavigationState(event); }, [onLoad, onLoadEnd, updateNavigationState] ); const onMessage = useCallback( (event: WebViewMessageEvent) => { const { nativeEvent } = event; // Exodus: Validate URL against whitelist before processing message if (!passesWhitelistCallback(nativeEvent.url)) return; try { const parsedData = JSON.parse(nativeEvent.data); const data = JSON.stringify(validateData(parsedData)); const meta = validateMeta(extractMeta(nativeEvent)); onMessageProp?.({ ...meta, data }); } catch (err) { console.error('Error parsing WebView message', err); } }, [onMessageProp, passesWhitelistCallback, validateData, validateMeta] ); const onLoadingProgress = useCallback( (event: WebViewProgressEvent) => { const { nativeEvent: { progress }, } = event; // patch for Android only if (Platform.OS === 'android' && progress === 1) { setViewState((prevViewState) => prevViewState === 'LOADING' ? 'IDLE' : prevViewState ); } // !patch for Android only onLoadProgress?.(event); }, [onLoadProgress] ); const onShouldStartLoadWithRequest = useMemo( () => createOnShouldStartLoadWithRequest( onShouldStartLoadWithRequestCallback, originWhitelist, deeplinkWhitelist, onShouldStartLoadWithRequestProp ), [ originWhitelist, deeplinkWhitelist, onShouldStartLoadWithRequestProp, onShouldStartLoadWithRequestCallback, ] ); const onOpenWindow = useCallback( (event: WebViewOpenWindowEvent) => { onOpenWindowProp?.(event); }, [onOpenWindowProp] ); return { onShouldStartLoadWithRequest, onLoadingStart, onLoadingProgress, onLoadingError, onLoadingSubResourceError, onLoadingFinish, onRenderProcessGone, onContentProcessDidTerminate, onMessage, onOpenWindow, viewState, setViewState, lastErrorEvent, }; }; /** * Exodus: Check if a version string passes the minimum version requirement. * Supports complex version constraints like "12.5.6 <13, 13.6.1 <14, 14.8.1 <15, 15.7.1" * which means: * - 12.5.6 or higher but less than 13 * - OR 13.6.1 or higher but less than 14 * - OR 14.8.1 or higher but less than 15 * - OR 15.7.1 or higher (no upper bound) */ export const versionPasses = ( version: string | undefined, minimum: string | undefined ): boolean => { if (!version || !minimum) return false; if (typeof version !== 'string' || typeof minimum !== 'string') return false; // Handle multiple version ranges separated by ", " if (minimum.includes(', ')) { const variants = minimum.split(', '); // Every entry but the last one should have an upper bound if (!variants.slice(0, -1).every((x) => x.includes(' <'))) return false; // Any match passes return variants.some((x) => versionPasses(version, x)); } // Handle version range with upper bound (e.g., "12.5.6 <13") if (minimum.includes(' <')) { const [min, max, ...rest] = minimum.split(' <'); if (rest.length > 0) return false; // Must be >= min AND < max // Last check validates that max > min (formatting validation) return ( versionPasses(version, min) && !versionPasses(version, max) && versionPasses(max, version) ); } // Simple version comparison (e.g., "15.7.1") const versionRegex = /^[0-9]+(\.[0-9]+)*$/; if (!versionRegex.test(version) || !versionRegex.test(minimum)) return false; const versionParts = version.split('.').map(Number); const minimumParts = minimum.split('.').map(Number); const len = Math.max(versionParts.length, minimumParts.length); for (let i = 0; i < len; i += 1) { const ver = versionParts[i] || 0; const min = minimumParts[i] || 0; if (ver > min) return true; if (ver < min) return false; } return true; // equals };