diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index cd9d2eb519..4727ad7785 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -80,7 +80,7 @@ Defaults are compatibility choices, not a hardened profile. | `AllowGetType`, `AllowSystemReflection` | Disabled | Blocks instance type discovery, type widening, and reflection namespace/object access | | `AllowedAssemblies` | Empty until `AllowClr` adds entries | Closed allow-list for `System` / `importNamespace` type discovery, not for what admitted APIs can do | | Writes through projected CLR objects | Disabled | Fields, properties, indexers, dictionary/list entries, and live array elements require explicit `AllowClrWrite()` opt-in | -| CLR array conversion | Live view | Script observes the CLR array directly; element writes remain blocked unless CLR writes are enabled | +| CLR array conversion | Copy | Script receives a detached native JavaScript array snapshot | | Modules | Disabled | The fail-fast loader rejects imports | | Module graph limits | Unlimited | Count, source bytes, graph depth, and resolution hops are unbounded unless configured | | Module load policy | None | A custom loader's resolved targets are unrestricted unless a policy is configured | @@ -178,19 +178,20 @@ cancellation, and avoid returning powerful objects. ### TM-03: Script mutates host state -**Threat.** Direct writes through an `ObjectWrapper` are disabled by default, but a host can -explicitly enable them with `AllowClrWrite()`. Projected CLR arrays are live views by -default, so enabling writes lets script alter host properties, fields, indexers, -dictionaries, lists, arrays, or objects reachable from them. Independently of that option, -calling an exposed instance, static, or extension method can mutate host state and violate -host invariants. +**Threat.** Direct writes through an `ObjectWrapper` are disabled by default, and CLR arrays +cross as detached copies by default. A host can explicitly enable writes with +`AllowClrWrite()` and independently select `ArrayConversionMode.LiveView`; doing both lets +script alter host properties, fields, indexers, dictionaries, lists, live arrays, or objects +reachable from them. Calling an exposed instance, static, or extension method can mutate host +state regardless of the direct-write option. **Existing mitigations.** - Direct writes through CLR wrappers are disabled by default; `AllowClrWrite()` is an explicit opt-in. -- `ArrayConversionMode.Copy`, immutable DTOs, and Jint-owned record layouts avoid live - write-through. +- `ArrayConversionMode.Copy` is the default and disconnects the JavaScript array container + from later CLR mutations and script writes. +- Immutable DTOs and Jint-owned record layouts avoid live write-through. **Missing or residual mitigation.** @@ -198,12 +199,19 @@ host invariants. Jint. - Calling exposed methods or extension methods can still mutate state even when direct writes are disabled. - -**Required host action.** Leave CLR writes disabled and do not call `AllowClrWrite()` unless -direct mutation is an intentional capability. Pin `AllowClrWrite(false)` in hardened -configuration, use copied arrays where a live view is unnecessary, and expose immutable -snapshots. Do not expose mutating methods unless they are intentional, authorized -capabilities. +- A copied CLR array is only a shallow projection boundary: reference-type elements can still + expose mutable host objects. +- `ArrayConversionMode.LiveView` restores live reads and avoids the initial O(N) copy and + allocation, but does not grant write authority; `AllowClrWrite()` is additionally required + for write-through. +- Wrapper caches can reuse a copied snapshot, preserving script-side mutations and identity + across crossings, but they never make later CLR-side array mutations visible through it. + +**Required host action.** Leave CLR writes disabled and keep `ArrayConversionMode.Copy` +explicitly pinned in hardened configurations. Expose immutable snapshots whose elements are +also safe to project. Use `LiveView` only when live observation is intentional, and enable CLR +writes only when shared mutation is a separately authorized capability. Do not expose +mutating methods unless they are intentional, authorized capabilities. ### TM-04: Infinite loops and computational denial of service @@ -1201,6 +1209,7 @@ var engine = new Engine(options => options.AgentCanSuspend = false; // Pin the safe default explicitly so configuration reviews cannot miss this boundary. options.AllowClrWrite(false); + // Pin the hardened behavior even though Copy is the compatibility default. options.Interop.ArrayConversion = ArrayConversionMode.Copy; options.ResultLimits = ResultLimits.Conservative; options.Interop.ExposeDetailedExceptionMessages = false; @@ -1262,6 +1271,8 @@ Before deploying a host that executes untrusted scripts: - [ ] CLR namespace access, `AllowGetType`, reflection, debugger, and `Atomics.wait` are off. - [ ] Direct CLR writes remain disabled, and projected host objects are immutable or intentionally capability-scoped with no unintended mutating instance, static, or extension methods. +- [ ] CLR arrays use `Copy`; any `LiveView` opt-in is intentional, remains read-only unless + write-through is separately authorized, and does not expose mutable element objects accidentally. - [ ] Initial source, callback, external serializer, HTTP response, and log limits are enforced outside Jint; `ResultLimits` is configured for Jint-owned conversion and JSON output, and all four Jint module graph limits are configured when modules are enabled. diff --git a/Jint.Benchmark/ClrArrayProjectionBenchmark.cs b/Jint.Benchmark/ClrArrayProjectionBenchmark.cs new file mode 100644 index 0000000000..405d1ee2e5 --- /dev/null +++ b/Jint.Benchmark/ClrArrayProjectionBenchmark.cs @@ -0,0 +1,56 @@ +using BenchmarkDotNet.Attributes; +using Jint.Native; + +namespace Jint.Benchmark; + +/// +/// Measures the first crossing of a CLR array on an already constructed engine. Each row has its own engine, +/// and cycles through more source arrays than the recent-wrapper cache can retain, so every invocation measures +/// projection rather than a cache hit. The host arrays are allocated in setup and stay outside the measurement. +/// This complements the warm traversal rows in : Copy pays O(N) time +/// and allocation here, while LiveView avoids that upfront work; after crossing, the native Copy snapshot can +/// traverse as fast as or faster than the wrapper. +/// +[MemoryDiagnoser] +public class ClrArrayProjectionBenchmark +{ + private const int SourceCount = 16; + + private Engine _copyEngine = null!; + private Engine _liveViewEngine = null!; + private int[][] _copySources = null!; + private int[][] _liveViewSources = null!; + private int _copyIndex; + private int _liveViewIndex; + + [Params(0, 10, 100, 1000)] + public int Length { get; set; } + + [GlobalSetup] + public void GlobalSetup() + { + _copyEngine = new Engine(); + _liveViewEngine = new Engine(options => options.Interop.ArrayConversion = ArrayConversionMode.LiveView); + _copySources = CreateSources(); + _liveViewSources = CreateSources(); + } + + [Benchmark(Baseline = true)] + public JsValue FirstCrossing_Copy() + => JsValue.FromObject(_copyEngine, _copySources[_copyIndex++ & (SourceCount - 1)]); + + [Benchmark] + public JsValue FirstCrossing_LiveView() + => JsValue.FromObject(_liveViewEngine, _liveViewSources[_liveViewIndex++ & (SourceCount - 1)]); + + private int[][] CreateSources() + { + var sources = new int[SourceCount][]; + for (var i = 0; i < sources.Length; i++) + { + sources[i] = new int[Length]; + } + + return sources; + } +} diff --git a/Jint.Benchmark/InteropWrapperChurnBenchmark.cs b/Jint.Benchmark/InteropWrapperChurnBenchmark.cs index 36550b76c4..36708939a6 100644 --- a/Jint.Benchmark/InteropWrapperChurnBenchmark.cs +++ b/Jint.Benchmark/InteropWrapperChurnBenchmark.cs @@ -51,15 +51,15 @@ public sealed class Payload private Engine _engineArrayHoistedDefault = null!; private Engine _engineArrayCopy = null!; private Engine _engineArrayIdentity = null!; - private Engine _engineArrayRecentCache = null!; + private Engine _engineArrayLiveView = null!; [GlobalSetup] public void GlobalSetup() { var holder = new Holder(new Payload { Value = 42 }); - // the out-of-the-box engine: since 4.14 ArrayConversion defaults to LiveView and - // CacheRecentObjectWrappers defaults to true (repeated crossings reuse the wrapper) + // the out-of-the-box engine: ArrayConversion defaults to Copy and + // CacheRecentObjectWrappers defaults to true (repeated crossings reuse the snapshot) Engine CreateDefault() { var engine = new Engine(); @@ -70,7 +70,11 @@ Engine CreateDefault() // opt out of the recent-wrapper cache: every crossing builds a fresh wrapper Engine CreateNoCache() { - var engine = new Engine(cfg => cfg.Interop.CacheRecentObjectWrappers = false); + var engine = new Engine(cfg => + { + cfg.Interop.ArrayConversion = ArrayConversionMode.LiveView; + cfg.Interop.CacheRecentObjectWrappers = false; + }); engine.SetValue("h", holder); return engine; } @@ -102,8 +106,15 @@ Engine CreateRecentCache() return engine; } - // the pre-4.14 default: every array crossing deep-copies into a fresh JsArray snapshot - // (the cache opt-out keeps this row measuring the copy itself) + Engine CreateLiveView() + { + var engine = new Engine(cfg => cfg.Interop.ArrayConversion = ArrayConversionMode.LiveView); + engine.SetValue("h", holder); + return engine; + } + + // Every array crossing deep-copies into a fresh JsArray snapshot (the cache opt-out keeps + // this row measuring the copy itself). Engine CreateCopy() { var engine = new Engine(cfg => @@ -144,8 +155,8 @@ Engine CreateCopy() _engineArrayIdentity = CreateIdentity(); _engineArrayIdentity.Execute(_arrayTraversal); - _engineArrayRecentCache = CreateRecentCache(); - _engineArrayRecentCache.Execute(_arrayTraversal); + _engineArrayLiveView = CreateLiveView(); + _engineArrayLiveView.Execute(_arrayTraversal); } [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] @@ -172,14 +183,14 @@ public void SameObjectReturnedInLoop_RecentCache() for (var i = 0; i < OperationsPerInvoke; i++) _engineChurnRecentCache.Execute("h.Payload.Value"); } - // A CLR array member read repeatedly inside the loop. Under the default (LiveView) each h.Numbers - // read wraps the array in a live fixed-size view without a per-read deep copy; the Copy-mode rows - // below deep-copy into a fresh JsArray unless an identity flag reuses the snapshot. + // A CLR array member read repeatedly inside the loop. The default Copy mode reuses its first + // snapshot through the recent-wrapper cache; the explicit LiveView row avoids a deep copy, and + // the uncached Copy row below measures a fresh snapshot on each crossing. private static readonly Prepared