fix(android): fix injectedJavaScriptBeforeContentLoaded and injectedJavaScriptObject by Kudo · Pull Request #3929 · react-native-webview/react-native-webview · GitHub
Skip to content

fix(android): fix injectedJavaScriptBeforeContentLoaded and injectedJavaScriptObject - #3929

Open
Kudo wants to merge 2 commits into
react-native-webview:masterfrom
Kudo:@kudo/wip/fix-android-9
Open

Kudo wants to merge 2 commits into
react-native-webview:masterfrom
Kudo:@kudo/wip/fix-android-9

Conversation

@Kudo

@Kudo Kudo commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

Why

Fixing two issues on Android:

How

injectedJavaScriptObject — Revamp bridge architecture

The old bridge architecture used addWebMessageListener (WML) on "ReactNativeWebView" for messaging and addJavascriptInterface on a separate name as fallback. injectedObjectJson() was injected via evaluateJavascript with backtick interpolation, which was fragile and not running synchronously.

The new design flips the relationship:

  • addJavascriptInterface is always registered on "ReactNativeWebView", making injectedObjectJson() a native @JavascriptInterface method — available before DOMContentLoaded, no JS aliasing needed.
  • WML is registered on an internal name ("__RNCWebViewMessageListener") for origin security.
  • When WML is available, window.ReactNativeWebView is replaced with a wrapper (via Object.assign) that routes postMessage through WML while preserving all other properties including injectedObjectJson().
Android WebView feature postMessage injectedObjectJson beforeContentLoaded
WML + DSS (modern) WML (sourceOrigin) RNCWebViewBridge DSS
WML only (mid) WML after override runs; @JavascriptInterface before that RNCWebViewBridge evaluateJavascript (unreliable)
Neither (old) @JavascriptInterface RNCWebViewBridge evaluateJavascript (unreliable)

injectedJavaScriptBeforeContentLoaded — Document Start Scripts

Use WebViewCompat.addDocumentStartJavaScript() API to inject injectedJavaScriptBeforeContentLoaded before any page content loads. This is similar to WKUserScriptInjectionTimeAtDocumentStart on iOS but only available in newer WebView (when WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) is available).

For older Android WebView (e.g. factory Android 9 WebView), DSS is not supported and injectedJavaScriptBeforeContentLoaded falls back to the existing evaluateJavascript path (which remains unreliable on those devices — a known platform limitation).

Test Plan

A new InjectedObjectJson example has been added to the example app to verify injectedObjectJson() availability at both DOMContentLoaded and afterContentLoaded. Also verified the existing Messaging example to ensure postMessage continues to work correctly.

  • Modern Android (WML + DSS): Run the Injection and InjectedObjectJson examples

    • injectedObjectJson() should be available at both DOMContentLoaded and afterContentLoaded
    • postMessage should work (messaging example)
    • beforeContentLoaded injection should succeed (injection example)
  • Android 9 / old WebView (no WML, no DSS): Run same examples

    • injectedObjectJson() should work (RNCWebViewBridge, always available)
    • postMessage should work via @JavascriptInterface
    • beforeContentLoaded may be unreliable (known platform limitation)

Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com

Kudo and others added 2 commits February 26, 2026 04:44
Always register `addJavascriptInterface` on "ReactNativeWebView" as the
primary bridge, making `injectedObjectJson()` a native @JavascriptInterface
method available before DOMContentLoaded without JS aliasing.

When WML is supported, register it on an internal name
("__RNCWebViewMessageListener") and override `window.ReactNativeWebView`
with a wrapper (via Object.assign) that routes `postMessage` through WML
for origin security while preserving all other properties.

This eliminates the backtick injection vulnerability in the old
`injectJavascriptObject()` approach and provides a clearer mental model:
one bridge object, one optional messaging upgrade.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ntLoaded injection

Use WebViewCompat.addDocumentStartJavaScript() API to inject
injectedJavaScriptBeforeContentLoaded before any page content loads,
replacing the unreliable evaluateJavascript() call during onPageStarted
where the V8 context may not be ready.

