Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/Core/src/Handlers/HybridWebView/HybridWebView.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
// Determine the mechanism to receive messages from the host application.
if (window.chrome && window.chrome.webview && window.chrome.webview.addEventListener) {
// Windows WebView2
// The .NET side URL-encodes messages (see MauiHybridWebView.SendRawMessage) so embedded
// NUL characters survive WebView2's null-terminated string marshalling. Decode here.
window.chrome.webview.addEventListener('message', (arg) => {
dispatchHybridWebViewMessage(arg.data);
dispatchHybridWebViewMessage(decodeURIComponent(arg.data));
});
}
else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.webwindowinterop) {
Expand Down Expand Up @@ -56,7 +58,10 @@
// Determine the function to use to send messages to the host application.
if (window.chrome && window.chrome.webview) {
// Windows WebView2
sendMessageFunction = msg => window.chrome.webview.postMessage(msg);
// URL-encode so embedded NUL characters survive WebView2's null-terminated string
// marshalling (TryGetWebMessageAsString returns an LPWSTR); the .NET side decodes it
// in HybridWebViewHandler.OnWebMessageReceived.
sendMessageFunction = msg => window.chrome.webview.postMessage(encodeURIComponent(msg));
}
else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.webwindowinterop) {
// iOS and MacCatalyst WKWebView
Expand Down
9 changes: 7 additions & 2 deletions src/Core/src/Handlers/HybridWebView/HybridWebView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,10 @@ interface DotNetInvokeResult {
// Determine the mechanism to receive messages from the host application.
if (window.chrome && window.chrome.webview && window.chrome.webview.addEventListener) {
// Windows WebView2
// The .NET side URL-encodes messages (see MauiHybridWebView.SendRawMessage) so embedded
// NUL characters survive WebView2's null-terminated string marshalling. Decode here.
window.chrome.webview.addEventListener('message', (arg: any) => {
dispatchHybridWebViewMessage(arg.data);
dispatchHybridWebViewMessage(decodeURIComponent(arg.data));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Malformed/non-string WebView2 message handlingdecodeURIComponent(arg.data) assumes every chrome.webview 'message' event was posted via the new percent-encoding in MauiHybridWebView.SendRawMessage. But this listener fires for any message posted to this CoreWebView2 (e.g. via PostWebMessageAsJson, a non-string payload, or any other app/component code sharing the same WebView2), where arg.data may not be a string or may not be valid percent-encoding. decodeURIComponent throws URIError: malformed URI sequence for invalid % sequences (and coerces non-string arg.data via implicit toString() first, which can also produce invalid sequences); the resulting unhandled exception aborts dispatchHybridWebViewMessage for that message with no fallback, silently breaking hybrid message dispatch for that event. Consider a typeof arg.data === 'string' guard plus a try/catch around the decode, falling back to the raw value on failure. (Same pattern applies to the generated HybridWebView.js at the equivalent line.)

});
} else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.webwindowinterop) {
// iOS and MacCatalyst WKWebView
Expand Down Expand Up @@ -105,7 +107,10 @@ interface DotNetInvokeResult {
// Determine the function to use to send messages to the host application.
if (window.chrome && window.chrome.webview) {
// Windows WebView2
sendMessageFunction = msg => window.chrome.webview.postMessage(msg);
// URL-encode so embedded NUL characters survive WebView2's null-terminated string
// marshalling (TryGetWebMessageAsString returns an LPWSTR); the .NET side decodes it
// in HybridWebViewHandler.OnWebMessageReceived.
sendMessageFunction = msg => window.chrome.webview.postMessage(encodeURIComponent(msg));
} else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.webwindowinterop) {
// iOS and MacCatalyst WKWebView
sendMessageFunction = msg => window.webkit.messageHandlers.webwindowinterop.postMessage(msg);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ public static void MapSendRawMessage(IHybridWebViewHandler handler, IHybridWebVi

private void OnWebMessageReceived(WebView2 sender, CoreWebView2WebMessageReceivedEventArgs args)
{
MessageReceived(args.TryGetWebMessageAsString());
// The JS transport URL-encodes messages so embedded NUL characters survive WebView2's
// null-terminated string marshalling (TryGetWebMessageAsString returns an LPWSTR). Decode
// the payload before dispatching it.
MessageReceived(Uri.UnescapeDataString(args.TryGetWebMessageAsString()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[major] Null Safety — malformed/non-string WebView2 messageargs.TryGetWebMessageAsString() returns null (not an exception) whenever the posted web message isn't a string (e.g. chrome.webview.postMessage({...}), a number, or any payload posted by other script/code sharing this WebView2 — WebMessageReceived is subscribed for the whole CoreWebView2, not just messages from the HybridWebView bridge script). Uri.UnescapeDataString(null) throws ArgumentNullException immediately, before MessageReceived's own null/empty validation (HybridWebViewHandler.cs ~line 121, which previously produced a clear ArgumentException("The raw message cannot be null or empty.")) ever runs. This replaces a well-defined, descriptive validation exception with an earlier, less-clear ArgumentNullException for any non-string message posted on the same WebView2. Guard for null (e.g. args.TryGetWebMessageAsString() is string s ? Uri.UnescapeDataString(s) : null) before dispatching to MessageReceived.

}

internal static void MapFlowDirection(IHybridWebViewHandler handler, IHybridWebView hybridWebView)
Expand Down
83 changes: 78 additions & 5 deletions src/Core/src/Platform/Android/MauiHybridWebViewClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,36 @@ 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
// 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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[major] Cross-Platform Behavioral Consistency — This new IsFrameworkInternalRequest gate (and its ordering ahead of WebRequestInterceptingWebView.TryInterceptResponseStream) is added only for Android. HybridWebViewHandler.Windows.cs (OnWebResourceRequested, ~line 122) and HybridWebViewHandler.iOS.cs (StartUrlSchemeTask, ~line 186) still call TryInterceptResponseStream (app-level WebResourceRequested/startURLSchemeTask interception) before any check for _framework/hybridwebview.js, __hwvInvokeDotNet, or __hwvSendMessage, so an app's request-interception handler can still see, modify, or short-circuit these framework-internal bridge requests on Windows and iOS/MacCatalyst. If shielding these endpoints from app interception is the security/reliability goal here (per the comment's claim this "preserves [an] invariant"), the same reordering is needed on Windows and iOS; otherwise this is an Android-only fix leaving the other two platforms exposed to the same problem.

{
// 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;
}

// 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)
{
Expand All @@ -90,22 +112,67 @@ public override void OnPageFinished(AWebView? view, string? url)
return base.ShouldInterceptRequest(view, request);
}

// 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.
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 <script> load with no header, matched by path;
// - the message/invoke channels must ALSO carry the protocol marker header, because the
// header name/value are public and a same-origin script could otherwise set it on an
// arbitrary URL to bypass interception.
static bool IsFrameworkInternalRequest(string fullUrl, IWebResourceRequest request)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Regression Prevention / Test Coverage — No tests accompany this behavior change (request-interception ordering plus new IsFrameworkInternalRequest/TryGetAppRelativePath helpers), nor the Windows NUL-character encoding change. Per the general guidance to test adjacent scenarios and not just the reported one, this area needs coverage for: (1) an app-registered WebResourceRequested handler can still intercept/override normal static-asset and default-document requests, (2) the same handler can no longer intercept _framework/hybridwebview.js, __hwvInvokeDotNet, or __hwvSendMessage requests when the expected headers are present, (3) a request to the reserved __hwvInvokeDotNet/__hwvSendMessage paths that is missing the expected header is not treated as framework-internal (falls through to normal app-interception handling and eventually 400s), and (4) a raw message containing an embedded NUL character round-trips correctly through the Windows SendRawMessage/OnWebMessageReceived path. None of this is exercised by tests in the diff.

{
if (!TryGetAppRelativePath(fullUrl, out var relativePath) || relativePath is null)
{
return false;
}

if (relativePath == HybridWebViewHandler.HybridWebViewDotJsPath)
{
return true;
}

if (relativePath == HybridWebViewHandler.InvokeDotNetPath ||
relativePath == HybridWebViewHandler.SendMessagePath)
{
return HybridWebViewHandler.HasExpectedHeaders(request.RequestHeaders);
}

return false;
}

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))
if (!TryGetAppRelativePath(fullUrl, out var relativePath))
{
// Not an app-origin request; let it proceed unmodified.
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);
Expand Down Expand Up @@ -148,7 +215,13 @@ public override void OnPageFinished(AWebView? view, string? url)
return error;
}

// Do not wrap this in a try/catch. MessageReceived raises the app-facing message
// handlers (e.g. RawMessageReceived); an exception thrown by app code must be allowed
// to propagate rather than be swallowed, matching how MAUI treats event handlers such
// as Button.Click. Developers who want to handle these exceptions can catch them in
// their own handler.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[critical] Async/Threading Safety — contradictory JNI exception-safety claims — This Handler.MessageReceived(messageBody) call (which synchronously invokes app-facing handlers such as RawMessageReceived) is explicitly left unwrapped per the comment directly above it ("Do not wrap this in a try/catch ... an exception thrown by app code must be allowed to propagate"). But the comment block added at the top of ShouldInterceptRequest (around line 92) claims the opposite: "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." These two added comments directly contradict each other, and the code follows the "do not catch" comment — so the "exception-isolated"/JNI-crash-safety claim is false as shipped. ShouldInterceptRequest is invoked by the native Android WebView engine across a JNI boundary; if an app's RawMessageReceived/message handler throws here, per the PR's own stated risk this can crash the native WebView thread instead of surfacing as a normal managed exception. Either wrap this dispatch (log + isolate, matching the first comment's intent) or correct the misleading first comment to match the actual (propagate) behavior — right now the code's safety characteristics are undocumented/inconsistent and the crash risk the PR itself calls out is left unmitigated for this exact call site.

Handler.MessageReceived(messageBody);

return new WebResourceResponse(null, "UTF-8", 204, "No Content", null, new MemoryStream());
}

Expand Down
5 changes: 4 additions & 1 deletion src/Core/src/Platform/Windows/MauiHybridWebView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ public MauiHybridWebView(HybridWebViewHandler handler)

public void SendRawMessage(string rawMessage)
{
CoreWebView2.PostWebMessageAsString(rawMessage);
// WebView2's PostWebMessageAsString marshals to a null-terminated LPCWSTR, so any embedded
// NUL character would truncate the message. URL-encode the payload so it survives; the JS
// transport decodes it in the WebView2 'message' event listener in hybridwebview.js.
CoreWebView2.PostWebMessageAsString(Uri.EscapeDataString(rawMessage));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Performance-Critical PathUri.EscapeDataString now percent-encodes the entire message on every SendRawMessage call (mirrored by encodeURIComponent on every send in HybridWebView.ts/.js), purely to protect against the rare case of an embedded NUL character. Typical payloads here are JSON (quotes, braces, colons, brackets — none of which are RFC3986-unreserved characters) which will be almost entirely percent-escaped, inflating message size up to ~3x on every JS<->.NET round trip through the Windows WebView2 bridge. For a channel that can be used for frequent/streaming JS<->.NET calls, consider only escaping the NUL character itself (or another narrow-scope transform) rather than fully percent-encoding every message.

}

public async void RunAfterInitialize(Action action)
Expand Down
Loading