diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ace0af..27c322d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,26 @@
+## 0.1.2
+
+* **Navigation scheme allowlist**: `CefWebView(allowedSchemes: {...})` restricts
+ which URL schemes the page may navigate to — the initial load, programmatic
+ `navigate()`, in-page link clicks, and redirects are all gated in the
+ renderer's `OnBeforeBrowse`. `about:` is always permitted. Pass e.g.
+ `{'http', 'https'}` to keep an untrusted page off `file:` / `data:` /
+ `chrome:` schemes — important when a host can drive navigation
+ programmatically. Default (`null`) preserves the previous allow-all behavior,
+ so this is a non-breaking, opt-in addition. The host's explicit
+ content-injection APIs — `loadHtmlString` (a `data:` URL) and `loadFile` (a
+ `file:` URL) — are exempt from the allowlist, since the host (not the page)
+ chose that content; only navigation (the page's, and `navigate()`) is gated.
+* **Production hardening (build-time, `-DCEF_HOST_ADHOC=OFF`)**: a signed release
+ build now enables the **Chromium renderer/GPU sandbox** (the helper calls
+ `CefScopedSandboxContext` before loading the framework; `settings.no_sandbox`
+ is false), drops the ad-hoc-only Mach-port peer-validation bypass + mock
+ keychain (so cookies encrypt at rest via the real Keychain/OSCrypt), and signs
+ with a stripped entitlements file that omits `get-task-allow`. All of this is
+ off by default (`CEF_HOST_ADHOC=ON`) so dev/CI builds are byte-identical and
+ run unsandboxed under ad-hoc signing; the release posture only *validates*
+ under correct inside-out Developer-ID signing of the `cef_host` tree.
+
## 0.1.1
* **Multi-view host support**: the IME connection now carries
diff --git a/README.md b/README.md
index 97977c2..ee582ec 100644
--- a/README.md
+++ b/README.md
@@ -103,22 +103,37 @@ stay on.
## Security
-`flutter_cef` embeds a full, **non-sandboxed** Chromium that runs arbitrary web
-content with JIT. Treat any page you load as untrusted code. Specifically:
-
-- **Non-sandboxed host with hardened-runtime relaxations** (`disable-library-validation`,
- `allow-jit`, `allow-unsigned-executable-memory` — required by CEF's renderer).
- `get-task-allow` is on for dev and **must be removed + the app notarized** to
- distribute.
-- **Multi-process Mach-port peer validation is disabled** for the process tree
- (the `MACH_PORT_RENDEZVOUS_PEER_VALDATION=0` env var for child inheritance,
- plus `--disable-features=MachPortRendezvousValidatePeerRequirements,`
- `MachPortRendezvousEnforcePeerRequirements` in the browser process) so ad-hoc
- signing works; the production posture is inside-out Developer-ID signing so
- both can be dropped (see below).
-- **JS channel names are validated** as JS identifiers before injection (so a
- channel name can't break out and run script), and **`runJavaScriptReturningResult`
- expects a single expression** from trusted app code.
+`flutter_cef` embeds a full Chromium that runs arbitrary web content with JIT.
+Treat any page you load as untrusted code. The security posture is driven by one
+build flag, `CEF_HOST_ADHOC` (default `ON`):
+
+| | `CEF_HOST_ADHOC=ON` (default, dev/CI) | `CEF_HOST_ADHOC=OFF` (signed release) |
+| --- | --- | --- |
+| Chromium renderer/GPU sandbox | off (`no_sandbox=true`) | **on** — helper calls `CefScopedSandboxContext` |
+| Mach-port peer validation | bypassed (env var + `--disable-features`) | **enforced** |
+| Cookie-at-rest encryption | mock keychain / `password-store=basic` | **real Keychain / OSCrypt** |
+| `get-task-allow` entitlement | present (local debugging) | **absent** (`entitlements.release.plist`) |
+
+The `OFF` posture only *validates* under correct **inside-out Developer-ID
+signing** of the `cef_host` tree (deepest helper → `libcef_sandbox.dylib` + CEF
+framework → host, depth-first, Hardened Runtime + trusted timestamp). Build it
+with `CEF_HOST_ADHOC=OFF CODESIGN_ID="" native/build_cef_host.sh`,
+or — when bundled into a host app — let the app's own signing re-sign the tree
+with those entitlements. Ad-hoc/dev builds run unsandboxed by necessity (the
+sandbox can't validate without proper signing), which is why `ON` is the default.
+
+Other always-on protections:
+
+- **Hardened-runtime relaxations** (`disable-library-validation`, `allow-jit`,
+ `allow-unsigned-executable-memory`) are kept in both entitlements files — CEF's
+ JIT renderer + dlopen'd framework require them.
+- **Navigation scheme allowlist** (`CefWebView(allowedSchemes:)`) — gate which
+ schemes a page may navigate to (main-frame nav, programmatic `navigate()`,
+ clicks, redirects); host content-injection (`loadHtmlString`/`loadFile`) is
+ exempt. Off by default (allow-all).
+- **JS channel names are validated** as JS identifiers before injection, and
+ **`runJavaScriptReturningResult` expects a single expression** from trusted
+ app code.
- **Per-user, per-process CEF cache** (under the 0700 temp dir, not a fixed
world-readable `/tmp` path) and a **randomized control-socket name**.
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 2c09fa0..d986080 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -27,6 +27,11 @@ class BrowserDemo extends StatefulWidget {
class _BrowserDemoState extends State {
static const _startUrl = 'https://flutter.dev';
+ // Demonstrates the navigation scheme allowlist: this view may only navigate
+ // to http(s) (and about:, which is always permitted). Try the "block test"
+ // toolbar button — a file:// navigation is refused in the renderer's
+ // OnBeforeBrowse and the page stays put. Pass `null` to allow every scheme.
+ static const _allowedSchemes = {'http', 'https'};
final CefWebController _controller = CefWebController();
final FocusNode _webFocus = FocusNode(debugLabel: 'web');
final TextEditingController _urlBar = TextEditingController(text: _startUrl);
@@ -133,6 +138,16 @@ and committed text — including emoji — should appear intact.
void _go() => _controller.navigate(_normalize(_urlBar.text.trim()));
+ /// Exercise [_allowedSchemes]: attempt a file:// navigation, which is not in
+ /// the allowlist and so should be refused in the renderer's OnBeforeBrowse —
+ /// the page should NOT change to the file listing.
+ void _tryBlockedScheme() {
+ const blocked = 'file:///etc/hosts';
+ _snack('Navigating to $blocked — should be REFUSED (allowed: '
+ '${_allowedSchemes.join(", ")}). The page should not change.');
+ _controller.navigate(blocked);
+ }
+
String _normalize(String s) => s.isEmpty
? 'about:blank'
: (s.startsWith('http') || s.contains(':') ? s : 'https://$s');
@@ -210,6 +225,11 @@ and committed text — including emoji — should appear intact.
tooltip: 'Load the IME / text-input test page',
onPressed: _loadImeTest,
),
+ IconButton(
+ icon: const Icon(Icons.block),
+ tooltip: 'Try a blocked file:// navigation (allowedSchemes)',
+ onPressed: _tryBlockedScheme,
+ ),
Expanded(
child: TextField(
controller: _urlBar,
@@ -255,6 +275,7 @@ and committed text — including emoji — should appear intact.
url: _startUrl,
controller: _controller,
focusNode: _webFocus,
+ allowedSchemes: _allowedSchemes,
),
),
],
diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock
index 1faf70c..c77b628 100644
--- a/example/macos/Podfile.lock
+++ b/example/macos/Podfile.lock
@@ -1,5 +1,5 @@
PODS:
- - flutter_cef (0.0.1):
+ - flutter_cef (0.1.2):
- FlutterMacOS
- FlutterMacOS (1.0.0)
@@ -14,7 +14,7 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
SPEC CHECKSUMS:
- flutter_cef: 9d5ba4c7173f95ccbd35f4fa32a0aa7b82776146
+ flutter_cef: 923b658c344928eb53d81cf23367973f5737457f
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
diff --git a/example/pubspec.lock b/example/pubspec.lock
index 0162980..508c415 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -76,7 +76,7 @@ packages:
path: ".."
relative: true
source: path
- version: "0.1.1"
+ version: "0.1.2"
flutter_driver:
dependency: transitive
description: flutter
diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart
index 9a52597..7803440 100644
--- a/lib/src/cef_web_controller.dart
+++ b/lib/src/cef_web_controller.dart
@@ -282,6 +282,7 @@ class CefWebController {
required int width,
required int height,
double dpr = 1.0,
+ Set? allowedSchemes,
}) async {
final res = await _channel.invokeMapMethod('create', {
'sessionId': sessionId,
@@ -289,6 +290,8 @@ class CefWebController {
'width': width,
'height': height,
'dpr': dpr,
+ if (allowedSchemes != null && allowedSchemes.isNotEmpty)
+ 'allowedSchemes': allowedSchemes.map((s) => s.toLowerCase()).join(','),
});
textureId = res?['textureId'] as int?;
// Re-register any JS channels added before the session existed, so call
@@ -301,9 +304,20 @@ class CefWebController {
}
/// Navigate the main frame to [url].
+ ///
+ /// Subject to the view's `allowedSchemes` (if set): a navigation to a scheme
+ /// outside the allowlist is refused. Use [loadHtmlString] / [loadFile] for
+ /// trusted local content you want to render regardless of the allowlist.
Future navigate(String url) =>
_channel.invokeMethod('navigate', {'sessionId': sessionId, 'url': url});
+ /// Load host-trusted content, bypassing the navigation scheme allowlist.
+ /// Backs [loadHtmlString] (data:) and [loadFile] (file:): the host explicitly
+ /// chose this content, so it isn't subject to `allowedSchemes` the way page
+ /// navigation and [navigate] are.
+ Future _loadTrusted(String url) => _channel
+ .invokeMethod('loadTrusted', {'sessionId': sessionId, 'url': url});
+
/// Reload the current page.
Future reload() => _send('reload');
@@ -453,14 +467,19 @@ class CefWebController {
/// Load an HTML string. (`baseUrl` is accepted for API familiarity but not yet
/// honoured — relative URLs resolve against the `data:` document.)
+ ///
+ /// Host-trusted content: rendered regardless of the view's `allowedSchemes`.
Future loadHtmlString(String html, {String? baseUrl}) {
final encoded = base64Encode(const Utf8Encoder().convert(html));
- return navigate('data:text/html;charset=utf-8;base64,$encoded');
+ return _loadTrusted('data:text/html;charset=utf-8;base64,$encoded');
}
/// Load a local file by absolute path.
+ ///
+ /// Host-trusted content: rendered regardless of the view's `allowedSchemes`
+ /// (so `file:` need not be in the allowlist to use this).
Future loadFile(String absolutePath) =>
- navigate('file://$absolutePath');
+ _loadTrusted('file://$absolutePath');
/// Set the page content zoom. `level` is a Chromium zoom *level*; the zoom
/// *factor* is `1.2^level` (0 = 100%, 1 ≈ 120%, -1 ≈ 83%).
diff --git a/lib/src/cef_web_view.dart b/lib/src/cef_web_view.dart
index b2d93de..ccbd5b8 100644
--- a/lib/src/cef_web_view.dart
+++ b/lib/src/cef_web_view.dart
@@ -42,6 +42,7 @@ class CefWebView extends StatefulWidget {
this.controller,
this.focusNode,
this.placeholder,
+ this.allowedSchemes,
});
/// Page to load. Changing it on an existing view navigates.
@@ -58,6 +59,21 @@ class CefWebView extends StatefulWidget {
/// Shown until the first frame arrives. Defaults to a dark blank box.
final Widget? placeholder;
+ /// If non-null, the page may only navigate to URLs whose scheme is in this
+ /// set (case-insensitive) — every other navigation, including the initial
+ /// load, programmatic [CefWebController.navigate], in-page clicks, and
+ /// redirects, is refused by the renderer ([CefClient.OnBeforeBrowse]). The
+ /// `about` scheme (e.g. `about:blank`) is always allowed. Use this to keep an
+ /// untrusted page off `file:` / `data:` / `chrome:` etc. — important when a
+ /// host can be driven to navigate the view programmatically. Null (the
+ /// default) allows all schemes, matching a plain browser.
+ ///
+ /// The host's explicit content-injection APIs — [CefWebController.loadHtmlString]
+ /// (a `data:` URL) and [CefWebController.loadFile] (a `file:` URL) — are NOT
+ /// subject to this allowlist: the host chose that content, so it always loads.
+ /// Only navigation (the page's, and [CefWebController.navigate]) is gated.
+ final Set? allowedSchemes;
+
@override
State createState() => _CefWebViewState();
}
@@ -133,7 +149,11 @@ class _CefWebViewState extends State
_creating = true;
try {
final id = await _controller.create(
- url: widget.url, width: w, height: h, dpr: dpr);
+ url: widget.url,
+ width: w,
+ height: h,
+ dpr: dpr,
+ allowedSchemes: widget.allowedSchemes);
_lastSize = size;
if (mounted) setState(() => _textureId = id);
} finally {
diff --git a/macos/Classes/CefWebSession.swift b/macos/Classes/CefWebSession.swift
index ecb52fc..4c9ec89 100644
--- a/macos/Classes/CefWebSession.swift
+++ b/macos/Classes/CefWebSession.swift
@@ -61,6 +61,7 @@ final class CefWebSession: NSObject, FlutterTexture {
private static let opImeCommit: UInt8 = 0x31
private static let opImeCancel: UInt8 = 0x32
private static let opShowDevTools: UInt8 = 0x33
+ private static let opLoadTrusted: UInt8 = 0x34
// Event callbacks (fired off the main thread). The registrar relays each to a
// Dart channel message.
@@ -87,6 +88,7 @@ final class CefWebSession: NSObject, FlutterTexture {
private weak var registry: FlutterTextureRegistry?
private let cefHostPath: String
+ private let allowedSchemes: String // CSV; "" = allow all
private var width: Int
private var height: Int
private let dpr: CGFloat
@@ -107,11 +109,13 @@ final class CefWebSession: NSObject, FlutterTexture {
// acceptAndRead thread exits, so dispose() can join it before freeing state.
init(sessionId: String, url: String, width: Int, height: Int, dpr: CGFloat,
- registry: FlutterTextureRegistry, cefHostPath: String) {
+ allowedSchemes: String = "", registry: FlutterTextureRegistry,
+ cefHostPath: String) {
self.sessionId = sessionId
self.width = max(1, width)
self.height = max(1, height)
self.dpr = dpr
+ self.allowedSchemes = allowedSchemes
self.registry = registry
self.cefHostPath = cefHostPath
super.init()
@@ -151,6 +155,12 @@ final class CefWebSession: NSObject, FlutterTexture {
sendFrame(Self.opNavigate, Array(url.utf8))
}
+ /// A host content-injection load (loadHtmlString -> data:, loadFile -> file:):
+ /// exempt from the navigation scheme allowlist, unlike `navigate`.
+ func loadTrusted(_ url: String) {
+ sendFrame(Self.opLoadTrusted, Array(url.utf8))
+ }
+
func reload() { sendFrame(Self.opReload) }
func stopLoad() { sendFrame(Self.opStop) }
func goBack() { sendFrame(Self.opBack) }
@@ -371,7 +381,7 @@ final class CefWebSession: NSObject, FlutterTexture {
let surfaceId = ioSurface.map { IOSurfaceGetID($0) } ?? 0
let p = Process()
p.executableURL = URL(fileURLWithPath: cefHostPath)
- p.arguments = [
+ var args = [
"--url=\(url)",
"--width=\(width)",
"--height=\(height)",
@@ -379,6 +389,10 @@ final class CefWebSession: NSObject, FlutterTexture {
"--iosurface-id=\(surfaceId)",
"--ipc=\(socketPath)",
]
+ if !allowedSchemes.isEmpty {
+ args.append("--allowed-schemes=\(allowedSchemes)")
+ }
+ p.arguments = args
do {
try p.run()
process = p
diff --git a/macos/Classes/FlutterCefPlugin.swift b/macos/Classes/FlutterCefPlugin.swift
index 0f8c67b..cc5b9d2 100644
--- a/macos/Classes/FlutterCefPlugin.swift
+++ b/macos/Classes/FlutterCefPlugin.swift
@@ -28,6 +28,7 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
switch call.method {
case "create": create(args, result)
case "navigate": navigate(args, result)
+ case "loadTrusted": loadTrusted(args, result)
case "resize": resize(args, result)
case "dispose": destroy(args, result)
case "pointer": pointer(args, result)
@@ -148,10 +149,11 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
let width = a["width"] as? Int ?? 800
let height = a["height"] as? Int ?? 600
let dpr = (a["dpr"] as? Double).map { CGFloat($0) } ?? 1.0
+ let allowedSchemes = a["allowedSchemes"] as? String ?? ""
sessions[sessionId]?.dispose()
let session = CefWebSession(
sessionId: sessionId, url: url, width: width, height: height, dpr: dpr,
- registry: registry, cefHostPath: cefHost)
+ allowedSchemes: allowedSchemes, registry: registry, cefHostPath: cefHost)
session.onCursor = { [weak self] cursor in
self?.emit("cursor", ["sessionId": sessionId, "cursor": cursor])
}
@@ -229,6 +231,15 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
result(nil)
}
+ /// Host content-injection load (loadHtmlString/loadFile) — bypasses the
+ /// navigation scheme allowlist in cef_host.
+ private func loadTrusted(_ a: [String: Any], _ result: @escaping FlutterResult) {
+ if let id = a["sessionId"] as? String, let url = a["url"] as? String {
+ sessions[id]?.loadTrusted(url)
+ }
+ result(nil)
+ }
+
private func resize(_ a: [String: Any], _ result: @escaping FlutterResult) {
if let id = a["sessionId"] as? String, let s = sessions[id] {
s.resize(width: a["width"] as? Int ?? 800, height: a["height"] as? Int ?? 600)
diff --git a/macos/flutter_cef.podspec b/macos/flutter_cef.podspec
index a649d48..acbb104 100644
--- a/macos/flutter_cef.podspec
+++ b/macos/flutter_cef.podspec
@@ -6,7 +6,7 @@
#
Pod::Spec.new do |s|
s.name = 'flutter_cef'
- s.version = '0.1.1'
+ s.version = '0.1.2'
s.summary = 'Live Chromium (CEF) browser as a Flutter Texture (macOS).'
s.description = <<-DESC
Embed a live Chromium browser via CEF off-screen rendering, shown as a Flutter
diff --git a/native/build_cef_host.sh b/native/build_cef_host.sh
index 100fa78..6c81768 100755
--- a/native/build_cef_host.sh
+++ b/native/build_cef_host.sh
@@ -6,7 +6,10 @@
#
# Env: FLUTTER_CEF_CACHE (default ~/.cache/flutter_cef), CODESIGN_ID (default
# ad-hoc; pass a Developer ID / Apple Development identity for standalone use —
-# when bundled into an app, the app's own signing re-signs it).
+# when bundled into an app, the app's own signing re-signs it),
+# CEF_MULTI_PROCESS (default ON; OFF for the single-process fallback),
+# CEF_HOST_ADHOC (default ON; OFF for a signed release — drops the mock
+# keychain + Mach-port peer-validation bypass, so it needs Developer-ID signing).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
@@ -53,8 +56,17 @@ if [ "${CEF_MULTI_PROCESS:-}" = "0" ] || [ "${CEF_MULTI_PROCESS:-}" = "OFF" ]; t
MP_FLAG="-DCEF_MULTI_PROCESS=OFF"
echo "[flutter_cef] single-process build (simpler; first-party content only)"
fi
+# Ad-hoc shortcuts (mock keychain + Mach-port peer-validation bypass) ON by
+# default so a dev/CI build runs without Developer-ID signing. A signed release
+# sets CEF_HOST_ADHOC=OFF: real Keychain + enforced validation, which then
+# require correct inside-out Developer-ID signing of the cef_host tree.
+ADHOC_FLAG="-DCEF_HOST_ADHOC=ON"
+if [ "${CEF_HOST_ADHOC:-}" = "0" ] || [ "${CEF_HOST_ADHOC:-}" = "OFF" ]; then
+ ADHOC_FLAG="-DCEF_HOST_ADHOC=OFF"
+ echo "[flutter_cef] release build: ad-hoc shortcuts OFF (needs Developer-ID signing)"
+fi
cmake -G Ninja -S "$HERE/cef_host" -B "$OUT" \
- -DCEF_ROOT="$CEF_ROOT" -DCODESIGN_ID="$CODESIGN_ID" "$MP_FLAG" >/dev/null
+ -DCEF_ROOT="$CEF_ROOT" -DCODESIGN_ID="$CODESIGN_ID" "$MP_FLAG" "$ADHOC_FLAG" >/dev/null
ninja -C "$OUT" cef_host
echo "[flutter_cef] -> $OUT/cef_host.app"
echo "[flutter_cef] for dev, point the app at it:"
diff --git a/native/cef_host/CMakeLists.txt b/native/cef_host/CMakeLists.txt
index 40a576d..e38f3e1 100644
--- a/native/cef_host/CMakeLists.txt
+++ b/native/cef_host/CMakeLists.txt
@@ -22,7 +22,6 @@ add_subdirectory("${CEF_ROOT}/libcef_dll" libcef_dll_wrapper)
if(NOT DEFINED CODESIGN_ID)
set(CODESIGN_ID "-")
endif()
-set(ENT "${CMAKE_CURRENT_SOURCE_DIR}/entitlements.plist")
# Multi-process is the DEFAULT: it spawns the CEF helper subprocesses (GPU/Viz +
# OnAcceleratedPaint shared-IOSurface render) and isolates renderer/utility
@@ -34,6 +33,27 @@ set(ENT "${CMAKE_CURRENT_SOURCE_DIR}/entitlements.plist")
# simpler single-process build (first-party content only).
option(CEF_MULTI_PROCESS "Spawn CEF helper subprocesses (vs --single-process)" ON)
+# CEF_HOST_ADHOC (default ON): compile in the dev-only Chromium security
+# shortcuts that let cef_host run under an ad-hoc signature — the mock keychain
+# + basic password store (no Keychain prompt on launch) and the Mach-port
+# peer-validation bypass (clears -67030 on the multi-process GPU handoff). A
+# signed release builds with -DCEF_HOST_ADHOC=OFF: the real Keychain via OSCrypt
+# and enforced peer validation, which then require correct inside-out
+# Developer-ID signing of the cef_host tree. Independent of CEF_MULTI_PROCESS.
+option(CEF_HOST_ADHOC "Compile in dev/ad-hoc-only Chromium security shortcuts" ON)
+
+# Codesign entitlements: dev/ad-hoc carries get-task-allow (local debugging); a
+# signed release (CEF_HOST_ADHOC=OFF) uses a stripped variant without it
+# (notarization hard-fails with get-task-allow, and it's a local task-port
+# privilege-escalation vector). Both keep allow-jit / allow-unsigned-executable-
+# memory / disable-library-validation, which the JIT renderer + dlopen'd
+# framework need.
+if(CEF_HOST_ADHOC)
+ set(ENT "${CMAKE_CURRENT_SOURCE_DIR}/entitlements.plist")
+else()
+ set(ENT "${CMAKE_CURRENT_SOURCE_DIR}/entitlements.release.plist")
+endif()
+
# cef_host: the BROWSER process. A .app bundle (CEF needs a real bundle). The CEF
# framework (and, in multi-process, the helper apps) live in Contents/Frameworks.
add_executable(cef_host MACOSX_BUNDLE main.mm)
@@ -46,6 +66,9 @@ target_link_libraries(cef_host libcef_dll_wrapper ${CEF_STANDARD_LIBS}
if(CEF_MULTI_PROCESS)
target_compile_definitions(cef_host PRIVATE CEF_HOST_MULTIPROCESS)
endif()
+if(CEF_HOST_ADHOC)
+ target_compile_definitions(cef_host PRIVATE CEF_HOST_ADHOC)
+endif()
set(APP_DIR "${CMAKE_CURRENT_BINARY_DIR}/cef_host.app")
@@ -70,6 +93,11 @@ if(CEF_MULTI_PROCESS)
SET_EXECUTABLE_TARGET_PROPERTIES(${_helper_target})
add_dependencies(${_helper_target} libcef_dll_wrapper)
target_link_libraries(${_helper_target} libcef_dll_wrapper ${CEF_STANDARD_LIBS})
+ # Helper needs the flag too: it gates the CefScopedSandboxContext init in
+ # process_helper.mm (sandbox on only in a signed -DCEF_HOST_ADHOC=OFF build).
+ if(CEF_HOST_ADHOC)
+ target_compile_definitions(${_helper_target} PRIVATE CEF_HOST_ADHOC)
+ endif()
set_target_properties(${_helper_target} PROPERTIES
MACOSX_BUNDLE_INFO_PLIST "${_helper_plist}"
OUTPUT_NAME "${_helper_output_name}")
diff --git a/native/cef_host/entitlements.plist b/native/cef_host/entitlements.plist
index 264ef77..ee5d828 100644
--- a/native/cef_host/entitlements.plist
+++ b/native/cef_host/entitlements.plist
@@ -7,8 +7,9 @@
executable memory under hardened runtime. Library validation is relaxed
because the framework is loaded by dlopen; when everything is signed with
one identity it would pass anyway, but this keeps ad-hoc/dev builds
- working too. get-task-allow is for local debugging — drop it (and notarize)
- for distribution. -->
+ working too. get-task-allow is for local debugging; the signed-release
+ build drops it automatically by using entitlements.release.plist instead
+ (selected when CEF_HOST_ADHOC=OFF). -->
com.apple.security.cs.allow-jit
com.apple.security.cs.allow-unsigned-executable-memory
com.apple.security.cs.disable-library-validation
diff --git a/native/cef_host/entitlements.release.plist b/native/cef_host/entitlements.release.plist
new file mode 100644
index 0000000..1d47efc
--- /dev/null
+++ b/native/cef_host/entitlements.release.plist
@@ -0,0 +1,18 @@
+
+
+
+
+
+ com.apple.security.cs.allow-jit
+ com.apple.security.cs.allow-unsigned-executable-memory
+ com.apple.security.cs.disable-library-validation
+
+
diff --git a/native/cef_host/main.mm b/native/cef_host/main.mm
index a1a3f17..5f41bbb 100644
--- a/native/cef_host/main.mm
+++ b/native/cef_host/main.mm
@@ -21,6 +21,11 @@
// -DCEF_MULTI_PROCESS=OFF for the simpler single-process fallback (software
// OnPaint, no helpers, no peer validation at all).
//
+// Those Mach-port shortcuts plus a mock keychain are gated behind the
+// CEF_HOST_ADHOC compile flag (ON by default). A signed release builds with
+// -DCEF_HOST_ADHOC=OFF, which enforces peer validation and uses the real
+// Keychain (OSCrypt) — and so requires correct inside-out Developer-ID signing.
+//
// Args: --url= --width= --height= --dpr= --iosurface-id=
// --ipc=
//
@@ -118,6 +123,7 @@
constexpr uint8_t kOpImeCommit = 0x31; // {utf8 text} commit composed text
constexpr uint8_t kOpImeCancel = 0x32; // {} cancel composition
constexpr uint8_t kOpShowDevTools = 0x33; // {} open DevTools in a window
+constexpr uint8_t kOpLoadTrusted = 0x34; // {utf8 url} host content-load, exempt from allowlist
// ---- Shared runtime state ----
int g_ipc_fd = -1;
@@ -131,6 +137,25 @@
CefRefPtr g_browser;
+// Host-set navigation scheme allowlist (lowercased; `--allowed-schemes=a,b`).
+// Empty = allow all. `about` is always allowed (the blank placeholder).
+// Enforced in HostClient::OnBeforeBrowse so it covers the initial load,
+// programmatic navigation (navigate), in-page clicks, and redirects. The host's
+// explicit content-injection APIs (loadHtmlString -> data:, loadFile -> file:)
+// are NOT subject to it — they arrive as kOpLoadTrusted and set the one-shot
+// g_skip_allowlist_once below so their load isn't refused.
+std::set g_allowed_schemes;
+
+// Exact URLs armed for a host-trusted content load (kOpLoadTrusted). The
+// exemption is bound to the specific URL, NOT to a moment in time: LoadURL does
+// not deliver OnBeforeBrowse synchronously (it enqueues the nav; the callback
+// arrives as a later UI task), so a global one-shot flag could be consumed by a
+// page-initiated navigation queued in the gap — an allowlist bypass. Matching on
+// the exact URL (and main frame) in OnBeforeBrowse means a page nav to a
+// different URL can never steal another load's exemption. A multiset tolerates
+// identical concurrent trusted loads. UI-thread only, so no lock.
+std::multiset g_trusted_pending;
+
// Pending JS dialog callbacks, keyed by id. UI-thread-only (OnJSDialog and the
// host's response both run on the CEF UI thread), so no lock is needed.
std::map> g_dialogs;
@@ -701,7 +726,40 @@ void OnBeforeClose(CefRefPtr browser) override {
if (router_) router_->OnBeforeClose(browser);
}
bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame,
- CefRefPtr, bool, bool) override {
+ CefRefPtr request, bool, bool) override {
+ if (!g_allowed_schemes.empty()) {
+ const std::string url = request->GetURL().ToString();
+ // Only gate MAIN-frame navigations. A subframe can't change the view's
+ // top-level origin (it's already same-policy-constrained by Chromium), and
+ // gating subframes would cancel legitimate cross-scheme embeds — blob: /
+ // data: iframes, PDF/video viewers, ad frames — breaking real pages.
+ const bool main_frame = !frame || frame->IsMain();
+ // A host content-injection load (loadHtmlString -> data:, loadFile ->
+ // file:) armed an exact-URL exemption in DoNavigateTrusted. Honor it only
+ // for the matching main-frame request, and consume that one entry, so a
+ // page navigation to a different URL can't steal it. A redirect of a
+ // trusted load carries a different URL and so remains gated.
+ bool host_trusted = false;
+ if (main_frame) {
+ auto it = g_trusted_pending.find(url);
+ if (it != g_trusted_pending.end()) {
+ g_trusted_pending.erase(it);
+ host_trusted = true;
+ }
+ }
+ if (main_frame && !host_trusted) {
+ const size_t colon = url.find(':');
+ std::string scheme =
+ colon == std::string::npos ? std::string() : url.substr(0, colon);
+ std::transform(scheme.begin(), scheme.end(), scheme.begin(),
+ [](unsigned char c) { return std::tolower(c); });
+ // `about:` (blank placeholder) is always allowed; anything else must be
+ // in the host allowlist or the navigation is refused.
+ if (scheme != "about" && g_allowed_schemes.count(scheme) == 0) {
+ return true; // cancel
+ }
+ }
+ }
if (router_) router_->OnBeforeBrowse(browser, frame);
return false; // allow
}
@@ -718,8 +776,14 @@ bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame,
}
void OnBeforeCommandLineProcessing(
const CefString&, CefRefPtr command_line) override {
+#ifdef CEF_HOST_ADHOC
+ // Dev / ad-hoc-only (CEF_HOST_ADHOC is ON by default; a signed release sets
+ // -DCEF_HOST_ADHOC=OFF). Mock keychain + basic password store so a launch
+ // doesn't raise the macOS Keychain access prompt every time. A signed
+ // release omits these and uses the real Keychain via OSCrypt.
command_line->AppendSwitch("use-mock-keychain");
command_line->AppendSwitchWithValue("password-store", "basic");
+#endif
#ifndef CEF_HOST_MULTIPROCESS
// Single-process (-DCEF_MULTI_PROCESS=OFF; NOT the default): renderer + GPU
// + utility all share this process, so there are no Mach-port peers to
@@ -729,17 +793,16 @@ void OnBeforeCommandLineProcessing(
// simpler/first-party content; the default multi-process build isolates
// those crashes.
command_line->AppendSwitch("single-process");
- command_line->AppendSwitchWithValue(
- "disable-features",
- "MachPortRendezvousValidatePeerRequirements,"
- "MachPortRendezvousEnforcePeerRequirements");
-#else
- // Multi-process GPU OSR: keep hardware compositing so the GPU/Viz process
- // produces a shared-texture IOSurface for OnAcceleratedPaint, and disable the
- // Mach-port peer-requirement validation that otherwise -67030s the GPU→
- // browser handoff for an ad-hoc signature. These are the same flags the
- // single-process path uses; together they let the GPU-accelerated path run
- // multi-process (crash-isolated) WITHOUT Developer-ID signing.
+#endif
+#ifdef CEF_HOST_ADHOC
+ // Dev / ad-hoc-only: disable Chromium 144's Mach-port peer-requirement
+ // validation, which otherwise -67030s the multi-process GPU→browser handoff
+ // (OnAcceleratedPaint) under an ad-hoc signature. (Harmless in
+ // single-process, where there are no peers to validate.) Together with the
+ // shared-texture GPU OSR path this lets the accelerated path run
+ // multi-process (crash-isolated) WITHOUT Developer-ID signing. A signed
+ // release omits this and enforces validation, which then requires correct
+ // inside-out Developer-ID signing of the cef_host tree.
command_line->AppendSwitchWithValue(
"disable-features",
"MachPortRendezvousValidatePeerRequirements,"
@@ -808,6 +871,18 @@ void DoNavigate(const std::string& url) {
if (f) f->LoadURL(url);
}
+// A host content-injection load (loadHtmlString -> data:, loadFile -> file:).
+// Runs on the CEF UI thread. Arm an exact-URL exemption so this specific load's
+// OnBeforeBrowse (a later UI task) skips the scheme allowlist, while a page nav
+// to any other URL stays gated. Only arm when an allowlist is actually set —
+// g_allowed_schemes is immutable after startup, so when the feature is off this
+// is a plain navigate and we don't accumulate unconsumed entries. Trusted
+// because the host explicitly chose this content, not the page.
+void DoNavigateTrusted(const std::string& url) {
+ if (!g_allowed_schemes.empty()) g_trusted_pending.insert(url);
+ DoNavigate(url);
+}
+
void DoReload() {
if (g_browser) g_browser->Reload();
}
@@ -1086,6 +1161,11 @@ void IpcReadLoop() {
CefPostTask(TID_UI, base::BindOnce(&DoNavigate, url));
break;
}
+ case kOpLoadTrusted: {
+ std::string url(reinterpret_cast(p), plen);
+ CefPostTask(TID_UI, base::BindOnce(&DoNavigateTrusted, url));
+ break;
+ }
case kOpReload:
CefPostTask(TID_UI, base::BindOnce(&DoReload));
break;
@@ -1275,14 +1355,15 @@ int ConnectUnixSocket(const std::string& path) {
} // namespace
int main(int argc, char* argv[]) {
-#ifdef CEF_HOST_MULTIPROCESS
+#if defined(CEF_HOST_MULTIPROCESS) && defined(CEF_HOST_ADHOC)
// Disable Chromium 144's Mach-port peer-requirement validation for the whole
// process tree. The child processes read this policy from an env var (NOT the
// FeatureList, which isn't up yet when the rendezvous runs), and the browser
// injects it; pre-setting it here makes children inherit kNoValidation (0).
// (Note Chromium's misspelling "VALDATION".) On macOS 26 a failed validation
// TERMINATES children, so without this no paint callback ever fires. This is a
- // dev/CI unblock; the production fix is correct inside-out Developer-ID signing.
+ // dev/CI unblock (ad-hoc only — compiled out of a signed -DCEF_HOST_ADHOC=OFF
+ // release); the production fix is correct inside-out Developer-ID signing.
setenv("MACH_PORT_RENDEZVOUS_PEER_VALDATION", "0", 1);
#endif
CefScopedLibraryLoader library_loader;
@@ -1297,6 +1378,18 @@ int main(int argc, char* argv[]) {
std::string hs = ArgValue(argc, argv, "height");
std::string dprs = ArgValue(argc, argv, "dpr");
std::string sid = ArgValue(argc, argv, "iosurface-id");
+ std::string allowed = ArgValue(argc, argv, "allowed-schemes");
+ for (size_t start = 0; start < allowed.size();) {
+ const size_t comma = allowed.find(',', start);
+ const size_t len =
+ comma == std::string::npos ? std::string::npos : comma - start;
+ std::string s = allowed.substr(start, len);
+ std::transform(s.begin(), s.end(), s.begin(),
+ [](unsigned char c) { return std::tolower(c); });
+ if (!s.empty()) g_allowed_schemes.insert(s);
+ if (comma == std::string::npos) break;
+ start = comma + 1;
+ }
if (url.empty()) url = "about:blank";
if (!ws.empty()) g_width = atoi(ws.c_str());
if (!hs.empty()) g_height = atoi(hs.c_str());
@@ -1327,7 +1420,18 @@ int main(int argc, char* argv[]) {
@autoreleasepool {
[CefHostApplication sharedApplication];
CefSettings settings;
+#ifdef CEF_HOST_ADHOC
+ // Dev / ad-hoc: the Chromium renderer/GPU sandbox is OFF. It only *validates*
+ // under proper Developer-ID signing, so an ad-hoc build must run unsandboxed.
settings.no_sandbox = true;
+#else
+ // Signed release (-DCEF_HOST_ADHOC=OFF): enable the Chromium renderer/GPU
+ // sandbox. The browser process itself is never sandboxed on macOS — only the
+ // helper subprocesses, which call CefScopedSandboxContext (process_helper.mm)
+ // before loading the framework. Requires correct inside-out Developer-ID
+ // signing of the cef_host tree (the libcef_sandbox.dylib + helpers + host).
+ settings.no_sandbox = false;
+#endif
settings.windowless_rendering_enabled = true;
settings.log_severity = LOGSEVERITY_INFO;
// Per-process cache under the per-user (0700) temp dir — NOT a fixed,
diff --git a/native/cef_host/process_helper.mm b/native/cef_host/process_helper.mm
index 86342db..647d2db 100644
--- a/native/cef_host/process_helper.mm
+++ b/native/cef_host/process_helper.mm
@@ -14,6 +14,7 @@
#include "include/cef_app.h"
#include "include/cef_render_process_handler.h"
+#include "include/cef_sandbox_mac.h"
#include "include/cef_v8.h"
#include "include/wrapper/cef_library_loader.h"
#include "include/wrapper/cef_message_router.h"
@@ -58,6 +59,20 @@ bool OnProcessMessageReceived(CefRefPtr browser,
} // namespace
int main(int argc, char* argv[]) {
+#ifndef CEF_HOST_ADHOC
+ // Signed release (-DCEF_HOST_ADHOC=OFF): bring this sub-process into the
+ // Chromium sandbox before anything else. CefScopedSandboxContext dlopens
+ // libcef_sandbox.dylib from the framework Libraries (path resolved relative to
+ // this helper executable) and calls cef_sandbox_initialize. Must run before
+ // LoadInHelper and stay in scope for the process lifetime (it does — main()
+ // blocks in CefExecuteProcess until the process exits). Sandbox enforcement
+ // only validates under proper Developer-ID signing, so it is compiled out of
+ // ad-hoc/dev builds (CEF_HOST_ADHOC), which run unsandboxed.
+ CefScopedSandboxContext sandbox_context;
+ if (!sandbox_context.Initialize(argc, argv)) {
+ return 1;
+ }
+#endif
// Load the CEF framework from cef_host.app/Contents/Frameworks, resolved
// relative to this helper's executable.
CefScopedLibraryLoader library_loader;
diff --git a/pubspec.yaml b/pubspec.yaml
index be4fd58..b564a45 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,6 @@
name: flutter_cef
description: "Embed a live Chromium (CEF) browser as a Flutter Texture on macOS: composites and clips like any widget, with input forwarding, a JS bridge, navigation and page events."
-version: 0.1.1
+version: 0.1.2
homepage: https://github.com/FlutterFlow/flutter_cef
repository: https://github.com/FlutterFlow/flutter_cef
issue_tracker: https://github.com/FlutterFlow/flutter_cef/issues
diff --git a/test/cef_web_controller_test.dart b/test/cef_web_controller_test.dart
index d74e553..7892d28 100644
--- a/test/cef_web_controller_test.dart
+++ b/test/cef_web_controller_test.dart
@@ -38,6 +38,32 @@ void main() {
expect(args['width'], 100);
});
+ test('create forwards allowedSchemes as a lowercased CSV', () async {
+ final c = CefWebController(sessionId: 's-allow');
+ await c.create(
+ url: 'https://example.com',
+ width: 10,
+ height: 10,
+ allowedSchemes: {'HTTP', 'https', 'about'});
+ final args = (log.firstWhere((m) => m.method == 'create').arguments as Map)
+ .cast();
+ expect(args['allowedSchemes'], 'http,https,about');
+ });
+
+ test('create omits allowedSchemes when unset or empty', () async {
+ final c = CefWebController(sessionId: 's-noallow');
+ await c.create(url: 'about:blank', width: 1, height: 1);
+ final none = (log.firstWhere((m) => m.method == 'create').arguments as Map);
+ expect(none.containsKey('allowedSchemes'), isFalse);
+
+ log.clear();
+ await c.create(
+ url: 'about:blank', width: 1, height: 1, allowedSchemes: const {});
+ final empty =
+ (log.firstWhere((m) => m.method == 'create').arguments as Map);
+ expect(empty.containsKey('allowedSchemes'), isFalse);
+ });
+
test('navigate forwards the url for this session', () async {
final c = CefWebController(sessionId: 's2');
await c.navigate('https://flutter.dev');
@@ -202,13 +228,17 @@ void main() {
expect(find['forward'], false);
expect(find['matchCase'], true);
expect(log.any((m) => m.method == 'stopFind'), true);
- // loadHtmlString + loadFile route through navigate with data:/file: urls.
- final navs = log
- .where((m) => m.method == 'navigate')
+ // loadHtmlString + loadFile are host-trusted content loads: they route
+ // through `loadTrusted` (NOT `navigate`), so they bypass the scheme
+ // allowlist with their data:/file: urls.
+ final loads = log
+ .where((m) => m.method == 'loadTrusted')
.map((m) => (m.arguments as Map)['url'] as String)
.toList();
- expect(navs.any((u) => u.startsWith('data:text/html')), true);
- expect(navs, contains('file:///tmp/x.html'));
+ expect(loads.any((u) => u.startsWith('data:text/html')), true);
+ expect(loads, contains('file:///tmp/x.html'));
+ // ...and specifically NOT through the gated navigate path.
+ expect(log.any((m) => m.method == 'navigate'), false);
});
test('findResult event invokes onFindResult', () async {