diff --git a/.changeset/quote-injected-object-json.md b/.changeset/quote-injected-object-json.md new file mode 100644 index 0000000000..5f955b9718 --- /dev/null +++ b/.changeset/quote-injected-object-json.md @@ -0,0 +1,7 @@ +--- +"@phantom/react-native-webview": patch +--- + +Quote `injectedJavaScriptObject` JSON as a JavaScript string on Android and +Apple platforms. This preserves nested JSON and control characters while +preventing template-literal interpolation from executing injected code. diff --git a/android/build.gradle b/android/build.gradle index cb3476ad25..e906547634 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -107,4 +107,6 @@ dependencies { implementation 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion')}" implementation "androidx.webkit:webkit:${safeExtGet('webkitVersion')}" + + testImplementation "junit:junit:4.13.2" } diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCJavaScriptString.java b/android/src/main/java/com/reactnativecommunity/webview/RNCJavaScriptString.java new file mode 100644 index 0000000000..8b0085b6e9 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCJavaScriptString.java @@ -0,0 +1,57 @@ +package com.reactnativecommunity.webview; + +final class RNCJavaScriptString { + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private RNCJavaScriptString() {} + + static String quote(String value) { + if (value == null) { + return "null"; + } + + StringBuilder quoted = new StringBuilder(value.length() + 2); + quoted.append('"'); + for (int index = 0; index < value.length(); index += 1) { + char character = value.charAt(index); + switch (character) { + case '"': + quoted.append("\\\""); + break; + case '\\': + quoted.append("\\\\"); + break; + case '\b': + quoted.append("\\b"); + break; + case '\f': + quoted.append("\\f"); + break; + case '\n': + quoted.append("\\n"); + break; + case '\r': + quoted.append("\\r"); + break; + case '\t': + quoted.append("\\t"); + break; + default: + if (character <= 0x1f || character == '\u2028' || character == '\u2029') { + appendUnicodeEscape(quoted, character); + } else { + quoted.append(character); + } + } + } + return quoted.append('"').toString(); + } + + private static void appendUnicodeEscape(StringBuilder output, char character) { + output.append("\\u"); + output.append(HEX[(character >> 12) & 0xf]); + output.append(HEX[(character >> 8) & 0xf]); + output.append(HEX[(character >> 4) & 0xf]); + output.append(HEX[character & 0xf]); + } +} diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java index efa843b072..2f799bc45c 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java @@ -303,9 +303,10 @@ public void onPostMessage(@NonNull WebView view, @NonNull WebMessageCompat messa private void injectJavascriptObject() { if (getSettings().getJavaScriptEnabled()) { + String objectJson = RNCJavaScriptString.quote(injectedJavaScriptObject); String js = "(function(){\n" + " window." + JAVASCRIPT_INTERFACE + " = window." + JAVASCRIPT_INTERFACE + " || {};\n" + - " window." + JAVASCRIPT_INTERFACE + ".injectedObjectJson = function () { return " + (injectedJavaScriptObject == null ? null : ("`" + injectedJavaScriptObject + "`")) + "; };\n" + + " window." + JAVASCRIPT_INTERFACE + ".injectedObjectJson = function () { return " + objectJson + "; };\n" + "})();"; evaluateJavascriptWithFallback(js); } diff --git a/android/src/test/java/com/reactnativecommunity/webview/RNCJavaScriptStringTest.java b/android/src/test/java/com/reactnativecommunity/webview/RNCJavaScriptStringTest.java new file mode 100644 index 0000000000..ca09cab1af --- /dev/null +++ b/android/src/test/java/com/reactnativecommunity/webview/RNCJavaScriptStringTest.java @@ -0,0 +1,36 @@ +package com.reactnativecommunity.webview; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class RNCJavaScriptStringTest { + @Test + public void quotesTemplateLiteralInjectionCharacters() { + assertEquals( + "\"`${alert(1)}`;globalThis.pwned=true;//\"", + RNCJavaScriptString.quote("`${alert(1)}`;globalThis.pwned=true;//") + ); + } + + @Test + public void preservesNestedJsonEscapes() { + assertEquals( + "\"{\\\"state\\\":\\\"{\\\\\\\"key\\\\\\\":\\\\\\\"value\\\\\\\"}\\\"}\"", + RNCJavaScriptString.quote("{\"state\":\"{\\\"key\\\":\\\"value\\\"}\"}") + ); + } + + @Test + public void escapesControlAndLineSeparatorCharacters() { + assertEquals( + "\"line\\nnext\\tvalue\\u0000\\u2028\\u2029\"", + RNCJavaScriptString.quote("line\nnext\tvalue\u0000\u2028\u2029") + ); + } + + @Test + public void returnsJavaScriptNullForNull() { + assertEquals("null", RNCJavaScriptString.quote(null)); + } +} diff --git a/apple/RNCWebViewImpl.m b/apple/RNCWebViewImpl.m index ea6024246e..addcf5735c 100644 --- a/apple/RNCWebViewImpl.m +++ b/apple/RNCWebViewImpl.m @@ -25,6 +25,28 @@ NSString *const CUSTOM_SELECTOR = @"_CUSTOM_SELECTOR_"; +static NSString *RNCJavaScriptStringLiteral(NSString *value) +{ + if (value == nil) { + return @"null"; + } + + NSError *error = nil; + NSData *serialized = [NSJSONSerialization dataWithJSONObject:@[value] options:0 error:&error]; + if (serialized == nil || error != nil) { + return @"null"; + } + + NSString *arrayLiteral = [[NSString alloc] initWithData:serialized encoding:NSUTF8StringEncoding]; + if (arrayLiteral.length < 2) { + return @"null"; + } + + NSString *quoted = [arrayLiteral substringWithRange:NSMakeRange(1, arrayLiteral.length - 2)]; + quoted = [quoted stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"]; + return [quoted stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"]; +} + #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 @@ -1778,6 +1800,7 @@ - (void)setInjectedJavaScript:(NSString *)source { - (void)setInjectedJavaScriptObject:(NSString *)source { _injectedJavaScriptObject = source; + NSString *objectJson = RNCJavaScriptStringLiteral(source); self.injectedObjectJsonScript = [ [WKUserScript alloc] initWithSource: [ @@ -1785,8 +1808,8 @@ - (void)setInjectedJavaScriptObject:(NSString *)source stringWithFormat: @"window.%@ = window.%@ || {};" "window.%@.injectedObjectJson = function () {" - " return `%@`;" - "};", MessageHandlerName, MessageHandlerName, MessageHandlerName, source + " return %@;" + "};", MessageHandlerName, MessageHandlerName, MessageHandlerName, objectJson ] injectionTime:WKUserScriptInjectionTimeAtDocumentStart /* TODO: For a separate (minor) PR: use logic like this (as react-native-wkwebview does) so that messaging can be used in all frames if desired.