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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
47 changes: 31 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<Developer 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**.

Expand Down
21 changes: 21 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class BrowserDemo extends StatefulWidget {

class _BrowserDemoState extends State<BrowserDemo> {
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);
Expand Down Expand Up @@ -133,6 +138,16 @@ and committed text — including emoji — should appear intact.</p>

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');
Expand Down Expand Up @@ -210,6 +225,11 @@ and committed text — including emoji — should appear intact.</p>
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,
Expand Down Expand Up @@ -255,6 +275,7 @@ and committed text — including emoji — should appear intact.</p>
url: _startUrl,
controller: _controller,
focusNode: _webFocus,
allowedSchemes: _allowedSchemes,
),
),
],
Expand Down
4 changes: 2 additions & 2 deletions example/macos/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- flutter_cef (0.0.1):
- flutter_cef (0.1.2):
- FlutterMacOS
- FlutterMacOS (1.0.0)

Expand All @@ -14,7 +14,7 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral

SPEC CHECKSUMS:
flutter_cef: 9d5ba4c7173f95ccbd35f4fa32a0aa7b82776146
flutter_cef: 923b658c344928eb53d81cf23367973f5737457f
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1

PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.1.1"
version: "0.1.2"
flutter_driver:
dependency: transitive
description: flutter
Expand Down
23 changes: 21 additions & 2 deletions lib/src/cef_web_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -282,13 +282,16 @@ class CefWebController {
required int width,
required int height,
double dpr = 1.0,
Set<String>? allowedSchemes,
}) async {
final res = await _channel.invokeMapMethod<String, dynamic>('create', {
'sessionId': sessionId,
'url': url,
'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
Expand All @@ -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<void> 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<void> _loadTrusted(String url) => _channel
.invokeMethod('loadTrusted', {'sessionId': sessionId, 'url': url});

/// Reload the current page.
Future<void> reload() => _send('reload');

Expand Down Expand Up @@ -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<void> 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<void> 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%).
Expand Down
22 changes: 21 additions & 1 deletion lib/src/cef_web_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String>? allowedSchemes;

@override
State<CefWebView> createState() => _CefWebViewState();
}
Expand Down Expand Up @@ -133,7 +149,11 @@ class _CefWebViewState extends State<CefWebView>
_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 {
Expand Down
18 changes: 16 additions & 2 deletions macos/Classes/CefWebSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -371,14 +381,18 @@ 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)",
"--dpr=\(dpr)",
"--iosurface-id=\(surfaceId)",
"--ipc=\(socketPath)",
]
if !allowedSchemes.isEmpty {
args.append("--allowed-schemes=\(allowedSchemes)")
}
p.arguments = args
do {
try p.run()
process = p
Expand Down
13 changes: 12 additions & 1 deletion macos/Classes/FlutterCefPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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])
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion macos/flutter_cef.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions native/build_cef_host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"

Expand Down Expand Up @@ -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:"
Expand Down
Loading
Loading