The document start script path activates when the DOCUMENT_START_SCRIPT
feature is supported by the WebView. When mainFrameOnly is true (default),
the script is wrapped with a top-frame guard. Falls back gracefully to the
existing evaluateJavascript path on older WebViews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Kudo
Kudo force-pushed the @kudo/wip/fix-android-9 branch from 82e8855 to 6e8ad60 Compare February 25, 2026 21:16
@Kudo Kudo mentioned this pull request Feb 25, 2026
3 tasks done
@Kudo
Kudo marked this pull request as ready for review February 25, 2026 21:40
@kelset

kelset commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

mmm looks like the CI is failing potentially because iPhone 15 is old (?)

but thanks @Kudo for working on this! it'd be great to get it in, would unlock a lot on my end :)

@versten1uk

Copy link
Copy Markdown

Any updates here guys?

Kudo added a commit to expo/expo that referenced this pull request Apr 28, 2026
# Why

fixes dom components rendering problems on android (specific android 9)
related to #34868 and #40413
close ENG-17714

# How

- [dom] `injectedJavaScriptBeforeContentLoaded` is not reliable on android. use `injectedJavaScriptObject` instead.
- [dom] inject `window.ReactNativeWebView.injectedObjectJson()` as `window.$$EXPO_INITIAL_PROPS` in entry html before going into bundle
- [dom] entry html also adds polyfills for `globalThis` and `Array.flat` which android 9 factory webview doesn't support (chrome 66)
- [metro-config][babel-preset-expo] for dom components, the babel should not use modern browser presets.

> [!IMPORTANT]  
> because react-native-webview `injectedJavaScriptObject` is not reliable as well. i've created the pr to fix the problem react-native-webview/react-native-webview#3929. but we need to bump react-native-webview with the webview fix before merging the pr

# Test Plan

- apply changes from react-native-webview/react-native-webview#3929
- tested on android/ios on router-e2e dom components
- tested on android 9 emulator

# Checklist

