From a4915d4532448237fd5939efbc92747439fa1341 Mon Sep 17 00:00:00 2001 From: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:20:34 +0530 Subject: [PATCH 1/5] Fixed HybridWebView device test crash --- .../Handlers/HybridWebView/HybridWebView.js | 18 ++- .../Handlers/HybridWebView/HybridWebView.ts | 18 ++- .../HybridWebView/HybridWebViewHandler.cs | 4 +- .../Android/MauiHybridWebViewClient.cs | 104 +++++++++++++++--- 4 files changed, 114 insertions(+), 30 deletions(-) diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebView.js b/src/Core/src/Handlers/HybridWebView/HybridWebView.js index 7e374fa85754..fc570fa6c1ed 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebView.js +++ b/src/Core/src/Handlers/HybridWebView/HybridWebView.js @@ -73,7 +73,10 @@ headers: { 'Content-Type': 'text/plain', 'X-Maui-Invoke-Token': 'HybridWebView', - 'X-Maui-Request-Body': msg + // URL-encode so the payload survives the HTTP header byte-set restriction + // (headers cannot carry CR/LF/NUL or non-Latin-1 characters). The .NET side + // decodes this in MauiHybridWebViewClient.TryValidateBridgeRequest. + 'X-Maui-Request-Body': encodeURIComponent(msg) }, body: msg }).catch(err => { @@ -145,10 +148,10 @@ * @param message The message to send to the .NET host application. */ function sendRawMessage(message) { - // URL-encode the payload so it survives transports that restrict the byte set - // (the Android fetch X-Maui-Request-Body header rejects CR/LF/NUL). Decoded - // on the .NET side in HybridWebViewHandler.MessageReceived. - sendMessageToDotNet('__RawMessage', encodeURIComponent(message)); + // Byte-set-sensitive transports (the Android fetch X-Maui-Request-Body header) are + // handled by encoding the whole message in sendMessageFunction, so no per-message + // encoding is needed here. + sendMessageToDotNet('__RawMessage', message); } /* * Invoke a .NET method on the InvokeJavaScriptTarget instance. @@ -184,7 +187,10 @@ 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Maui-Invoke-Token': 'HybridWebView', - 'X-Maui-Request-Body': message // Some platforms (Android) do not expose the POST body + // Some platforms (Android) do not expose the POST body, so we also send it as a + // header. URL-encode it so it survives the HTTP header byte-set restriction; the + // .NET side decodes this in MauiHybridWebViewClient.TryValidateBridgeRequest. + 'X-Maui-Request-Body': encodeURIComponent(message) }, body: message }); diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebView.ts b/src/Core/src/Handlers/HybridWebView/HybridWebView.ts index 81f57bb8068b..22758e0ca42c 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebView.ts +++ b/src/Core/src/Handlers/HybridWebView/HybridWebView.ts @@ -120,7 +120,10 @@ interface DotNetInvokeResult { headers: { 'Content-Type': 'text/plain', 'X-Maui-Invoke-Token': 'HybridWebView', - 'X-Maui-Request-Body': msg + // URL-encode so the payload survives the HTTP header byte-set restriction + // (headers cannot carry CR/LF/NUL or non-Latin-1 characters). The .NET side + // decodes this in MauiHybridWebViewClient.TryValidateBridgeRequest. + 'X-Maui-Request-Body': encodeURIComponent(msg) }, body: msg }).catch(err => { @@ -197,10 +200,10 @@ interface DotNetInvokeResult { * @param message The message to send to the .NET host application. */ function sendRawMessage(message: string) { - // URL-encode the payload so it survives transports that restrict the byte set - // (the Android fetch X-Maui-Request-Body header rejects CR/LF/NUL). Decoded - // on the .NET side in HybridWebViewHandler.MessageReceived. - sendMessageToDotNet('__RawMessage', encodeURIComponent(message)); + // Byte-set-sensitive transports (the Android fetch X-Maui-Request-Body header) are + // handled by encoding the whole message in sendMessageFunction, so no per-message + // encoding is needed here. + sendMessageToDotNet('__RawMessage', message); } /* @@ -242,7 +245,10 @@ interface DotNetInvokeResult { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Maui-Invoke-Token': 'HybridWebView', - 'X-Maui-Request-Body': message // Some platforms (Android) do not expose the POST body + // Some platforms (Android) do not expose the POST body, so we also send it as a + // header. URL-encode it so it survives the HTTP header byte-set restriction; the + // .NET side decodes this in MauiHybridWebViewClient.TryValidateBridgeRequest. + 'X-Maui-Request-Body': encodeURIComponent(message) }, body: message }); diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs index 77503c435f19..1970e6d0686d 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs @@ -178,9 +178,7 @@ internal void MessageReceived(string rawMessage) } break; case "__RawMessage": - // Payload is URL-encoded in JS (HybridWebView.ts sendRawMessage) so it survives - // transports that restrict the byte set (Android fetch header forbids CR/LF/NUL). - VirtualView?.RawMessageReceived(Uri.UnescapeDataString(messageContent)); + VirtualView?.RawMessageReceived(messageContent); break; default: throw new ArgumentException($"The message type '{messageType}' is not recognized.", nameof(rawMessage)); diff --git a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs index 76f5610c89de..07c08b5f56f0 100644 --- a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs +++ b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs @@ -69,14 +69,37 @@ public override void OnPageFinished(AWebView? view, string? url) if (view is not null && request is not null && !string.IsNullOrEmpty(url)) { - // 1. Check if the app wants to modify or override the request - var response = WebRequestInterceptingWebView.TryInterceptResponseStream(Handler, view, request, url, logger); + // 1. Handle framework-internal bridge endpoints (the JS bridge script and the + // JS <-> .NET message/invoke channels) before user interception. A + // WebResourceRequested handler must never receive — or be able to throw on — + // these internal requests. Before JS -> .NET messages were routed over HTTP + // they were invisible to interception, and they should remain so. + var bridgeResponse = TryGetBridgeResponse(url, request, logger); + if (bridgeResponse is not null) + { + return bridgeResponse; + } + + // 2. Check if the app wants to modify or override the request + WebResourceResponse? response = null; + try + { + response = WebRequestInterceptingWebView.TryInterceptResponseStream(Handler, view, request, url, logger); + } + catch (Exception ex) + { + // ShouldInterceptRequest runs on a native WebView thread; letting a user + // WebResourceRequested handler's exception unwind across the JNI boundary + // crashes the app. Log it and fall back to default handling instead. + logger?.LogError(ex, "A WebResourceRequested handler threw while intercepting {Url}.", url); + } + if (response is not null) { return response; } - // 2. Check if the request is for a local resource + // 3. Check if the request is for a local resource response = GetResponse(url, request, logger); if (response is not null) { @@ -90,9 +113,14 @@ public override void OnPageFinished(AWebView? view, string? url) return base.ShouldInterceptRequest(view, request); } - private WebResourceResponse? GetResponse(string fullUrl, IWebResourceRequest request, ILogger? logger) + // Handles the framework-internal HybridWebView bridge endpoints: the bridge script, + // the JS -> .NET InvokeDotNet channel, and the JS -> .NET message channel. These are + // resolved before user WebResourceRequested interception so app code never sees — or + // can block/crash on — the framework's own control requests. Returns null when the + // request is not one of these internal endpoints. + private WebResourceResponse? TryGetBridgeResponse(string fullUrl, IWebResourceRequest request, ILogger? logger) { - if (Handler is null || Handler is IViewHandler ivh && ivh.VirtualView is null) + if (Handler is null || (Handler is IViewHandler ivh && ivh.VirtualView is null)) { return null; } @@ -103,16 +131,13 @@ public override void OnPageFinished(AWebView? view, string? url) return null; } - logger?.LogDebug("Request for {Url} will be handled by .NET MAUI.", fullUrl); - var relativePath = WebUtils.ResolveRelativePath(HybridWebViewHandler.AppOriginUri, uri); if (relativePath is null) { - logger?.LogDebug("Request for {Url} resolved to an invalid path.", fullUrl); - return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", GetHeaders("text/plain"), new MemoryStream()); + return null; } - // 1.a. Try the special "_framework/hybridwebview.js" path + // The bridge script if (relativePath == HybridWebViewHandler.HybridWebViewDotJsPath) { logger?.LogDebug("Request for {Url} will return the hybrid web view script.", fullUrl); @@ -121,9 +146,11 @@ public override void OnPageFinished(AWebView? view, string? url) { return new WebResourceResponse("application/json", "UTF-8", 200, "OK", GetHeaders("application/json"), jsStream); } + + return null; } - // 1.b. Try special InvokeDotNet path + // The JS -> .NET InvokeDotNet channel if (relativePath == HybridWebViewHandler.InvokeDotNetPath) { logger?.LogDebug("Request for {Url} will be handled by the .NET method invoker.", fullUrl); @@ -138,7 +165,7 @@ public override void OnPageFinished(AWebView? view, string? url) return new WebResourceResponse("application/json", "UTF-8", 200, "OK", GetHeaders("application/json"), responseStream); } - // 1.c. Try the special SendMessage path (JS -> .NET messages). + // The JS -> .NET message channel if (relativePath == HybridWebViewHandler.SendMessagePath) { logger?.LogDebug("Request for {Url} will be handled by the .NET message receiver.", fullUrl); @@ -148,11 +175,52 @@ public override void OnPageFinished(AWebView? view, string? url) return error; } - Handler.MessageReceived(messageBody); + try + { + Handler.MessageReceived(messageBody); + } + catch (Exception ex) + { + // ShouldInterceptRequest runs on a native WebView thread; letting an exception + // unwind across the JNI boundary crashes the app (and breaks in the debugger). + // Log it and return an error response instead. + logger?.LogError(ex, "SendMessage handler threw while processing a JS -> .NET message."); + return new WebResourceResponse(null, "UTF-8", 500, "Internal Server Error", null, new MemoryStream()); + } + return new WebResourceResponse(null, "UTF-8", 204, "No Content", null, new MemoryStream()); } - // 2. If nothing found yet, try to get static content from the asset path + return null; + } + + private WebResourceResponse? GetResponse(string fullUrl, IWebResourceRequest request, ILogger? logger) + { + if (Handler is null || Handler is IViewHandler ivh && ivh.VirtualView is null) + { + return null; + } + + var requestUri = WebUtils.RemovePossibleQueryString(fullUrl); + if (new Uri(requestUri) is not Uri uri || !HybridWebViewHandler.AppOriginUri.IsBaseOf(uri)) + { + return null; + } + + logger?.LogDebug("Request for {Url} will be handled by .NET MAUI.", fullUrl); + + var relativePath = WebUtils.ResolveRelativePath(HybridWebViewHandler.AppOriginUri, uri); + if (relativePath is null) + { + logger?.LogDebug("Request for {Url} resolved to an invalid path.", fullUrl); + return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", GetHeaders("text/plain"), new MemoryStream()); + } + + // The framework-internal bridge endpoints (bridge script, InvokeDotNet and + // SendMessage channels) are handled earlier in ShouldInterceptRequest via + // TryGetBridgeResponse, so by this point only static app content remains. + + // Try to get static content from the asset path string? contentType; if (string.IsNullOrEmpty(relativePath)) { @@ -188,7 +256,8 @@ public override void OnPageFinished(AWebView? view, string? url) // Validates a POST-only bridge request that carries its body in the // X-Maui-Request-Body header (Android does not expose the POST body to - // ShouldInterceptRequest). On success returns true with the body in `body`; + // ShouldInterceptRequest). The header value is URL-encoded by the JS transport + // and decoded here. On success returns true with the decoded body in `body`; // on failure returns false with the appropriate error response in `errorResponse`. private static bool TryValidateBridgeRequest(IWebResourceRequest request, string endpointName, ILogger? logger, [NotNullWhen(true)] out string? body, [NotNullWhen(false)] out WebResourceResponse? errorResponse) { @@ -216,6 +285,11 @@ private static bool TryValidateBridgeRequest(IWebResourceRequest request, string return false; } + // The JS transport URL-encodes the header value so it survives the HTTP header byte-set + // restriction (headers cannot carry CR/LF/NUL or non-Latin-1 characters). Decode it here + // once for both the InvokeDotNet and SendMessage endpoints. + body = Uri.UnescapeDataString(body); + errorResponse = null; return true; } From 8d9a6d7762d3688e141c82a783b0fe588db0b04b Mon Sep 17 00:00:00 2001 From: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:43:21 +0530 Subject: [PATCH 2/5] Update MauiHybridWebViewClient.cs --- .../Android/MauiHybridWebViewClient.cs | 147 ++++++++++-------- 1 file changed, 79 insertions(+), 68 deletions(-) diff --git a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs index 07c08b5f56f0..1660d7d5dd4d 100644 --- a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs +++ b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs @@ -69,31 +69,30 @@ public override void OnPageFinished(AWebView? view, string? url) if (view is not null && request is not null && !string.IsNullOrEmpty(url)) { - // 1. Handle framework-internal bridge endpoints (the JS bridge script and the - // JS <-> .NET message/invoke channels) before user interception. A - // WebResourceRequested handler must never receive — or be able to throw on — - // these internal requests. Before JS -> .NET messages were routed over HTTP - // they were invisible to interception, and they should remain so. - var bridgeResponse = TryGetBridgeResponse(url, request, logger); - if (bridgeResponse is not null) + // 1. Framework-internal bridge requests must be handled by the framework and + // never exposed to app-level WebResourceRequested interception. See + // IsFrameworkInternalRequest: each reserved endpoint is bound to BOTH its + // well-known path and (for the message/invoke channels) the protocol marker + // header, so the header alone is never a trust boundary. Before JS -> .NET + // messages were routed over HTTP they were invisible to app interception, and + // this preserves that invariant. + if (IsFrameworkInternalRequest(url, request)) { - return bridgeResponse; - } - - // 2. Check if the app wants to modify or override the request - WebResourceResponse? response = null; - try - { - response = WebRequestInterceptingWebView.TryInterceptResponseStream(Handler, view, request, url, logger); - } - catch (Exception ex) - { - // ShouldInterceptRequest runs on a native WebView thread; letting a user - // WebResourceRequested handler's exception unwind across the JNI boundary - // crashes the app. Log it and fall back to default handling instead. - logger?.LogError(ex, "A WebResourceRequested handler threw while intercepting {Url}.", url); + // A framework-internal request must be handled by the framework. If it + // cannot be resolved, fail fast with a 400 rather than forwarding it to the + // app handler. + return GetResponse(url, request, logger) + ?? new WebResourceResponse(null, "UTF-8", 400, "Bad Request", null, new MemoryStream()); } + // 2. Check if the app wants to modify or override the request. This path is + // intentionally left unwrapped: if a user WebResourceRequested handler throws + // for a legitimate app-origin request, the exception propagates exactly as it + // did before bridge traffic was routed over HTTP. Only the framework's own + // .NET dispatch (Handler.MessageReceived in GetResponse) is exception-isolated, + // because it runs under a JNI stack where an unhandled throw crashes the + // native WebView thread. + var response = WebRequestInterceptingWebView.TryInterceptResponseStream(Handler, view, request, url, logger); if (response is not null) { return response; @@ -113,31 +112,74 @@ public override void OnPageFinished(AWebView? view, string? url) return base.ShouldInterceptRequest(view, request); } - // Handles the framework-internal HybridWebView bridge endpoints: the bridge script, - // the JS -> .NET InvokeDotNet channel, and the JS -> .NET message channel. These are - // resolved before user WebResourceRequested interception so app code never sees — or - // can block/crash on — the framework's own control requests. Returns null when the - // request is not one of these internal endpoints. - private WebResourceResponse? TryGetBridgeResponse(string fullUrl, IWebResourceRequest request, ILogger? logger) + // Resolves the app-origin-relative path for a request URL. Returns false when the URL is + // not under the HybridWebView app origin; returns true otherwise, with relativePath set to + // the resolved path (which may itself be null if the path could not be resolved). Shared by + // IsFrameworkInternalRequest and GetResponse to keep the URI parsing in one place. + private static bool TryGetAppRelativePath(string fullUrl, out string? relativePath) + { + relativePath = null; + + var requestUri = WebUtils.RemovePossibleQueryString(fullUrl); + if (new Uri(requestUri) is not Uri uri || !HybridWebViewHandler.AppOriginUri.IsBaseOf(uri)) + { + return false; + } + + relativePath = WebUtils.ResolveRelativePath(HybridWebViewHandler.AppOriginUri, uri); + return true; + } + + // Returns true when the request targets a reserved HybridWebView bridge endpoint and must + // therefore be handled by the framework instead of being exposed to app-level + // WebResourceRequested interception. Each endpoint is bound to its well-known path: + // - the bridge bootstrap script is a plain