diff --git a/modules/ensemble/lib/widget/webview/native/webviewstate.dart b/modules/ensemble/lib/widget/webview/native/webviewstate.dart index a474b6924..7237a4b3a 100644 --- a/modules/ensemble/lib/widget/webview/native/webviewstate.dart +++ b/modules/ensemble/lib/widget/webview/native/webviewstate.dart @@ -6,6 +6,7 @@ import 'package:ensemble/framework/event.dart'; import 'package:ensemble/framework/widget/widget.dart'; import 'package:ensemble/screen_controller.dart'; import 'package:ensemble/widget/webview/webview.dart'; +import 'package:ensemble/widget/webview/webview_javascript_bridge_security.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -205,7 +206,16 @@ class WebViewState extends EWidgetState with CookieMethods { for (var channel in widget.controller.javascriptChannels) { controller.addJavaScriptHandler( handlerName: channel.name, - callback: (args) { + callback: (args) async { + final currentUrl = await controller.getUrl(); + final allowedOrigin = + webViewAllowedOriginFromUrl(widget.controller.url); + if (!isAllowedWebViewJavaScriptMessageOrigin( + messageOrigin: currentUrl?.origin, + allowedOrigin: allowedOrigin, + )) { + return null; + } if (channel.onMessageReceived != null) { String message = ''; if (args.isNotEmpty) { diff --git a/modules/ensemble/lib/widget/webview/webview_javascript_bridge_security.dart b/modules/ensemble/lib/widget/webview/webview_javascript_bridge_security.dart new file mode 100644 index 000000000..7c2a331dc --- /dev/null +++ b/modules/ensemble/lib/widget/webview/webview_javascript_bridge_security.dart @@ -0,0 +1,35 @@ +import 'package:meta/meta.dart'; + +/// Returns true when [messageOrigin] matches [allowedOrigin]. +/// +/// Used by native [InAppWebView] JavaScript channel handlers to mirror the +/// origin check already applied to web iframe `postMessage` handling. +@visibleForTesting +bool isAllowedWebViewJavaScriptMessageOrigin({ + required String? messageOrigin, + required String? allowedOrigin, +}) { + if (messageOrigin == null || allowedOrigin == null) { + return false; + } + if (messageOrigin.isEmpty || allowedOrigin.isEmpty) { + return false; + } + return messageOrigin == allowedOrigin; +} + +/// Derives the allowed postMessage / JS-bridge origin from the configured +/// WebView [url]. Returns null for missing or non-http(s) URLs. +@visibleForTesting +String? webViewAllowedOriginFromUrl(String? url) { + final uri = Uri.tryParse(url ?? ''); + if (uri == null || !uri.hasScheme || !uri.hasAuthority) { + return null; + } + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'http' && scheme != 'https') { + return null; + } + final origin = uri.origin; + return origin.isEmpty ? null : origin; +} diff --git a/modules/ensemble/test/webview_javascript_bridge_security_test.dart b/modules/ensemble/test/webview_javascript_bridge_security_test.dart new file mode 100644 index 000000000..8363950d4 --- /dev/null +++ b/modules/ensemble/test/webview_javascript_bridge_security_test.dart @@ -0,0 +1,56 @@ +import 'package:ensemble/widget/webview/webview_javascript_bridge_security.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('webViewAllowedOriginFromUrl', () { + test('returns origin for https URLs', () { + expect( + webViewAllowedOriginFromUrl('https://app.example.com/path'), + 'https://app.example.com', + ); + }); + + test('returns null for missing or non-http(s) schemes', () { + expect(webViewAllowedOriginFromUrl(null), isNull); + expect(webViewAllowedOriginFromUrl(''), isNull); + expect(webViewAllowedOriginFromUrl('file:///etc/passwd'), isNull); + expect(webViewAllowedOriginFromUrl('not-a-url'), isNull); + }); + }); + + group('isAllowedWebViewJavaScriptMessageOrigin', () { + test('allows matching origins', () { + expect( + isAllowedWebViewJavaScriptMessageOrigin( + messageOrigin: 'https://trusted.example', + allowedOrigin: 'https://trusted.example', + ), + isTrue, + ); + }); + + test('rejects cross-origin and empty values', () { + expect( + isAllowedWebViewJavaScriptMessageOrigin( + messageOrigin: 'https://evil.example', + allowedOrigin: 'https://trusted.example', + ), + isFalse, + ); + expect( + isAllowedWebViewJavaScriptMessageOrigin( + messageOrigin: 'https://evil.example', + allowedOrigin: null, + ), + isFalse, + ); + expect( + isAllowedWebViewJavaScriptMessageOrigin( + messageOrigin: '', + allowedOrigin: 'https://trusted.example', + ), + isFalse, + ); + }); + }); +}