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
41 changes: 26 additions & 15 deletions .github/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -178,32 +178,40 @@ 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.**

- Read-only wrapper configuration does not make a mutable object graph immutable outside
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

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
56 changes: 56 additions & 0 deletions Jint.Benchmark/ClrArrayProjectionBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using BenchmarkDotNet.Attributes;
using Jint.Native;

namespace Jint.Benchmark;

/// <summary>
/// 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 <see cref="InteropWrapperChurnBenchmark"/>: 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.
/// </summary>
[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;
}
}
43 changes: 27 additions & 16 deletions Jint.Benchmark/InteropWrapperChurnBenchmark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -144,8 +155,8 @@ Engine CreateCopy()
_engineArrayIdentity = CreateIdentity();
_engineArrayIdentity.Execute(_arrayTraversal);

_engineArrayRecentCache = CreateRecentCache();
_engineArrayRecentCache.Execute(_arrayTraversal);
_engineArrayLiveView = CreateLiveView();
_engineArrayLiveView.Execute(_arrayTraversal);
}

[Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
Expand All @@ -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<Script> _arrayTraversal = Engine.PrepareScript(
"var s = 0; for (var i = 0; i < 100; i++) { s += h.Numbers[i]; }");

[Benchmark]
public void ArrayMemberTraversal_DefaultLiveView()
public void ArrayMemberTraversal_DefaultCopy()
{
_engineArrayDefault.Execute(_arrayTraversal);
}
Expand All @@ -196,13 +207,13 @@ public void ArrayMemberTraversal_LiveViewNoCache()
"var arr = h.Numbers; var s = 0; for (var pass = 0; pass < 100; pass++) { for (var i = 0; i < 100; i++) { s += arr[i]; } }");

[Benchmark]
public void ArrayHoistedTraversal_DefaultLiveView()
public void ArrayHoistedTraversal_DefaultCopy()
{
_engineArrayHoistedDefault.Execute(_arrayHoistedTraversal);
}

[Benchmark]
public void ArrayMemberTraversal_CopyMode()
public void ArrayMemberTraversal_CopyNoCache()
{
_engineArrayCopy.Execute(_arrayTraversal);
}
Expand All @@ -214,8 +225,8 @@ public void ArrayMemberTraversal_CopyIdentityFlag()
}

[Benchmark]
public void ArrayMemberTraversal_CopyRecentCache()
public void ArrayMemberTraversal_LiveView()
{
_engineArrayRecentCache.Execute(_arrayTraversal);
_engineArrayLiveView.Execute(_arrayTraversal);
}
}
11 changes: 6 additions & 5 deletions Jint.Benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,14 +296,15 @@ What the numbers show:
Jint +2.6%). Allocation is Jint's on every row: 3.9×–12.4× less than the row's nearest managed
competitor. (Three ClearScript_FastProxy rows show smaller KB figures, but those count
interop-bridge overhead only — V8's working memory is on its native heap.)
* **Collection traversal — last of all engines at 4.13.0 — stays transformed at
15,597 → 1,251 µs (12.5×, −99% allocation) with no script changes**: the 4.14.0
`ArrayConversionMode.LiveView` default exposes the host array as a live view instead of
* **Historical 4.14.0 collection traversal result — last of all engines at 4.13.0 — was transformed at
15,597 → 1,251 µs (12.5×, −99% allocation) with no script changes**: in that release,
`ArrayConversionMode.LiveView` was the default and exposed the host array as a live view instead of
re-copying it on every read, and a per-descriptor value memo
([#2756](https://github.com/sebastienros/jint/pull/2756)) skips re-converting the array on every
read. NiL.JS took the row back by ~4% in the previous session; here the two are level (0.7%
apart, both rank 1) while NiL.JS allocates 12× more. Either way the old hoist-into-a-local
workaround is no longer needed.
apart, both rank 1) while NiL.JS allocates 12× more. The repeated copies in this historical table predate
the current recent-wrapper cache. `Copy` is now the default and normally reuses its first snapshot while
cached; explicitly opt into `LiveView` to retain shared mutation and avoid the initial O(N) projection.

| Method | FileName | Mean | StdDev | Rank | Allocated |
|---------------------- |----------------------------- |------------:|---------:|-----:|------------:|
Expand Down
1 change: 1 addition & 0 deletions Jint.Tests.PublicInterface/ClrWriteConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ private static Engine CreateEngine(
if (allowWrite)
{
options.AllowClrWrite();
options.Interop.ArrayConversion = ArrayConversionMode.LiveView;
}
})
.SetValue("host", host)
Expand Down
Loading