From 581ac93e7a12fd9a4e7107e8bc5fccffd944ea2e Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Tue, 9 Jun 2026 14:49:07 -0700 Subject: [PATCH 1/6] feat: navigation scheme allowlist (CefWebView.allowedSchemes) Host-settable allow-list of URL schemes the page may navigate to, enforced in the renderer's OnBeforeBrowse so it covers the initial load, programmatic navigate(), in-page link clicks, and redirects. `about:` is always permitted; default (null) preserves allow-all, so this is opt-in and non-breaking. Plumbed end-to-end: CefWebView(allowedSchemes:) -> controller.create lowercased CSV -> FlutterCefPlugin -> CefWebSession --allowed-schemes= argv -> cef_host g_allowed_schemes. Lets an embedder keep an untrusted page off file:/data:/chrome:, which matters when the host can drive navigation programmatically. Bumps to 0.1.2. cef_host rebuilt clean (0 errors); 94 Dart tests pass. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 ++++++++++ lib/src/cef_web_controller.dart | 3 +++ lib/src/cef_web_view.dart | 17 +++++++++++++- macos/Classes/CefWebSession.swift | 11 ++++++++-- macos/Classes/FlutterCefPlugin.swift | 3 ++- macos/flutter_cef.podspec | 2 +- native/cef_host/main.mm | 33 +++++++++++++++++++++++++++- pubspec.yaml | 2 +- test/cef_web_controller_test.dart | 26 ++++++++++++++++++++++ 9 files changed, 101 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ace0af..97a9035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 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. + ## 0.1.1 * **Multi-view host support**: the IME connection now carries diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index 9a52597..5a6aaaf 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 diff --git a/lib/src/cef_web_view.dart b/lib/src/cef_web_view.dart index b2d93de..44e47d2 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,16 @@ 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. + final Set? allowedSchemes; + @override State createState() => _CefWebViewState(); } @@ -133,7 +144,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..c28dc30 100644 --- a/macos/Classes/CefWebSession.swift +++ b/macos/Classes/CefWebSession.swift @@ -87,6 +87,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 +108,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() @@ -371,7 +374,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 +382,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..73919aa 100644 --- a/macos/Classes/FlutterCefPlugin.swift +++ b/macos/Classes/FlutterCefPlugin.swift @@ -148,10 +148,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]) } 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/cef_host/main.mm b/native/cef_host/main.mm index a1a3f17..5035ef8 100644 --- a/native/cef_host/main.mm +++ b/native/cef_host/main.mm @@ -131,6 +131,12 @@ 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, in-page clicks, and redirects. +std::set g_allowed_schemes; + // 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 +707,20 @@ 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(); + 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 } @@ -1297,6 +1316,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()); 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..6221750 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'); From a7bc7755e5d50b8fd4fb7da6db8f38aa90ad38c7 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Tue, 9 Jun 2026 14:59:09 -0700 Subject: [PATCH 2/6] feat: gate ad-hoc-only CEF security shortcuts behind CEF_HOST_ADHOC The mock keychain + basic password store and the Mach-port peer-validation bypass are dev/ad-hoc conveniences that must not ship in a signed release. Put all three behind a new CEF_HOST_ADHOC compile flag (ON by default, so dev/CI builds stay byte-identical). A signed release builds with -DCEF_HOST_ADHOC=OFF: real Keychain via OSCrypt + enforced Mach-port peer validation, which then require correct inside-out Developer-ID signing of the cef_host tree. - CMakeLists: option(CEF_HOST_ADHOC ON) + target_compile_definitions. - main.mm: #ifdef-gate the command-line switches and the setenv bypass; the single-process switch (not a security shortcut) stays ungated. - build_cef_host.sh: honors CEF_HOST_ADHOC=OFF and documents it. Verified both paths compile clean (ON and OFF, 0 errors). Co-Authored-By: Claude Fable 5 --- native/build_cef_host.sh | 16 +++++++++++++-- native/cef_host/CMakeLists.txt | 12 +++++++++++ native/cef_host/main.mm | 37 ++++++++++++++++++++++------------ 3 files changed, 50 insertions(+), 15 deletions(-) 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..c86e0ca 100644 --- a/native/cef_host/CMakeLists.txt +++ b/native/cef_host/CMakeLists.txt @@ -34,6 +34,15 @@ 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) + # 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 +55,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") diff --git a/native/cef_host/main.mm b/native/cef_host/main.mm index 5035ef8..2ddc515 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= // @@ -737,8 +742,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 @@ -748,17 +759,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," @@ -1294,14 +1304,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; From f53d4375543f7d6f571c037222de86baf2f2a580 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Tue, 9 Jun 2026 15:20:24 -0700 Subject: [PATCH 3/6] fix: exempt loadHtmlString/loadFile from the navigation scheme allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CEF implements loadHtmlString as a data: URL navigation and loadFile as a file: one — both funnel through navigate() -> kOpNavigate -> OnBeforeBrowse. So a `{http, https}` allowlist refused the host's own content-injection APIs (the data:/file: load was cancelled), e.g. loadHtmlString stopped rendering. Give those host-trusted loads a dedicated path that bypasses the allowlist: new kOpLoadTrusted opcode -> DoNavigateTrusted sets a one-shot g_skip_allowlist_once immediately before LoadURL, consumed by the next OnBeforeBrowse (set + read on the same CEF UI thread, so no race). navigate(), the initial load, in-page clicks, and redirects stay gated. The host chose loadHtmlString/loadFile content, so it isn't subject to the page allowlist. Plumbed end-to-end: loadHtmlString/loadFile -> _loadTrusted -> 'loadTrusted' channel method -> CefWebSession.loadTrusted -> kOpLoadTrusted. Dartdoc + CHANGELOG document the exemption; the controller test now asserts they route through loadTrusted (NOT the gated navigate). analyze clean; 94 tests pass; cef_host rebuilt clean. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 ++++- lib/src/cef_web_controller.dart | 20 +++++++++++++++-- lib/src/cef_web_view.dart | 5 +++++ macos/Classes/CefWebSession.swift | 7 ++++++ macos/Classes/FlutterCefPlugin.swift | 10 +++++++++ native/cef_host/main.mm | 33 ++++++++++++++++++++++++++-- test/cef_web_controller_test.dart | 14 +++++++----- 7 files changed, 84 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a9035..b5ae436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ `{'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. + 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. ## 0.1.1 diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index 5a6aaaf..7803440 100644 --- a/lib/src/cef_web_controller.dart +++ b/lib/src/cef_web_controller.dart @@ -304,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'); @@ -456,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 44e47d2..ccbd5b8 100644 --- a/lib/src/cef_web_view.dart +++ b/lib/src/cef_web_view.dart @@ -67,6 +67,11 @@ class CefWebView extends StatefulWidget { /// 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 diff --git a/macos/Classes/CefWebSession.swift b/macos/Classes/CefWebSession.swift index c28dc30..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. @@ -154,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) } diff --git a/macos/Classes/FlutterCefPlugin.swift b/macos/Classes/FlutterCefPlugin.swift index 73919aa..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) @@ -230,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/native/cef_host/main.mm b/native/cef_host/main.mm index 2ddc515..60213e4 100644 --- a/native/cef_host/main.mm +++ b/native/cef_host/main.mm @@ -123,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; @@ -139,9 +140,17 @@ // 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, in-page clicks, and redirects. +// 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; +// One-shot bypass for a host-trusted content load (kOpLoadTrusted). Set on the +// CEF UI thread immediately before LoadURL and consumed by the very next +// OnBeforeBrowse (also UI thread), so there is no cross-thread race. +bool g_skip_allowlist_once = false; + // 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; @@ -713,7 +722,13 @@ void OnBeforeClose(CefRefPtr browser) override { } bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame, CefRefPtr request, bool, bool) override { - if (!g_allowed_schemes.empty()) { + // A host content-injection load (loadHtmlString/loadFile) sets this one-shot + // flag right before its LoadURL; consume it here so its data:/file: URL is + // not refused by the page allowlist. Every other navigation — navigate(), + // the initial load, in-page clicks, redirects — is gated below. + const bool host_trusted = g_skip_allowlist_once; + g_skip_allowlist_once = false; + if (!host_trusted && !g_allowed_schemes.empty()) { const std::string url = request->GetURL().ToString(); const size_t colon = url.find(':'); std::string scheme = @@ -837,6 +852,15 @@ 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; set the one-shot bypass right before LoadURL so the +// load's OnBeforeBrowse (next UI task) skips the scheme allowlist. Trusted +// because the host explicitly chose this content, not the page. +void DoNavigateTrusted(const std::string& url) { + g_skip_allowlist_once = true; + DoNavigate(url); +} + void DoReload() { if (g_browser) g_browser->Reload(); } @@ -1115,6 +1139,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; diff --git a/test/cef_web_controller_test.dart b/test/cef_web_controller_test.dart index 6221750..7892d28 100644 --- a/test/cef_web_controller_test.dart +++ b/test/cef_web_controller_test.dart @@ -228,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 { From f7f8c681f8691ff05f2e501fd57b8070c99b7068 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Tue, 9 Jun 2026 15:20:31 -0700 Subject: [PATCH 4/6] example: demo the navigation scheme allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock the demo browser's CefWebView to allowedSchemes: {http, https} and add a toolbar "block test" button that attempts a file:// navigation — which the renderer refuses (the page stays put), while loadHtmlString (the IME test page) still loads since it is host-trusted content. Exercises the allowlist + the content-load exemption end-to-end. Co-Authored-By: Claude Fable 5 --- example/lib/main.dart | 21 +++++++++++++++++++++ example/macos/Podfile.lock | 4 ++-- example/pubspec.lock | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) 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 From 3b62e2b0bfbb6c1c994a40571a7b26a0d00fa5c0 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Tue, 9 Jun 2026 15:43:32 -0700 Subject: [PATCH 5/6] fix(security): bind trusted-load exemption to exact URL + gate main-frame only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial audit of the allowlist found two real issues in the prior trusted-load fix: HIGH — the one-shot g_skip_allowlist_once was stealable. LoadURL does not invoke OnBeforeBrowse synchronously; it enqueues the navigation and the callback arrives as a later UI task. So the global flag stayed armed across a gap, and a page-initiated navigation to a blocked scheme already queued (setTimeout location change, meta-refresh, pending redirect) could have its OnBeforeBrowse run first and consume the host's exemption — a full allowlist bypass. Replace the bool with g_trusted_pending (a multiset of exact URLs): DoNavigateTrusted arms the specific URL, OnBeforeBrowse exempts ONLY a matching main-frame request and consumes that one entry. A page nav to a different URL can't steal it; a redirect of a trusted load carries a different URL and stays gated. Only arm when an allowlist is set (immutable after startup) so the set doesn't accumulate when the feature is off. MEDIUM — the gate ran on every frame, so an {http,https} allowlist would cancel legit cross-scheme SUBframes (blob:/data: iframes, PDF/video viewers, ad frames), breaking real pages. Gate main-frame navigations only; subframes can't change the view's top-level origin and are already policy-constrained by Chromium. The popup path (OnBeforePopup) was checked and is correctly suppressed (never auto-navigates), so it is not a hole. cef_host rebuilt clean. Co-Authored-By: Claude Fable 5 --- native/cef_host/main.mm | 68 +++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/native/cef_host/main.mm b/native/cef_host/main.mm index 60213e4..aa9f1d4 100644 --- a/native/cef_host/main.mm +++ b/native/cef_host/main.mm @@ -146,10 +146,15 @@ // g_skip_allowlist_once below so their load isn't refused. std::set g_allowed_schemes; -// One-shot bypass for a host-trusted content load (kOpLoadTrusted). Set on the -// CEF UI thread immediately before LoadURL and consumed by the very next -// OnBeforeBrowse (also UI thread), so there is no cross-thread race. -bool g_skip_allowlist_once = false; +// 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. @@ -722,23 +727,37 @@ void OnBeforeClose(CefRefPtr browser) override { } bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame, CefRefPtr request, bool, bool) override { - // A host content-injection load (loadHtmlString/loadFile) sets this one-shot - // flag right before its LoadURL; consume it here so its data:/file: URL is - // not refused by the page allowlist. Every other navigation — navigate(), - // the initial load, in-page clicks, redirects — is gated below. - const bool host_trusted = g_skip_allowlist_once; - g_skip_allowlist_once = false; - if (!host_trusted && !g_allowed_schemes.empty()) { + if (!g_allowed_schemes.empty()) { const std::string url = request->GetURL().ToString(); - 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 + // 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); @@ -853,11 +872,14 @@ void DoNavigate(const std::string& url) { } // A host content-injection load (loadHtmlString -> data:, loadFile -> file:). -// Runs on the CEF UI thread; set the one-shot bypass right before LoadURL so the -// load's OnBeforeBrowse (next UI task) skips the scheme allowlist. Trusted +// 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) { - g_skip_allowlist_once = true; + if (!g_allowed_schemes.empty()) g_trusted_pending.insert(url); DoNavigate(url); } From 49265b04695aa20292bd015c7e2c3f4cddeaa265 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Tue, 9 Jun 2026 15:48:03 -0700 Subject: [PATCH 6/6] feat(security): enable CEF renderer sandbox + release entitlements (CEF_HOST_ADHOC=OFF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the signed-release build production-hardened, driven by the existing CEF_HOST_ADHOC flag so one switch flips the whole dev<->release posture. When CEF_HOST_ADHOC=OFF (signed release): - Chromium renderer/GPU sandbox ON: each helper subprocess initializes CefScopedSandboxContext (dlopens libcef_sandbox.dylib, calls cef_sandbox_initialize) before LoadInHelper, per cef_library_loader.h; the browser sets settings.no_sandbox = false. The browser process is never sandboxed on macOS — only the helpers, which is the canonical model. - Codesign uses entitlements.release.plist, which omits get-task-allow (notarization hard-fails with it; it's a local task-port priv-esc). JIT / unsigned-exec-memory / disable-library-validation are kept (CEF needs them). - (already) Mach-port bypass + mock keychain compiled out. Default CEF_HOST_ADHOC=ON is unchanged: unsandboxed, dev entitlements with get-task-allow — byte-identical dev/CI build, since the sandbox can't validate without proper Developer-ID signing. CMake passes CEF_HOST_ADHOC to the helper targets (gates process_helper.mm) and selects the entitlements file by flag. No static lib / no dist change — the minimal dist ships libcef_sandbox.dylib + the scoped-context wrapper. Verified: both configs compile clean (0 errors). The OFF artifact's helper signature carries the JIT entitlements with NO get-task-allow, links CefScopedSandboxContext, and bundles libcef_sandbox.dylib — a signing-ready production bundle. README security section + CHANGELOG updated. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 9 +++++ README.md | 47 ++++++++++++++-------- native/cef_host/CMakeLists.txt | 18 ++++++++- native/cef_host/entitlements.plist | 5 ++- native/cef_host/entitlements.release.plist | 18 +++++++++ native/cef_host/main.mm | 11 +++++ native/cef_host/process_helper.mm | 15 +++++++ 7 files changed, 104 insertions(+), 19 deletions(-) create mode 100644 native/cef_host/entitlements.release.plist diff --git a/CHANGELOG.md b/CHANGELOG.md index b5ae436..27c322d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ 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 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/native/cef_host/CMakeLists.txt b/native/cef_host/CMakeLists.txt index c86e0ca..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 @@ -43,6 +42,18 @@ option(CEF_MULTI_PROCESS "Spawn CEF helper subprocesses (vs --single-process)" O # 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) @@ -82,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 aa9f1d4..5f41bbb 100644 --- a/native/cef_host/main.mm +++ b/native/cef_host/main.mm @@ -1420,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;