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