From 69e95025c546f03b5780c6fe239e8a925496dffc Mon Sep 17 00:00:00 2001 From: Muhammad Ndako Date: Fri, 4 Apr 2025 10:25:10 +0100 Subject: [PATCH 1/8] feat: ensure download request originates from permitted origins --- src/WebView.android.tsx | 2 ++ src/WebView.ios.tsx | 2 ++ src/WebViewShared.tsx | 44 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/WebView.android.tsx b/src/WebView.android.tsx index 969ed1dfcc..a6b53e3ddb 100644 --- a/src/WebView.android.tsx +++ b/src/WebView.android.tsx @@ -71,6 +71,7 @@ const WebViewComponent = forwardRef<{}, AndroidWebViewProps>((props, ref) => { androidLayerType = "none", originWhitelist = defaultOriginWhitelist, deeplinkWhitelist = defaultDeeplinkWhitelist, + downloadOriginWhitelist = [], setBuiltInZoomControls = true, setDisplayZoomControls = false, nestedScrollEnabled = false, @@ -108,6 +109,7 @@ const WebViewComponent = forwardRef<{}, AndroidWebViewProps>((props, ref) => { }, []); const { onLoadingStart, onShouldStartLoadWithRequest, onMessage, viewState, setViewState, lastErrorEvent, onLoadingError, onLoadingFinish, onLoadingProgress, onOpenWindow, passesWhitelist } = useWebWiewLogic({ + downloadOriginWhitelist, onLoad, onError, onLoadEnd, diff --git a/src/WebView.ios.tsx b/src/WebView.ios.tsx index 0189bdde24..67b613fdc7 100644 --- a/src/WebView.ios.tsx +++ b/src/WebView.ios.tsx @@ -74,6 +74,7 @@ const WebViewComponent = forwardRef<{}, IOSWebViewProps>((props, ref) => { cacheEnabled = true, originWhitelist = defaultOriginWhitelist, deeplinkWhitelist = defaultDeeplinkWhitelist, + downloadOriginWhitelist = [], textInteractionEnabled= true, injectedJavaScript, injectedJavaScriptBeforeContentLoaded, @@ -111,6 +112,7 @@ const WebViewComponent = forwardRef<{}, IOSWebViewProps>((props, ref) => { }, []); const { onLoadingStart, onShouldStartLoadWithRequest, onMessage, viewState, setViewState, lastErrorEvent, onLoadingError, onLoadingFinish, onLoadingProgress } = useWebWiewLogic({ + downloadOriginWhitelist, onLoad, onError, onLoadEnd, diff --git a/src/WebViewShared.tsx b/src/WebViewShared.tsx index 1986c66011..8f241d838b 100644 --- a/src/WebViewShared.tsx +++ b/src/WebViewShared.tsx @@ -56,8 +56,32 @@ const _passesWhitelist = ( const compileWhitelist = ( originWhitelist: readonly string[], + defaultEntries: readonly string[] = ['about:blank'], ): readonly RegExp[] => - ['about:blank', ...(originWhitelist || [])].map(stringWhitelistToRegex); + [...defaultEntries, ...(originWhitelist || [])].map(stringWhitelistToRegex); + +const isDownloadMessageAllowed = ({ + data, + url, + compiledDownloadWhitelist, +}: { + data: string, + url: string, + compiledDownloadWhitelist: readonly RegExp[], +}): boolean => { + try { + const parsedData = JSON.parse(data); + + if (parsedData?.method === 'download') { + return _passesWhitelist(compiledDownloadWhitelist, url); + } + } catch { + // Ignore invalid JSON — treat as non-download message + } + + // Non-download messages are allowed by default + return true; +}; const urlToProtocolScheme = (url: string): string | null => { try { @@ -150,8 +174,8 @@ export { defaultRenderError, }; - export const useWebWiewLogic = ({ + downloadOriginWhitelist, startInLoadingState, onLoadStart, onLoad, @@ -166,6 +190,7 @@ export const useWebWiewLogic = ({ validateMeta, validateData, }: { + downloadOriginWhitelist: readonly string[]; startInLoadingState?: boolean onLoadStart?: (event: WebViewNavigationEvent) => void; onLoad?: (event: WebViewNavigationEvent) => void; @@ -243,14 +268,25 @@ export const useWebWiewLogic = ({ // TODO: can/should we perform any other validation? try { const parsedData = JSON.parse(nativeEvent.data); - const data = JSON.stringify(validateData(parsedData)); + const validatedData = validateData(parsedData); + + if (!isDownloadMessageAllowed({ + data: parsedData.data, + url: nativeEvent.url, + compiledDownloadWhitelist: compileWhitelist(downloadOriginWhitelist, []), + })) { + console.warn('Download request rejected: origin not in download whitelist'); + return; + } + + const data = JSON.stringify(validatedData); const meta = validateMeta(extractMeta(nativeEvent)); onMessageProp?.({ ...meta, data }); } catch (err) { console.error('Error parsing WebView message', err); } - }, [onMessageProp, passesWhitelistUse, validateData, validateMeta]); + }, [onMessageProp, passesWhitelistUse, validateData, validateMeta, downloadOriginWhitelist]); const onLoadingProgress = useCallback((event: WebViewProgressEvent) => { const { nativeEvent: { progress } } = event; From 347763c2b0ddf7183eeceecbbeb5ab837ddd5246 Mon Sep 17 00:00:00 2001 From: Muhammad Ndako Date: Fri, 4 Apr 2025 10:25:41 +0100 Subject: [PATCH 2/8] feat: add downloadOriginWhitelist types --- src/WebViewTypes.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/WebViewTypes.ts b/src/WebViewTypes.ts index 0de7379d70..ee30f60de2 100644 --- a/src/WebViewTypes.ts +++ b/src/WebViewTypes.ts @@ -788,11 +788,17 @@ export interface WebViewSharedProps extends ViewProps { */ javaScriptEnabled?: boolean; + /** + * List of origin strings that are allowed to send download data through postMessage. + * The strings allow wildcards and get matched against *just* the origin (not the full URL). + * If a download request comes from an origin not in this whitelist, it will be ignored. + * The default whitelisted origins are empty array []. + */ + readonly downloadOriginWhitelist?: string[]; /** * Defines a list of domain origins that can access camera. */ - readonly cameraPermissionOriginWhitelist?: string[]; /** From ae0e7973f088fe6fb5e4f8d59e9428ca83f11e27 Mon Sep 17 00:00:00 2001 From: Muhammad Ndako Date: Fri, 4 Apr 2025 10:26:08 +0100 Subject: [PATCH 3/8] fix: always return boolean --- src/WebViewShared.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebViewShared.tsx b/src/WebViewShared.tsx index 8f241d838b..5b3fafc29b 100644 --- a/src/WebViewShared.tsx +++ b/src/WebViewShared.tsx @@ -45,7 +45,7 @@ const _passesWhitelist = ( const { href, origin } = new URL(url) if (origin && origin !== 'null') { - return matchWithRegexList(compiledWhitelist, origin); + return matchWithRegexList(compiledWhitelist, origin) } return matchWithRegexList(compiledWhitelist, href) From c2ad41dd89d3e3003c6364b14a4e2210ff397d83 Mon Sep 17 00:00:00 2001 From: Muhammad Ndako Date: Wed, 9 Apr 2025 00:59:22 +0100 Subject: [PATCH 4/8] feat: validate download file extension and origin --- src/WebView.android.tsx | 4 +-- src/WebView.ios.tsx | 4 +-- src/WebViewShared.tsx | 54 ++++++++++++++++++++++++++--------------- src/WebViewTypes.ts | 19 +++++++++------ 4 files changed, 51 insertions(+), 30 deletions(-) diff --git a/src/WebView.android.tsx b/src/WebView.android.tsx index a6b53e3ddb..41d67348f7 100644 --- a/src/WebView.android.tsx +++ b/src/WebView.android.tsx @@ -71,7 +71,7 @@ const WebViewComponent = forwardRef<{}, AndroidWebViewProps>((props, ref) => { androidLayerType = "none", originWhitelist = defaultOriginWhitelist, deeplinkWhitelist = defaultDeeplinkWhitelist, - downloadOriginWhitelist = [], + downloadWhitelist = [], setBuiltInZoomControls = true, setDisplayZoomControls = false, nestedScrollEnabled = false, @@ -109,7 +109,7 @@ const WebViewComponent = forwardRef<{}, AndroidWebViewProps>((props, ref) => { }, []); const { onLoadingStart, onShouldStartLoadWithRequest, onMessage, viewState, setViewState, lastErrorEvent, onLoadingError, onLoadingFinish, onLoadingProgress, onOpenWindow, passesWhitelist } = useWebWiewLogic({ - downloadOriginWhitelist, + downloadWhitelist, onLoad, onError, onLoadEnd, diff --git a/src/WebView.ios.tsx b/src/WebView.ios.tsx index 67b613fdc7..6a3bbfc98c 100644 --- a/src/WebView.ios.tsx +++ b/src/WebView.ios.tsx @@ -74,7 +74,7 @@ const WebViewComponent = forwardRef<{}, IOSWebViewProps>((props, ref) => { cacheEnabled = true, originWhitelist = defaultOriginWhitelist, deeplinkWhitelist = defaultDeeplinkWhitelist, - downloadOriginWhitelist = [], + downloadWhitelist = [], textInteractionEnabled= true, injectedJavaScript, injectedJavaScriptBeforeContentLoaded, @@ -112,7 +112,7 @@ const WebViewComponent = forwardRef<{}, IOSWebViewProps>((props, ref) => { }, []); const { onLoadingStart, onShouldStartLoadWithRequest, onMessage, viewState, setViewState, lastErrorEvent, onLoadingError, onLoadingFinish, onLoadingProgress } = useWebWiewLogic({ - downloadOriginWhitelist, + downloadWhitelist, onLoad, onError, onLoadEnd, diff --git a/src/WebViewShared.tsx b/src/WebViewShared.tsx index 5b3fafc29b..d688ccac46 100644 --- a/src/WebViewShared.tsx +++ b/src/WebViewShared.tsx @@ -63,24 +63,38 @@ const compileWhitelist = ( const isDownloadMessageAllowed = ({ data, url, - compiledDownloadWhitelist, + downloadWhitelist, }: { - data: string, - url: string, - compiledDownloadWhitelist: readonly RegExp[], + data: string; + url: string; + downloadWhitelist: { origin: string; allowedFileExtensions: string[] }[]; }): boolean => { + let parsedData; + try { - const parsedData = JSON.parse(data); - - if (parsedData?.method === 'download') { - return _passesWhitelist(compiledDownloadWhitelist, url); - } + parsedData = JSON.parse(data); } catch { - // Ignore invalid JSON — treat as non-download message + return true; // Invalid JSON — treat as non-download message + } + + if (parsedData.method !== 'download') { + return true; + } + + const { origin } = new URL(url); + const fileExtension = parsedData.params?.fileName?.split('.').pop()?.toLowerCase(); + + if (!fileExtension || !origin) { + return false; } - // Non-download messages are allowed by default - return true; + const matchingRule = downloadWhitelist.find(rule => { + const ruleOriginRegex = stringWhitelistToRegex(rule.origin); + + return ruleOriginRegex.test(origin) && rule.allowedFileExtensions.includes(fileExtension); + }); + + return Boolean(matchingRule); }; const urlToProtocolScheme = (url: string): string | null => { @@ -175,7 +189,7 @@ export { }; export const useWebWiewLogic = ({ - downloadOriginWhitelist, + downloadWhitelist, startInLoadingState, onLoadStart, onLoad, @@ -190,7 +204,7 @@ export const useWebWiewLogic = ({ validateMeta, validateData, }: { - downloadOriginWhitelist: readonly string[]; + downloadWhitelist: { origin: string; allowedFileExtensions: string[] }[]; startInLoadingState?: boolean onLoadStart?: (event: WebViewNavigationEvent) => void; onLoad?: (event: WebViewNavigationEvent) => void; @@ -263,7 +277,9 @@ export const useWebWiewLogic = ({ const onMessage = useCallback((event: WebViewMessageEvent) => { const { nativeEvent } = event; - if (!passesWhitelistUse(nativeEvent.url)) return; + const { url } = nativeEvent; + + if (!passesWhitelistUse(url)) return; // TODO: can/should we perform any other validation? try { @@ -272,10 +288,10 @@ export const useWebWiewLogic = ({ if (!isDownloadMessageAllowed({ data: parsedData.data, - url: nativeEvent.url, - compiledDownloadWhitelist: compileWhitelist(downloadOriginWhitelist, []), + downloadWhitelist, + url, })) { - console.warn('Download request rejected: origin not in download whitelist'); + console.warn('Download request rejected: origin not in download whitelist or file extension not allowed'); return; } @@ -286,7 +302,7 @@ export const useWebWiewLogic = ({ } catch (err) { console.error('Error parsing WebView message', err); } - }, [onMessageProp, passesWhitelistUse, validateData, validateMeta, downloadOriginWhitelist]); + }, [onMessageProp, passesWhitelistUse, validateData, validateMeta, downloadWhitelist]); const onLoadingProgress = useCallback((event: WebViewProgressEvent) => { const { nativeEvent: { progress } } = event; diff --git a/src/WebViewTypes.ts b/src/WebViewTypes.ts index ee30f60de2..66323e7505 100644 --- a/src/WebViewTypes.ts +++ b/src/WebViewTypes.ts @@ -788,13 +788,18 @@ export interface WebViewSharedProps extends ViewProps { */ javaScriptEnabled?: boolean; - /** - * List of origin strings that are allowed to send download data through postMessage. - * The strings allow wildcards and get matched against *just* the origin (not the full URL). - * If a download request comes from an origin not in this whitelist, it will be ignored. - * The default whitelisted origins are empty array []. - */ - readonly downloadOriginWhitelist?: string[]; +/** + * List of objects defining the origins allowed to send download data through postMessage. + * Each object contains: + * - `origin`: The allowed origin (matches against the origin, not the full URL). + * - `allowedFileExtensions`: An array of allowed file extensions for downloads from that origin (required). + * If a download request comes from an origin not in this whitelist, or with a disallowed file extension, it will be ignored. + * The default whitelist is an empty array []. + */ + readonly downloadWhitelist?: { + origin: string; + allowedFileExtensions: string[]; + }[]; /** * Defines a list of domain origins that can access camera. From 4da257587e4112c0b4111915ea43297212313dcd Mon Sep 17 00:00:00 2001 From: Sergii Bondarenko Date: Wed, 13 Aug 2025 17:37:11 +0200 Subject: [PATCH 5/8] refactor: reduce diff --- src/WebViewShared.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/WebViewShared.tsx b/src/WebViewShared.tsx index d688ccac46..5e27f773a5 100644 --- a/src/WebViewShared.tsx +++ b/src/WebViewShared.tsx @@ -45,7 +45,7 @@ const _passesWhitelist = ( const { href, origin } = new URL(url) if (origin && origin !== 'null') { - return matchWithRegexList(compiledWhitelist, origin) + return matchWithRegexList(compiledWhitelist, origin); } return matchWithRegexList(compiledWhitelist, href) @@ -56,9 +56,8 @@ const _passesWhitelist = ( const compileWhitelist = ( originWhitelist: readonly string[], - defaultEntries: readonly string[] = ['about:blank'], ): readonly RegExp[] => - [...defaultEntries, ...(originWhitelist || [])].map(stringWhitelistToRegex); +['about:blank', ...(originWhitelist || [])].map(stringWhitelistToRegex); const isDownloadMessageAllowed = ({ data, @@ -277,9 +276,7 @@ export const useWebWiewLogic = ({ const onMessage = useCallback((event: WebViewMessageEvent) => { const { nativeEvent } = event; - const { url } = nativeEvent; - - if (!passesWhitelistUse(url)) return; + if (!passesWhitelistUse(nativeEvent.url)) return; // TODO: can/should we perform any other validation? try { @@ -289,7 +286,7 @@ export const useWebWiewLogic = ({ if (!isDownloadMessageAllowed({ data: parsedData.data, downloadWhitelist, - url, + url: nativeEvent.url, })) { console.warn('Download request rejected: origin not in download whitelist or file extension not allowed'); return; From cf8d1bf2dad6736dc71a7f493f1593413a396013 Mon Sep 17 00:00:00 2001 From: Sergii Bondarenko Date: Wed, 13 Aug 2025 17:39:51 +0200 Subject: [PATCH 6/8] refactor: consume mapped download validated data --- src/WebViewShared.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebViewShared.tsx b/src/WebViewShared.tsx index 5e27f773a5..6ca8984bbc 100644 --- a/src/WebViewShared.tsx +++ b/src/WebViewShared.tsx @@ -284,7 +284,7 @@ export const useWebWiewLogic = ({ const validatedData = validateData(parsedData); if (!isDownloadMessageAllowed({ - data: parsedData.data, + data: (validatedData as { data?: string })?.data ?? '', downloadWhitelist, url: nativeEvent.url, })) { From 04a38c7de612300514307831b5b786aad47c2585 Mon Sep 17 00:00:00 2001 From: Sergii Bondarenko Date: Wed, 13 Aug 2025 17:41:49 +0200 Subject: [PATCH 7/8] refactor: compare origins instead of regexp match --- src/WebViewShared.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/WebViewShared.tsx b/src/WebViewShared.tsx index 6ca8984bbc..12087c91e3 100644 --- a/src/WebViewShared.tsx +++ b/src/WebViewShared.tsx @@ -87,13 +87,7 @@ const isDownloadMessageAllowed = ({ return false; } - const matchingRule = downloadWhitelist.find(rule => { - const ruleOriginRegex = stringWhitelistToRegex(rule.origin); - - return ruleOriginRegex.test(origin) && rule.allowedFileExtensions.includes(fileExtension); - }); - - return Boolean(matchingRule); + return Boolean(downloadWhitelist.find((rule) => rule.origin === origin && rule.allowedFileExtensions.includes(fileExtension))); }; const urlToProtocolScheme = (url: string): string | null => { From a02e114564614b1aa524a584f27974fd84b8fd96 Mon Sep 17 00:00:00 2001 From: Sergii Bondarenko Date: Wed, 13 Aug 2025 17:42:11 +0200 Subject: [PATCH 8/8] refactor: remove string/array type confusion by using only array --- src/WebViewShared.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebViewShared.tsx b/src/WebViewShared.tsx index 12087c91e3..1bb6d2f4c7 100644 --- a/src/WebViewShared.tsx +++ b/src/WebViewShared.tsx @@ -87,7 +87,7 @@ const isDownloadMessageAllowed = ({ return false; } - return Boolean(downloadWhitelist.find((rule) => rule.origin === origin && rule.allowedFileExtensions.includes(fileExtension))); + return Boolean(downloadWhitelist.find((rule) => rule.origin === origin && [rule.allowedFileExtensions].flat().includes(fileExtension))); }; const urlToProtocolScheme = (url: string): string | null => {