Untrusted render security
The Playground and the fluvie_server render endpoints compile and run
untrusted user code and specs. This page records the threat model, the
result of a four-round red-team, and what remains to do. Read it before you
change anything under fluvie_server/lib/src/api/render/ or the capture harness.
Verdict
Section titled “Verdict”The untrusted-render boundary is not sound, and it cannot be made sound by
patching individual seams. A submitted Video build() snippet is JIT-run with
full VM privileges inside flutter test. Its only containment is an import
allowlist (code_import_policy.dart) plus a per-loader FLUVIE_BLOCK_FILE_SOURCES
flag. That model is the wrong layer: the allowed package:flutter surface reaches
the filesystem and network on its own, without any dart:io import.
Do not expose the Playground to untrusted input until the render runs under OS-level sandboxing. The in-process checks below are defense in depth, not a boundary.
Threat model
Section titled “Threat model”- Trusted: a
KeyRenderRequest(a bundled, developer-authored lesson). - Untrusted:
Code,Spec,Prompt, andEditrequests, and the/v1/validate+ MCPvalidate_codepaths. - The attacker controls the Dart snippet or the spec JSON. The snippet must be a
zero-arg synchronous top-level
Video build(); it may import only allowlisted libraries. That is not a real restriction (see below).
Confirmed exploits (executed against the live code)
Section titled “Confirmed exploits (executed against the live code)”The red-team wrote proofs and ran them. These are facts, not theory.
Reachable with only allowlisted package:flutter + package:fluvie/fluvie.dart
imports:
rootBundle.loadString('/etc/passwd')(flutter/services) returns the file. Underflutter testtheflutter/assetschannel resolves the key as a raw path, with absolute paths and../traversal. Arbitrary host-file read.NetworkImage(url)insidecompute()(flutter/foundation + flutter/painting) performs an outbound request.computeruns in a fresh isolate where flutter_test’s mockHttpOverridesdoes not apply, so the request leaves the host. SSRF and exfiltration.- A
fluvieImage.file(path)wrapped in a plainflutter.Builder(invisible to the media collect walk) under a nestedRenderModeContext(mode: preview)reaches the paint-time preview fallback, a rawdart:iofile read that the loader-levelFLUVIE_BLOCK_FILE_SOURCESnever sees.
Availability / integrity:
- 64-bit integer overflow defeats the canvas bounds:
width = height = 2^32makeswidth * heightwrap to 0 and pass. Fixed (per-axis check). renderPosterPngis a second capture entrypoint that never calls the canvas guard and drivesboundary.toImage()straight from the untrustedbaseSpec.- The parser-DoS pre-scan (
_nestsPastLimit) counts only bracket depth, so a bracket-free deep AST (a long cascadeo..a..a..., an elseless collection-if) under the size cap still burns seconds of synchronous CPU inparseString. /v1/validateand MCPvalidate_coderun the analyzer on untrusted input with no complexity guard at all, on the main server isolate (~38 s measured for a ~60 KB payload). Code and spec renders are not rate-limited.
Earlier rounds also found: a bypassable regex allowlist (now AST-based), and the
allowlist trusting all of package:fluvie/ including the private src/ tree,
which exposed IoProcessRunner.run (RCE) and readFileBytes (file read). Both
fixed.
Root cause
Section titled “Root cause”Containment is inherited from flutter_test’s incidental mocking or from
string-level import filtering, never owned by Fluvie at the IO and allocation
layer. String filtering cannot enumerate every capability of the allowed
framework surface, and flutter_test’s mocks do not survive Isolate.run.
Required remediation (in priority order)
Section titled “Required remediation (in priority order)”- Run every untrusted render and every untrusted analysis in an OS sandbox:
a locked-down process or container with no filesystem access beyond a scratch
dir, no network, dropped privileges (seccomp / gVisor / a container with
--network noneand a read-only rootfs). This is the actual boundary; the import allowlist becomes one layer inside it. - Parse untrusted code off the request isolate with a hard wall-clock budget
(import scan and
/v1/validate), so any pathological input is bounded regardless of structure. Rate-limit code and spec renders like prompt/edit. - If any in-process mitigation must stand before (1): install process-wide
HttpOverridesand an asset-channel handler that hard-fail under the untrusted define, latch capture mode from the root so a nestedRenderModeContextcannot downgrade it, and narrow the flutter allowlist. Treat each as partial.
What is hardened in-process today
Section titled “What is hardened in-process today”Defense in depth only, all under FLUVIE_BLOCK_FILE_SOURCES / the render path:
- Import allowlist is AST-based and grants only the public
fluvie.dartbarrel, flutter non-src/libraries,dart:math, anddart:ui(code_import_policy.dart). FileSourceis blocked at every media loader on the untrusted path.- Canvas and frame-count bounds are overflow-safe and per-axis, enforced at the
capture chokepoint (
render_harness.dartassertRenderWithinBounds). - The in-process MCP render path (
LocalRenderGateway) enqueues straight to the render queue, so it leans on that capture chokepoint for its size bound instead of the HTTP handler’s early reject, and like code and spec renders it is not rate-limited. - The edit poster (
renderPosterPng) grounds an untrusted base spec for the model; on the blocked path it rejectsFileSourceand network sources before they reach the preview fallback, so a base spec cannot read a host file into the grounding image. - A linear nesting pre-scan rejects bracket bombs before the parse (partial: see
the cascade/collection-
ifbypass above).