From 95efa50616be41ceaeb8fa1742d5fc01bb3d7734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= <175489935+joaoloureirop@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:09:39 +0100 Subject: [PATCH] fix: JSON-serialize URLs before evaluateJavaScript injection Unescaped urlString interpolation in download and iframe scripts allowed crafted URLs to break out of JS string literals and run in the current page origin. Serialize with RCTJSONStringify / JSONObject.quote instead. Co-authored-by: Cursor --- .../extension/file/BlobFileDownloader.kt | 4 +- apple/RNCWebViewImpl.m | 48 ++++++++++++++----- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/com/reactnativecommunity/webview/extension/file/BlobFileDownloader.kt b/android/src/main/java/com/reactnativecommunity/webview/extension/file/BlobFileDownloader.kt index f338a6a4ed..7241da81c7 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/extension/file/BlobFileDownloader.kt +++ b/android/src/main/java/com/reactnativecommunity/webview/extension/file/BlobFileDownloader.kt @@ -4,6 +4,7 @@ import android.webkit.JavascriptInterface import com.reactnativecommunity.webview.RNCWebView import com.reactnativecommunity.webview.extension.file.Base64FileDownloader.downloadBase64File import java.io.IOException +import org.json.JSONObject internal fun RNCWebView.addBlobFileDownloaderJavascriptInterface(downloadingMessage: String, requestFilePermission: (String) -> Unit) { this.addJavascriptInterface( @@ -33,7 +34,8 @@ internal class BlobFileDownloader( /** * Invokes JS method downloadBlob from [getBlobFileInterceptor] */ - fun getDownloadBlobInterceptor(url: String): String = "downloadBlob('$url');" + fun getDownloadBlobInterceptor(url: String): String = + "downloadBlob(${JSONObject.quote(url)});" /** * This script handles Blob downloading in two ways: diff --git a/apple/RNCWebViewImpl.m b/apple/RNCWebViewImpl.m index 46c6ebfefd..94ca930fec 100644 --- a/apple/RNCWebViewImpl.m +++ b/apple/RNCWebViewImpl.m @@ -17,6 +17,7 @@ #endif // !TARGET_OS_OSX #import "objc/runtime.h" +#import static NSTimer *keyboardTimer; static NSString *const HistoryShimName = @"ReactNativeHistoryShim"; @@ -26,6 +27,16 @@ NSString *const CUSTOM_SELECTOR = @"_CUSTOM_SELECTOR_"; +/** + * JSON-serialize a value as a JavaScript literal so attacker-controlled + * strings cannot break out of evaluateJavaScript templates. + * Returns nil if serialization fails. + */ +static NSString *RNCJavaScriptJSONLiteral(id value) +{ + return RCTJSONStringify(value, NULL); +} + #if TARGET_OS_IOS // runtime trick to remove WKWebView keyboard default toolbar // see: http://stackoverflow.com/questions/19033292/ios-7-uiwebview-keyboard-issue/19042279#19042279 @@ -1487,14 +1498,17 @@ - (void) webView:(WKWebView *)webView self->_onLoadingStart(event); } else { // In aditional to IFrameDetector report all navigated iFrames to the app - NSString *reportIframeUrlsScript = [NSString stringWithFormat: - @"if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {\n" - @" window.ReactNativeWebView.postMessage(JSON.stringify({\n" - @" type: 'IFRAME_DETECTED',\n" - @" iframeUrls: ['%@']\n" - @"}));\n" - @"}", urlString]; - [self.webView evaluateJavaScript:reportIframeUrlsScript completionHandler:^(id result, NSError *error) {}]; + NSString *serializedUrl = RNCJavaScriptJSONLiteral(urlString); + if (serializedUrl != nil) { + NSString *reportIframeUrlsScript = [NSString stringWithFormat: + @"if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {\n" + @" window.ReactNativeWebView.postMessage(JSON.stringify({\n" + @" type: 'IFRAME_DETECTED',\n" + @" iframeUrls: [%@]\n" + @"}));\n" + @"}", serializedUrl]; + [self.webView evaluateJavaScript:reportIframeUrlsScript completionHandler:^(id result, NSError *error) {}]; + } } } @@ -2122,9 +2136,14 @@ - (NSURLRequest *)requestForSource:(id)json { } - (void)handleBlobDownloadUrl:(NSString *)urlString { + NSString *serializedUrl = RNCJavaScriptJSONLiteral(urlString); + if (serializedUrl == nil) { + NSLog(@"Error downloading blob: failed to serialize URL"); + return; + } NSString *jsCode = [NSString stringWithFormat: @"var xhr = new XMLHttpRequest();" - "xhr.open('GET', '%@', true);" + "xhr.open('GET', %@, true);" "xhr.responseType = 'blob';" "xhr.onload = function(e) {" " if (this.status == 200) {" @@ -2137,7 +2156,7 @@ - (void)handleBlobDownloadUrl:(NSString *)urlString { " };" " }" "};" - "xhr.send();", urlString]; + "xhr.send();", serializedUrl]; [self.webView evaluateJavaScript:jsCode completionHandler:^(id result, NSError *error) {}]; } @@ -2160,8 +2179,13 @@ - (BOOL)isDownloadableFileURL:(NSURL *)url { } - (void)handleRegularFileDownload:(NSString *)urlString { + NSString *serializedUrl = RNCJavaScriptJSONLiteral(urlString); + if (serializedUrl == nil) { + NSLog(@"Error downloading file: failed to serialize URL"); + return; + } NSString *jsCode = [NSString stringWithFormat: - @"fetch('%@')" + @"fetch(%@)" ".then(response => {" " if (!response.ok) throw new Error('Unable to download file');" " return response.blob();" @@ -2175,7 +2199,7 @@ - (void)handleRegularFileDownload:(NSString *)urlString { "})" ".catch(error => {" " console.error('Download failed:', error);" - "});", urlString]; + "});", serializedUrl]; [self.webView evaluateJavaScript:jsCode completionHandler:^(id result, NSError *error) { if (error) {