- [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin).
- [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
yousofabouhalawa pushed a commit to yousofabouhalawa/expo that referenced this pull request May 1, 2026
# Why

fixes dom components rendering problems on android (specific android 9)
related to expo#34868 and expo#40413
close ENG-17714

# How

- [dom] `injectedJavaScriptBeforeContentLoaded` is not reliable on android. use `injectedJavaScriptObject` instead.
- [dom] inject `window.ReactNativeWebView.injectedObjectJson()` as `window.$$EXPO_INITIAL_PROPS` in entry html before going into bundle
- [dom] entry html also adds polyfills for `globalThis` and `Array.flat` which android 9 factory webview doesn't support (chrome 66)
- [metro-config][babel-preset-expo] for dom components, the babel should not use modern browser presets.

> [!IMPORTANT]  
> because react-native-webview `injectedJavaScriptObject` is not reliable as well. i've created the pr to fix the problem react-native-webview/react-native-webview#3929. but we need to bump react-native-webview with the webview fix before merging the pr

# Test Plan

- apply changes from react-native-webview/react-native-webview#3929
- tested on android/ios on router-e2e dom components
- tested on android 9 emulator

# Checklist

- [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin).
- [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
@caueobici

Copy link
Copy Markdown

Is there any updates here? This fixes a code injection security issue so i think it should be prioritized

@seanleecoder

Copy link
Copy Markdown

Another downstream data point, since this PR fixes two bugs that turn out to be load-bearing simultaneously in the same code path.

Context: Expo SDK 57 DOM components ("use dom") rendered through react-native-webview 13.16.1 (useExpoDOMWebView: false), on Android. This is the same failure as expo/expo#47373, where @Kudo pointed at this PR, the reporter confirmed it resolved the crash, and the issue was then closed as third-party. So this PR is currently the only home for that bug — there is no open Expo-side issue tracking it.

The Android symptom is a hard crash on mount:

Error: Top OS ($$EXPO_DOM_HOST_OS) is not defined. This is a bug in the DOM Component runtime.
  at registerDOMComponent (expo/src/dom/dom-entry.tsx)

because window.ReactNativeWebView.injectedObjectJson is installed asynchronously via evaluateJavascript from onPageStarted, so it can land after the DOM bundle has already been evaluated. Near-certain in dev, where the bundle <script> is parser-blocking, and still reachable in production where it is deferred. iOS is unaffected — it installs the same helper as a document-start WKUserScript.

What that costs downstream: two interlocking workarounds, one per bug this PR fixes.

  1. A package patch to expo/src/dom/dom-entry.tsx that polls for injectedObjectJson (16ms, 10s ceiling) and replays the bootstrap, instead of throwing. Turns an intermittent hard crash into a few frames of delay.
  2. A deliberately non-empty injectedJavaScriptBeforeContentLoaded. callInjectedJavaScriptBeforeContentLoaded() no-ops on an empty string, and on Android it is the only thing that re-runs injectJavascriptObject() after navigating to the DOM page — i.e. the only way injectedObjectJson ever comes back at all. With an empty string, workaround (1) has nothing to wait for.

That second one is the part worth flagging for prioritisation: it means the injectedJavaScriptBeforeContentLoaded unreliability (#1609, #1099) and the injectedJavaScriptObject bug (#3326) are not independent in practice. Fixing either alone would leave this path broken, which is presumably why the revamped bridge architecture here — addJavascriptInterface always on ReactNativeWebView, making injectedObjectJson() a native @JavascriptInterface available before DOMContentLoaded — is the right shape rather than a targeted patch.

Happy to test this branch against a real Expo SDK 57 app with DOM components on Android and report back, if that would help move the review along.

@N0tre3l

N0tre3l commented Oct 1, 2026

Copy link
Copy Markdown

Downstream security impact evidence for this fix — executed on react-native-webview 13.16.0 (Android 15 emulator)

Context: consumed by a production SDK (stripe/stripe-react-native, peer range *) which feeds injectedJavaScriptObject with {initParams: {appearance, locale, fonts}, initComponentProps: componentProps, appInfo: {applicationId}} — six developer-facing, string-bearing fields.

Vulnerable sink on 13.16.0: RNCWebView.java:279 — injectedObjectJson = function () { return `` + injectedJavaScriptObject + ``; } (raw backtick interpolation of the JSON payload).

Executed chain (all three stages, real device):

  1. Code execution at document start — a declarative theme string carrying a template expression:
    appearance.variables.fontFamily = "${console.log('N1-SINK-FIRED')}" →
    [INFO:CONSOLE(3)] "N1-SINK-FIRED" logged before connect.js initialization.

  2. Bridge forgery → native action — payload calls
    window.ReactNativeWebView.postMessage(JSON.stringify({type:'openAuthenticatedWebView',data:{url:'https://example.com/phish',id:'x'}}))
    → the downstream SDK's message handler accepted it and opened an unsolicited Chrome Custom Tab at https://example.com inside a payment onboarding flow, zero user interaction:
    ActivityTaskManager: START {act=VIEW dat=https://example.com/...} → topResumedActivity=...CustomTabActivity.

  3. Confidentiality — client-secret exfiltration — payload installs an accessor on the page's deferred at document start:
    Object.defineProperty(window,'clientSecretDeferred',{set:function(v){var o=v.resolve.bind(v);v.resolve=function(x){console.log('N1-CHAIN-SECRET:'+x);return o(x);};...}})
    → when the app's fetchClientSecret() result was injected via window.clientSecretDeferred.resolve(<secret>), the attacker wrapper captured it first:
    12:41:55.728 "N1-CHAIN-SECRET:accs_secret__VMPJxOAjK..." — 6 ms ahead of the legitimate consumer's own log of the same value (12:41:55.734).

So the interpolation is not only an escape/robustness issue: any string that reaches injectedJavaScriptObject becomes code execution in the top frame with access to the ReactNativeWebView bridge (native-facing message channel) and to secrets the app delivers to the page.

Same-class sink on iOS in 13.16.0: apple/RNCWebViewImpl.m (return \%@`;`) — static line evidence only (no iOS device run); PR #3958 addresses it.

Full logcat traces available if useful.

@jacamis

jacamis commented Oct 22, 2026

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

injectedJavaScriptObject prop (sometimes) does not work on Android

8 participants