Skip to content

Android: proxy-bridge.js wraps every fetch/XHR when using outbound/inboundProxyRules #555

@crosslist-jonaslarsen

Description

@crosslist-jonaslarsen

Bug Report

Capacitor Version

8

Plugin Version

@capgo/capacitor-inappbrowser: 8.6.16

context(s)

ManualModel: false
AutoMode: false
CapgoCloud: false
OnPremise: false

Platform(s)

Android (the JS bridge injection lives in android/src/main/assets/proxy-bridge.js and is gated by ProxyRequestSupport.shouldInjectBridge — iOS uses WKURLSchemeHandler and is not affected).

Current Behavior

When openWebView is called with the new-API outboundProxyRules and/or inboundProxyRules (and no legacy proxyRequests / proxyRequestsPattern), the proxy bridge script is still injected into every page load and silently proxies every page-level fetch / XMLHttpRequest / form submission through the /_capgo_proxy_?u=…&rid=… marker URL, regardless of whether the URL matches any rule.

Trace:

  1. WebViewDialog#onCreate enables the bridge whenever _options.shouldEnableNativeProxy() is true (i.e., as soon as you set any outbound or inbound rule):

    if (_options.shouldEnableNativeProxy()) {
        proxyAccessToken = UUID.randomUUID().toString();
        proxyBridge = new ProxyBridge(proxyAccessToken);
        _webView.addJavascriptInterface(proxyBridge, "__capgoProxy");
        proxyBridgeScript = loadProxyBridgeScript();
    }
  2. ProxyRequestSupport.shouldInjectBridge returns true for the same condition, so the bridge is re-injected on every onPageStarted:

    static boolean shouldInjectBridge(Options options) {
        return options != null && options.shouldEnableNativeProxy();
    }
  3. prepareProxyBridgeScript only populates the URL pattern for legacy mode, so for the new-API path the placeholder is replaced with an empty string:

    String proxyRegexSource = "";
    if (ProxyRequestSupport.usesLegacyJsProxyMode(_options) && _options.getProxyRequestsPattern() != null) {
        proxyRegexSource = _options.getProxyRequestsPattern().pattern();
    }
  4. In proxy-bridge.js, an empty string produces proxyRequestPattern = null, and shouldProxyBridgeUrl treats a falsy pattern as "match everything":

    const proxyRegexSource = "___CAPGO_PROXY_REGEX___"; // ""
    let proxyRequestPattern = null;
    if (proxyRegexSource) { /* skipped */ }
    // ...
    return !urlRegex || urlRegex.test(resolvedUrl); // !null === true

Net effect: every JS-initiated request on every page in the webview is rewritten to the marker URL, stored in ProxyBridge, intercepted natively, then replayed through performNativeRequest. This happens even for URLs that match no outbound/inbound rule — they were supposed to be handled directly by the WebView. Side effects:

  • window.fetch / XMLHttpRequest.prototype.{open,send,setRequestHeader,…} are monkey-patched on origins the developer never asked to touch (trivially detectable by bot/anti-automation checks, e.g. Facebook).
  • Every request pays an extra IPC + native HTTP replay round trip, losing WebView caching and any Service Worker.
  • Form submit is rewritten to a GET navigation to the marker URL, which can break POST forms.
  • Bodies are forced through base64 encoding; large multipart bodies are needlessly buffered.

This is reproducible with the exact snippet that the README documents as the canonical way to use delegateToJs (see "Stub one script from JavaScript" example): inbound-rules-only with a single connect.facebook.net rule causes the bridge to be injected into the Grailed signup page and wrap every fetch/XHR.

Expected Behavior

The JS bridge (proxy-bridge.js) only has a real effect in legacy mode — it's the only mode that actually puts a regex source into proxyRegexSource. In the new outbound/inbound rules mode, native-side shouldInterceptRequest already sees every WebView request and runs the rule matching itself; the JS bridge is redundant and misbehaving (no filter → wraps everything).

shouldInjectBridge should mirror usesLegacyJsProxyMode, not shouldEnableNativeProxy:

 static boolean shouldInjectBridge(Options options) {
-    return options != null && options.shouldEnableNativeProxy();
+    return options != null && usesLegacyJsProxyMode(options);
 }

After that change:

  • Legacy proxyRequests: true / proxyRequests: "<regex>" keep working exactly as today (their tests should be unaffected).
  • New-API users get rule-based native interception only, with no global fetch/XHR monkey-patching and no per-fetch marker round trip.

Code Reproduction

Minimal repro using the library's own README example:

import { InAppBrowser, addProxyHandler } from '@capgo/capacitor-inappbrowser';

const proxyHandle = await addProxyHandler(async (request) => {
  if (request.phase === 'inbound' && request.url.includes('connect.facebook.net')) {
    return new Response('window.FB = {};', {
      status: 200,
      headers: { 'Content-Type': 'application/javascript; charset=utf-8' },
    });
  }
  return null;
});

await InAppBrowser.openWebView({
  url: 'https://example.com/',
  inboundProxyRules: [
    { urlRegex: '^https://connect\\.facebook\\.net/.*', action: 'delegateToJs' },
  ],
});

// On Android, after the page loads, run in the WebView console:
//   fetch('https://example.com/anything')
// Observe in logcat (tag `InAppBrowserProxy`) that the request was routed via
// `/_capgo_proxy_?u=...&rid=...` and replayed through `performNativeRequest`,
// even though no rule matches `example.com/anything`.
// Also: `window.fetch.toString()` returns the wrapped (non-native) implementation.

Other Technical Details

npm --version output:

node --version output:

pod --version output (iOS issues only): n/a — Android-only bug.

Capacitor: @capacitor/core@^8.3.1, @capacitor/android@^8.3.1.

Additional Context

Suggested one-line fix (also tested locally as a patch):

--- a/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyRequestSupport.java
+++ b/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyRequestSupport.java
@@
 static boolean shouldInjectBridge(Options options) {
-    return options != null && options.shouldEnableNativeProxy();
+    return options != null && usesLegacyJsProxyMode(options);
 }

Rationale: prepareProxyBridgeScript already conditions proxyRegexSource on usesLegacyJsProxyMode, so anything outside legacy mode runs the bridge with a null pattern — clearly unintended. After this change:

  • ProxyBridge/__capgoProxy JavaScript interface is still wired up by shouldEnableNativeProxy (cheap, no script injection).
  • The actual evaluateJavascript(proxyBridgeScript, ...) in onPageStarted only fires when there's a real legacy regex to apply.
  • The new-rules path is entirely native-side via shouldInterceptRequest, which already handles everything correctly through shouldHandleNonBridgeRequest → outbound/inbound rule matching → performNativeRequest.

Happy to open a PR if useful. A regression test would need to drive a real WebView page (not just NativeRequestContext unit tests) so it sees window.fetch.toString() being mutated — likely why this hasn't been caught.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions