The "Missing Body" Mystery: Bundling Webapps, CORS, and Android’s Oldest WebView Bug
In Part 1 I explained how we bundled our webapp and tried to fix circumvent CORS issues with a native proxy, ran into Android Issue #119844519 and finally found a working solution. Or so I thought.
This part is about the aftermath: How I discovered that the fix was only working partially, how I found a real workaround and what I learned about WebView’s request handling in the process.
Current state
We left of last time with a working solution for POST requests: We were able to read the body of the request in shouldInterceptRequest and forward it to the backend. The WebView was happy, the backend was happy, and I was happy.
The solution was as pictured below:
By using Android-Request-Inspector-WebView (in green), which injects JavaScript into the webapp to capture the body of outgoing requests, I was able to work around the missing body issue in shouldInterceptRequest and forward the request, including it's body to the backend.
Here is a more detailed overview of the flow of how Android-Request-Inspector-WebView works:
It injects a JavaScript wrapper around fetch, XMLHttpRequest and from input (green line) that captures the body of outgoing requests. It then sends this body data to the native layer via a JavascriptInterface where it is simply stored first. When the request is intercepted, the native layer matches this body data to the corresponding request in shouldInterceptRequest by URL and merges them together to reconstruct the full request.
This enables us to proxy all requests, including POST requests with bodies and forward the response back to the webapp. It was a neat solution that felt like a hack but worked well in practice.
It also works fine for CORS requests, because the Browser Engine is not affected by the library. It can do the usual CORS handling: send the preflight requests (which also go through the Android-Request-Inspector library but aren't affected by it) and only do the original request if the preflight response fulfills the requirements. Our native proxy doesn't intercept CORS at all right now, but that could be changed in the future.
The Problem: Parallel requests to the Same URL
However, after a few days of testing, I noticed something strange: When the webapp made multiple requests to the same URL in quick succession (e.g. multiple GraphQL queries to the same url), "sometimes" one request came back with an unexpected response.
So what is happening there?
The issue is that the Android-Request-Inspector-WebView library matches the body data to the request in shouldInterceptRequest by URL. If there are multiple requests to the same URL, the library cannot distinguish which body belongs to which request, but just takes the latest. This creates a race condition between matching the first request with its body and storing the body of the second request. So it can lead to a mismatch where the body of the first request is incorrectly associated with the body of the second request, resulting in unexpected behavior. The second request is properly matched with its body.
This is particularly problematic for GraphQL requests, which often use the same endpoint for multiple queries. If two queries are sent to the same URL in quick succession, there is a chance that the body data gets mixed up, leading to incorrect responses from the backend.
The Solution: Matching requests by generated UUID
Since matching by URL is not sufficient, I needed a more robust way to match the body data to the correct request. The solution I implemented was to generate a unique identifier (UUID) for each request in the injected JavaScript and include this UUID in both the request URL and the body data sent to the native layer. Then I was able to match all requests with the proper body.
To add the UUID to the request, I used a custom header x-request-inspector-id to the request before it was passed from the JavaScript to the native layer. The same manipulated request was also send from JavaScript. So now, even if there are multiple requests to the same URL, each request can be uniquely identified and matched with its corresponding body.
Problem solved? Not quite, because it led to further issues.
Now we have CORS issues
Parallel requests to the same URL are now properly matched with their body, but we have a new problem: CORS errors. When the webapp makes a CORS request, the browser engine sees the custom header x-request-inspector-id and treats it as a non-simple request, which triggers the CORS preflight process. The preflight request comes back with a response that does not include the new custom header within the Access-Control-Allow-Headers header. Therefore, the browser engine doesn't send the original request, thinking it contains a disallowed header, and the webapp receives a CORS error.
Cleaning up the request after matching doesn't help here, because the CORS preflight request is triggered by the browser engine before we can intercept, match and clean up the request. So the browser engine already knows about the custom header, even though the request would not contain it later.
How to solve this?
The easiest way to solve this would be to have the library add the custom header only for non-CORS requests. This can be easily done by checking the request URL against the URL where the webapp is loaded from. Only if the origin of both match, the header is added. This way, CORS requests would not be affected by the custom header and would not trigger the preflight process.
This works fine in my case, because the proxy currently ignores CORS requests anyway, but it might not be a good solution for everyone. If the library user wants to proxy CORS requests as well, another way to manipulate the requests without using custom headers is needed.
I solved this by adding the generated UUID as a query parameter to the request URL instead of a custom header. This way, the browser engine does not see any custom headers, so it doesn't block the original request after the preflight response comes back. The library can extract the UUID from the query parameter in shouldInterceptRequest and match it with the corresponding body. This solution works for both CORS and non-CORS requests without any issues.
The downside of this approach is that it modifies the request URL, which is visible in Chrome inspect for example, see Screenshot below. (The custom header solution is also visible there, but a bit more hidden.) However, since the UUID is only used for matching and clean up afterwards, it does not affect the actual request and is completely transparent to the backend.
I actually implemented both solutions, by making the Android-Request-Inspector-WebView library customizable. The users can now choose between different matchers, the original URL matcher as the default one. At the time of this writing, the PR is still in review, but all comments are already closed, and it should be a matter of time until it gets merged.
This was actually a fun problem to solve, because it required a deep understanding of how the Android WebView handles requests and how CORS works in the browser engine. It took me a while to understand why cleaning up the request doesn't fix the CORS issues. It also showed me that even a seemingly simple solution can have unexpected consequences, and that it's important to consider all edge cases when implementing a solution.
Summary
- The Goal: Reliably match intercepted POST request bodies to their corresponding network requests, especially when the webapp makes parallel calls to the exact same endpoint.
- The Blocker: The original JavaScript injection method matched bodies purely by URL, creating race conditions and mismatched payloads when multiple requests (like GraphQL queries) were fired simultaneously.
- The Fix: Generating a unique identifier (UUID) for each request directly in the injected JavaScript to definitively link the intercepted body to the correct native request.
- The Bug: Passing this UUID via a custom header (
x-request-inspector-id) caused the browser engine to flag it as a non-simple request, triggering CORS preflight failures that blocked the network call. - The Workaround: Appending the UUID as a URL query parameter instead of a custom header, which completely bypasses the CORS preflight issue while still allowing the native layer to correctly match and merge the data.