From 88ebf8e4c22afec45efb766525256681dfd5c526 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Fri, 21 Aug 2026 19:22:34 +0300 Subject: [PATCH] Add module graph security limits and destination policy --- .github/THREAT_MODEL.md | 74 +- .../AsyncModuleLoaderTests.cs | 12 + .../HostModuleBuilderIdentityTests.cs | 32 + .../HostModuleGraphSecurityTests.cs | 1209 +++++++++++++++++ Jint/Engine.Modules.cs | 287 +++- Jint/Engine.cs | 25 + Jint/Options.cs | 48 + Jint/Runtime/ConstraintFailure.cs | 3 + Jint/Runtime/Host.cs | 10 +- Jint/Runtime/Modules/AsyncModuleLoader.cs | 9 +- Jint/Runtime/Modules/CyclicModule.cs | 83 +- Jint/Runtime/Modules/IModuleLoadPolicy.cs | 30 + Jint/Runtime/Modules/Module.cs | 8 + Jint/Runtime/Modules/ModuleAllowlistPolicy.cs | 207 +++ Jint/Runtime/Modules/ModuleBuilder.cs | 25 + Jint/Runtime/Modules/ModuleFactory.cs | 28 +- .../Modules/ModuleGraphLimitException.cs | 14 + Jint/Runtime/Modules/ModuleLoadCompletion.cs | 69 +- Jint/Runtime/Modules/ModuleLoadPayload.cs | 356 ++++- Jint/Runtime/Modules/ModuleLoader.cs | 24 +- Jint/Runtime/Throw.cs | 25 +- README.md | 38 + 22 files changed, 2462 insertions(+), 154 deletions(-) create mode 100644 Jint.Tests.PublicInterface/HostModuleGraphSecurityTests.cs create mode 100644 Jint/Runtime/Modules/IModuleLoadPolicy.cs create mode 100644 Jint/Runtime/Modules/ModuleAllowlistPolicy.cs create mode 100644 Jint/Runtime/Modules/ModuleGraphLimitException.cs diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index fb5b932c24..017244a0b7 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -81,6 +81,8 @@ Defaults are compatibility choices, not a hardened profile. | Writes through projected CLR objects | Enabled | A default engine can mutate host objects passed with `SetValue` | | CLR array conversion | Live view | Script writes can mutate a projected CLR array | | 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 | | `require` | Disabled | No CommonJS-like loader global | | Web platform APIs (`Options.WebApi.Features`) | `None` | No `console`, timers, `URL`, `Blob`, … globals exist | | `fetch` | Disabled, and never part of `WebApiFeatures.Default` | Only `UseFetch()` grants script outbound HTTP | @@ -218,27 +220,31 @@ independent process/container CPU and wall-clock limit. a script-catchable rejection. - The default file loader reads at most the configured source length plus one UTF-16 code unit before rejecting, rather than materializing the complete oversized source string. +- `MaxTotalModuleSourceBytes` can reject loader-provided source before Jint parses it. **Missing or residual mitigation.** - Initial `Execute(string)` and `Evaluate(string)` parse before `ExecuteWithConstraints`; execution timeout, statement, and memory constraints do not bound that parse; parser limits are separate and opt-in. -- The source limit counts decoded UTF-16 code units, not transport bytes. Custom loaders - have already allocated the string they return, and byte-backed sources are checked after - decoding unless the loader enforces an earlier byte cap. +- The parser source limit counts decoded UTF-16 code units, not transport bytes. The module + graph byte limit checks loader-provided bytes before decoding, but a custom loader may + already have buffered an oversized response before handing its contents to Jint. - The node limit bounds the completed AST size, not every speculative parser operation or a wall-clock CPU budget. Dynamic parsing cannot be preempted while Acornima is between node completions. - Prepared scripts and modules are not rechecked when executed. The host that prepares or accepts a prepared AST is responsible for applying preparation limits at that trust boundary. Those limits do continue to govern dynamic source compiled while it executes. +- Prepared modules and custom module records have no original encoded source length and + therefore contribute zero to `MaxTotalModuleSourceBytes`. - Parser limits do not cap aggregate imported source, module count, graph depth, or network policy. -**Required host action.** Configure both parser limits, cap encoded request/module bytes and -source offsets before Jint, cap module count and graph depth, and parse in the disposable -worker. Disable string compilation unless it is a requirement. +**Required host action.** Configure parser source and node limits plus module count, byte, +depth, and hop limits. Cap encoded request/module bytes, source offsets, and custom-loader +transport responses before Jint, and parse in the disposable worker. Disable string +compilation unless it is a requirement. ### TM-06: Memory exhaustion @@ -393,19 +399,33 @@ traces, and `import.meta.url` can disclose paths, hostnames, queries, or credent - `DefaultModuleLoader` restricts resolution to its base URI by default. - The default loader has no package or HTTP support. - Module loads are cached per engine and async loads can be coalesced. +- `MaxModuleCount`, `MaxTotalModuleSourceBytes`, `MaxModuleGraphDepth`, and + `MaxModuleResolutionHops` can bound a configured graph. Limit failures propagate like + execution constraints rather than becoming catchable import rejections. +- `IModuleLoadPolicy` can inspect every final resolved target. `ModuleAllowlistPolicy` + provides composable scheme, exact-host, exact-origin, canonical-file-root, and bare- + specifier controls. **Missing or residual mitigation.** -- Custom loader policy is entirely host-defined. -- Jint has no universal scheme, origin, redirect, DNS, response-byte, graph-size, or graph- - depth policy. +- All graph limits and policy are opt-in. +- The built-in policy sees the final `ResolvedSpecifier`, not redirects, DNS answers, or + transport response headers inside a custom loader. Redirect count, every redirect target, + resolved IP ranges, transport bytes, and timeouts remain loader responsibilities. +- Prepared modules, exports-only modules, and custom module records whose encoded source + size is unknowable contribute zero to the source-byte limit. +- Graph depth and resolution hops are per top-level load. A script can stage a larger + engine-lifetime graph across multiple dynamic imports or by loading dependencies first; + `MaxModuleCount` and the cumulative source-byte limit remain the aggregate bounds. +- File-root policy is lexical and does not resolve symbolic links or Windows reparse points; + an allowed root containing attacker-controlled links can still escape the lexical root. - Base-path checking is not a replacement for filesystem permissions or an OS sandbox. - Async loader failures expose the supplied exception message to script. -**Required host action.** Prefer host-registered modules. Otherwise use explicit scheme, -origin, resolved-IP, path, redirect, size, count, depth, and timeout allow-lists. Use a -credential-free module identity, sanitize loader errors, and apply restrictive filesystem -and network policy to the worker. +**Required host action.** Prefer host-registered modules. Otherwise configure all four graph +limits and an `IModuleLoadPolicy`; also enforce resolved-IP, redirect, transport-size, and +timeout policy inside the loader. Use a credential-free module identity, sanitize loader +errors, and apply restrictive filesystem and network policy to the worker. ### TM-12: Cross-request state leakage and prototype pollution @@ -1115,6 +1135,26 @@ finally } ``` +If untrusted scripts must use modules, replace the modules-disabled baseline with explicit +limits and an allowlist matched to the loader: + +```csharp +options.EnableModules(moduleRoot); +options.Modules.MaxModuleCount = 50; +options.Modules.MaxTotalModuleSourceBytes = 1_000_000; +options.Modules.MaxModuleGraphDepth = 10; +options.Modules.MaxModuleResolutionHops = 200; +options.Modules.LoadPolicy = new ModuleAllowlistPolicy +{ + AllowedSchemes = { "file" }, + AllowedFileRoots = { moduleRoot }, +}; +``` + +A network loader must additionally enforce redirect targets/count, DNS and resolved-IP +policy, response bytes, and timeout/cancellation inside the loader. Jint cannot observe +those transport steps. + This configuration is incomplete without host controls: 1. Reject oversized encoded script and module input before decoding, and configure Jint's @@ -1134,14 +1174,16 @@ Before deploying a host that executes untrusted scripts: - [ ] CLR namespace access, `AllowGetType`, reflection, debugger, and `Atomics.wait` are off. - [ ] Projected host objects are immutable or intentionally capability-scoped and read-only. -- [ ] Source, module graph, callback, result, and log limits are enforced outside Jint. +- [ ] Initial source, callback, result, and log limits are enforced outside Jint; all four + Jint module graph limits are configured when modules are enabled. - [ ] Time, statement, memory, recursion, stack, array, regex, promise, and operation limits are explicitly configured and tested with adversarial inputs. - [ ] Async API cancellation is paired with an engine cancellation constraint. - [ ] No engine, mutable host object, `JsValue`, module, or request context crosses trust domains. -- [ ] Custom module loaders enforce scheme, origin, IP, path, redirect, size, and timeout - policies and disclose no secrets in names or errors. +- [ ] An `IModuleLoadPolicy` restricts final resolved targets, and custom module loaders + enforce scheme, origin, redirect, resolved-IP, path, transport-size, and timeout policies + and disclose no secrets in names or errors. - [ ] `fetch` is off, or its `UrlFilter`, `AllowedSchemes`, `MaxResponseBytes`, `MaxRedirects`, `Timeout`, and `MaxConcurrentRequests` allow-list the destinations and the budgets the workload actually needs, and worker egress is restricted at the network layer diff --git a/Jint.Tests.PublicInterface/AsyncModuleLoaderTests.cs b/Jint.Tests.PublicInterface/AsyncModuleLoaderTests.cs index c7b07f1dea..24da011afb 100644 --- a/Jint.Tests.PublicInterface/AsyncModuleLoaderTests.cs +++ b/Jint.Tests.PublicInterface/AsyncModuleLoaderTests.cs @@ -537,6 +537,18 @@ public Task ACanceledFetchRejectsTheImportAsCanceled() => DedicatedThread.RunAsy import.Error!.Get("message").AsString().Should().Be("Loading module './doomed.js' was canceled."); }); + [Fact] + public async Task ACanceledFetchSettlesWhileImportAsyncOwnsTheEngine() + { + var loader = new TokenCapturingLoader(); + var engine = new Engine(options => options.EnableModules(loader)); + + var import = engine.Modules.ImportAsync("./doomed.js"); + loader.CancelPendingFetch(); + + await Invoking(() => import).Should().ThrowAsync(); + } + /// /// Records the cancellation token the engine hands the fetch, and never finishes on its own: the fetch /// task only settles when the test cancels it. diff --git a/Jint.Tests.PublicInterface/HostModuleBuilderIdentityTests.cs b/Jint.Tests.PublicInterface/HostModuleBuilderIdentityTests.cs index e462b1014c..68ff30d00a 100644 --- a/Jint.Tests.PublicInterface/HostModuleBuilderIdentityTests.cs +++ b/Jint.Tests.PublicInterface/HostModuleBuilderIdentityTests.cs @@ -278,6 +278,19 @@ public void CancellationInsideTheIndexingPassIsNotSwallowed() .Should().Throw(); } + [Fact] + public void CancellationInsideTheIndexingPassCanBeRetried() + { + var engine = new Engine(options => options.EnableModules(new CancelOnceResolveLoader())); + + engine.Modules.Add("cancelled", "export const value = 1;"); + + Invoking(() => engine.Modules.Import("cancelled")) + .Should().Throw(); + + engine.Modules.Import("cancelled").Get("value").AsNumber().Should().Be(1); + } + /// /// Canonicalizes like , except that one specifier makes it throw /// — the shape of a loader honoring a CancellationToken @@ -300,6 +313,25 @@ protected override string LoadModuleContents(Engine engine, ResolvedSpecifier re => throw new InvalidOperationException("the loader was asked to load " + resolved.Key); } + private sealed class CancelOnceResolveLoader : ModuleLoader + { + private bool _cancel = true; + + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + { + if (_cancel) + { + _cancel = false; + throw new OperationCanceledException(); + } + + return new ResolvedSpecifier(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + } + + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) + => throw new InvalidOperationException("The registered module should satisfy the load."); + } + [Fact] public void ALoaderHandingBackANullKeyDoesNotBreakAnUnrelatedImport() { diff --git a/Jint.Tests.PublicInterface/HostModuleGraphSecurityTests.cs b/Jint.Tests.PublicInterface/HostModuleGraphSecurityTests.cs new file mode 100644 index 0000000000..8ea18e22b3 --- /dev/null +++ b/Jint.Tests.PublicInterface/HostModuleGraphSecurityTests.cs @@ -0,0 +1,1209 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Jint.Native; +using Jint.Runtime; +using Jint.Runtime.Modules; + +using Module = Jint.Runtime.Modules.Module; + +namespace Jint.Tests.PublicInterface; + +/// +/// Host-configurable module graph security limits: module count, total source bytes, graph depth, +/// resolution hops, and load policy. Tests live here because the public-interface project has no +/// InternalsVisibleTo grant, so every green assertion proves an embedder can reach the surface. +/// +public sealed class HostModuleGraphSecurityTests +{ + // Helpers + + /// Simple in-memory module loader returning fixed source per specifier. + private sealed class DictLoader : ModuleLoader + { + private readonly Dictionary _modules; + private readonly string _basePath; + + public DictLoader(Dictionary modules, string? basePath = null) + { + _modules = modules; + _basePath = basePath ?? "/base"; + } + + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + { + var key = moduleRequest.Specifier; + if (key.StartsWith("./", StringComparison.Ordinal)) + { + key = key.Substring(2); + } + + var uri = new Uri($"file://{_basePath}/{key}"); + return new ResolvedSpecifier(moduleRequest, key, uri, SpecifierType.RelativeOrAbsolute); + } + + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) + { + if (_modules.TryGetValue(resolved.Key!, out var source)) + { + return source; + } + + throw new ModuleResolutionException($"Module not found: {resolved.Key}", resolved.ModuleRequest.Specifier, null, null); + } + } + + /// Simple loader that uses bare specifiers (no URI). + private sealed class BareLoader : ModuleLoader + { + private readonly Dictionary _modules; + + public BareLoader(Dictionary modules) => _modules = modules; + + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + { + return new ResolvedSpecifier(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + } + + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) + { + if (_modules.TryGetValue(resolved.Key!, out var source)) + { + return source; + } + + throw new ModuleResolutionException($"Not found: {resolved.Key}", resolved.ModuleRequest.Specifier, null, null); + } + } + + private sealed class DeferredLoader : IAsyncModuleLoader + { + private readonly List _pending = []; + private readonly Dictionary _loads = new(StringComparer.Ordinal); + + public int LoadsFor(string specifier) => _loads.TryGetValue(specifier, out var count) ? count : 0; + + public ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + public Module LoadModule(Engine engine, ResolvedSpecifier resolved) + => throw new InvalidOperationException("The asynchronous path should be used."); + + public void LoadModuleAsync(Engine engine, ResolvedSpecifier resolved, ModuleLoadCompletion completion) + { + _loads.TryGetValue(resolved.Key, out var count); + _loads[resolved.Key] = count + 1; + _pending.Add(completion); + } + + public void Deliver(string specifier, string source) + { + var completion = _pending.First(x => x.Resolved.Key == specifier); + _pending.Remove(completion); + completion.SetSource(source); + } + + public void Deliver(string specifier, byte[] source) + { + var completion = _pending.First(x => x.Resolved.Key == specifier); + _pending.Remove(completion); + completion.SetSource(source); + } + } + + private sealed class UriLoader : ModuleLoader + { + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + { + var uri = new Uri(moduleRequest.Specifier, UriKind.Absolute); + return new ResolvedSpecifier(moduleRequest, uri.AbsoluteUri, uri, SpecifierType.RelativeOrAbsolute); + } + + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) + => "export default 1;"; + } + + private sealed class PrefixingLoader : ModuleLoader + { + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, "/" + moduleRequest.Specifier.TrimStart('/'), null, SpecifierType.Bare); + + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) + => throw new InvalidOperationException("A registered module should satisfy the load."); + } + + private sealed class PreparedLoader : IModuleLoader + { + private readonly Prepared _prepared = Engine.PrepareModule( + "export default 1;", + "prepared"); + + public ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + public Module LoadModule(Engine engine, ResolvedSpecifier resolved) + => ModuleFactory.BuildSourceTextModule(engine, in _prepared); + } + + private sealed class BytesLoader : IModuleLoader + { + private readonly byte[] _bytes; + + public BytesLoader(byte[] bytes) => _bytes = bytes; + + public ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + public Module LoadModule(Engine engine, ResolvedSpecifier resolved) + => ModuleFactory.BuildBytesModule(engine, resolved, _bytes); + } + + private sealed class TextAndJsonLoader : IModuleLoader + { + internal const string Json = """{"value":1}"""; + internal const string Text = "plain text"; + + public ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + public Module LoadModule(Engine engine, ResolvedSpecifier resolved) + => resolved.Key == "json" + ? ModuleFactory.BuildJsonModule(engine, resolved, Json) + : ModuleFactory.BuildTextModule(engine, resolved, Text); + } + + private sealed class ThrowingModuleLoader : ModuleLoader + { + private readonly Exception _exception; + + public ThrowingModuleLoader(Exception exception) => _exception = exception; + + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) + => throw _exception; + } + + private sealed class ThrowingResolveLoader : ModuleLoader + { + private readonly Exception _exception; + + public ThrowingResolveLoader(Exception exception) => _exception = exception; + + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => throw _exception; + + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) + => throw new InvalidOperationException("Resolution should have failed."); + } + + private sealed class FaultingAsyncModuleLoader : AsyncModuleLoader + { + private readonly Exception _exception; + + public FaultingAsyncModuleLoader(Exception exception) => _exception = exception; + + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + protected override Task LoadModuleContentsAsync( + Engine engine, + ResolvedSpecifier resolved, + CancellationToken cancellationToken) + => Task.FromException(_exception); + } + + private sealed class TimingOutAsyncModuleLoader : AsyncModuleLoader + { + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + protected override Task LoadModuleContentsAsync( + Engine engine, + ResolvedSpecifier resolved, + CancellationToken cancellationToken) + { + try + { + engine.Execute("while (true);"); + return Task.FromResult(""); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + } + + private sealed class CancelingAsyncModuleLoader : AsyncModuleLoader + { + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + protected override Task LoadModuleContentsAsync( + Engine engine, + ResolvedSpecifier resolved, + CancellationToken cancellationToken) + => Task.FromCanceled(cancellationToken); + } + + private sealed class TimingOutEngineModuleLoader : ModuleLoader + { + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) + { + engine.Execute("while (true);"); + return ""; + } + } + + private sealed class RegexTimingOutEngineModuleLoader : ModuleLoader + { + private const string Pattern = "^(https?:\\/\\/)?([\\da-z\\.-]+)\\.([a-z\\.]{2,6})([\\/\\w\\.-]*)*\\/?$"; + private const string Input = "https://archiverbx.blob.core.windows.net/static/C:/Users/USR/Documents/Projects/PROJ/static/images/full/1234567890.jpg"; + + public override ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest) + => new(moduleRequest, moduleRequest.Specifier, null, SpecifierType.Bare); + + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) + { + engine.Execute($"'{Input}'.match(/{Pattern}/)"); + return ""; + } + } + + /// Policy that denies everything. + private sealed class DenyAllPolicy : IModuleLoadPolicy + { + public bool AllowLoad(string? referrerLocation, ModuleRequest request, ResolvedSpecifier resolved) => false; + } + + /// Policy that allows everything. + private sealed class AllowAllPolicy : IModuleLoadPolicy + { + public bool AllowLoad(string? referrerLocation, ModuleRequest request, ResolvedSpecifier resolved) => true; + } + + // Module count + + [Fact] + public void ModuleCount_ExactBoundary_Succeeds() + { + var modules = new Dictionary + { + ["a.js"] = "export const x = 1;", + ["b.js"] = "export const y = 2;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleCount = 2); + + engine.Modules.Import("a.js"); + engine.Modules.Import("b.js"); + } + + [Fact] + public void ModuleCount_OverLimit_ThrowsModuleGraphLimitException() + { + var modules = new Dictionary + { + ["a.js"] = "export const x = 1;", + ["b.js"] = "export const y = 2;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleCount = 1); + + engine.Modules.Import("a.js"); + Invoking(() => engine.Modules.Import("b.js")) + .Should().Throw(); + } + + [Fact] + public void ModuleCount_DuplicateImportCountsOnce() + { + var modules = new Dictionary + { + ["a.js"] = "export const x = 1;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleCount = 1); + + engine.Modules.Import("a.js"); + // Re-importing the same module should not count again. + engine.Modules.Import("a.js"); + } + + [Fact] + public void ModuleCount_ProgrammaticModuleParticipates() + { + var engine = new Engine(o => + { + o.EnableModules(new DictLoader(new Dictionary + { + ["b.js"] = "import { v } from 'lib'; export const x = v;", + })); + o.Modules.MaxModuleCount = 2; + }); + + engine.Modules.Add("lib", builder => builder.ExportValue("v", 42)); + // 'lib' counts as 1, 'b.js' as 2: at the limit. + engine.Modules.Import("b.js"); + } + + [Fact] + public void ModuleCount_DiamondCountsOnce() + { + // a -> b, a -> c, b -> d, c -> d: diamond, d should count once. + var modules = new Dictionary + { + ["a.js"] = "import './b.js'; import './c.js'; export default 1;", + ["b.js"] = "import './d.js'; export default 2;", + ["c.js"] = "import './d.js'; export default 3;", + ["d.js"] = "export default 4;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleCount = 4); + + engine.Modules.Import("a.js"); + } + + [Fact] + public void ModuleCount_CycleCountsOnce() + { + // a -> b -> a cycle: each module is registered once. + var modules = new Dictionary + { + ["a.js"] = "import './b.js'; export const x = 1;", + ["b.js"] = "import './a.js'; export const y = 2;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleCount = 2); + + engine.Modules.Import("a.js"); + } + + // Total source bytes + + [Fact] + public void SourceBytes_ExactBoundary_Succeeds() + { + var source = "export const x = 1;"; + var byteCount = Encoding.UTF8.GetByteCount(source); + var modules = new Dictionary { ["a.js"] = source }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxTotalModuleSourceBytes = byteCount); + + engine.Modules.Import("a.js"); + } + + [Fact] + public void SourceBytes_OverLimit_ThrowsModuleGraphLimitException() + { + var source = "export const x = 1;"; + var byteCount = Encoding.UTF8.GetByteCount(source); + var modules = new Dictionary { ["a.js"] = source }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxTotalModuleSourceBytes = byteCount - 1); + + Invoking(() => engine.Modules.Import("a.js")) + .Should().Throw(); + } + + [Fact] + public void SourceBytes_ProgrammaticExportsOnlyChargesZero() + { + var engine = new Engine(o => + { + o.EnableModules(new DictLoader(new Dictionary())); + o.Modules.MaxTotalModuleSourceBytes = 1; // only 1 byte allowed + o.Modules.MaxModuleCount = 1; + }); + + // Exports-only modules charge 0 bytes and do not exceed the 1-byte limit. + engine.Modules.Add("lib", builder => builder.ExportValue("v", 42)); + engine.Modules.Import("lib"); + } + + [Fact] + public void SourceBytes_PreparedModuleChargesZero() + { + var engine = new Engine(o => + { + o.EnableModules(new PreparedLoader()); + o.Modules.MaxTotalModuleSourceBytes = 1; + }); + + engine.Modules.Import("prepared"); + } + + [Fact] + public void SourceBytes_RawBytesUseExactLength() + { + var bytes = new byte[] { 1, 2, 3, 4 }; + var engine = new Engine(o => + { + o.EnableModules(new BytesLoader(bytes)); + o.Modules.MaxTotalModuleSourceBytes = bytes.Length - 1; + }); + + Invoking(() => engine.Modules.Import("bytes")) + .Should().Throw(); + } + + [Fact] + public void SourceBytes_JsonAndTextModulesUseUtf8Length() + { + var limit = Encoding.UTF8.GetByteCount(TextAndJsonLoader.Json) + + Encoding.UTF8.GetByteCount(TextAndJsonLoader.Text); + var engine = new Engine(o => + { + o.EnableModules(new TextAndJsonLoader()); + o.Modules.MaxTotalModuleSourceBytes = limit; + }); + + engine.Modules.Import("json"); + engine.Modules.Import("text"); + } + + [Fact] + public void SourceBytes_ProgrammaticSourcesIncludeInsertedLineBreaks() + { + const string first = "export const a = 1;"; + const string second = "export const b = 2;"; + var sourceBytesOnly = Encoding.UTF8.GetByteCount(first) + Encoding.UTF8.GetByteCount(second); + var engine = new Engine(o => o.Modules.MaxTotalModuleSourceBytes = sourceBytesOnly); + engine.Modules.Add("joined", builder => builder.AddSource(first).AddSource(second)); + + Invoking(() => engine.Modules.Import("joined")) + .Should().Throw(); + } + + [Fact] + public void SourceBytes_AsynchronousBytesAreRejectedByTheEnginePump() + { + var loader = new DeferredLoader(); + var engine = new Engine(o => + { + o.EnableModules(loader); + o.Modules.MaxTotalModuleSourceBytes = 3; + }); + var operation = engine.Modules.StartImport("bytes"); + + loader.Deliver("bytes", new byte[] { 1, 2, 3, 4 }); + + Invoking(() => engine.Advanced.ProcessTasks()) + .Should().Throw(); + operation.IsCompleted.Should().BeFalse(); + } + + [Fact] + public void SourceBytes_AsynchronousStringLoadsAreCumulative() + { + const string first = "export const first = 1;"; + const string second = "export const second = 2;"; + var loader = new DeferredLoader(); + var engine = new Engine(o => + { + o.EnableModules(loader); + o.Modules.MaxTotalModuleSourceBytes = + Encoding.UTF8.GetByteCount(first) + Encoding.UTF8.GetByteCount(second) - 1; + }); + + var firstImport = engine.Modules.StartImport("first.js"); + loader.Deliver("first.js", first); + engine.Advanced.ProcessTasks(); + firstImport.IsCompleted.Should().BeTrue(); + + var secondImport = engine.Modules.StartImport("second.js"); + loader.Deliver("second.js", second); + + Invoking(() => engine.Advanced.ProcessTasks()) + .Should().Throw(); + secondImport.IsCompleted.Should().BeFalse(); + } + + // Graph depth + + [Fact] + public void GraphDepth_ExactBoundary_Succeeds() + { + // Chain: a -> b -> c, depth = 3. + var modules = new Dictionary + { + ["a.js"] = "import './b.js'; export default 1;", + ["b.js"] = "import './c.js'; export default 2;", + ["c.js"] = "export default 3;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleGraphDepth = 3); + + engine.Modules.Import("a.js"); + } + + [Fact] + public void GraphDepth_OverLimit_ThrowsModuleGraphLimitException() + { + // Chain: a -> b -> c, depth = 3, limit = 2. + var modules = new Dictionary + { + ["a.js"] = "import './b.js'; export default 1;", + ["b.js"] = "import './c.js'; export default 2;", + ["c.js"] = "export default 3;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleGraphDepth = 2); + + Invoking(() => engine.Modules.Import("a.js")) + .Should().Throw(); + } + + [Fact] + public void GraphDepth_CycleContributesEachDistinctModule() + { + var modules = new Dictionary + { + ["a.js"] = "import './b.js'; export const a = 1;", + ["b.js"] = "import './a.js'; export const b = 1;", + }; + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleGraphDepth = 1); + + Invoking(() => engine.Modules.Import("a.js")) + .Should().Throw(); + } + + [Theory] + [InlineData("import './shared.js'; import './one.js';")] + [InlineData("import './one.js'; import './shared.js';")] + public void GraphDepth_SharedSubtreeUsesLongestPathRegardlessOfSiblingOrder(string imports) + { + var modules = new Dictionary + { + ["root.js"] = imports, + ["one.js"] = "import './two.js';", + ["two.js"] = "import './shared.js';", + ["shared.js"] = "import './leaf.js';", + ["leaf.js"] = "export default 1;", + }; + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleGraphDepth = 4); + + Invoking(() => engine.Modules.Import("root.js")) + .Should().Throw(); + } + + [Fact] + public void GraphDepth_LongSynchronousChainUsesIterativeLoading() + { + const int count = 1_000; + var modules = new Dictionary(count, StringComparer.Ordinal); + for (var i = 0; i < count - 1; i++) + { + modules[$"{i}.js"] = $"import './{i + 1}.js';"; + } + modules[$"{count - 1}.js"] = "export default 1;"; + + var engine = new Engine(o => + { + o.EnableModules(new DictLoader(modules)); + o.Modules.MaxModuleCount = count; + o.Modules.MaxModuleGraphDepth = count; + }); + + engine.Modules.Import("0.js"); + } + + // Resolution hops + + [Fact] + public void ResolutionHops_ExactBoundary_Succeeds() + { + // a -> b -> c = 3 hops (one per resolve: a, b, c). + var modules = new Dictionary + { + ["a.js"] = "import './b.js'; export default 1;", + ["b.js"] = "import './c.js'; export default 2;", + ["c.js"] = "export default 3;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleResolutionHops = 3); + + engine.Modules.Import("a.js"); + } + + [Fact] + public void ResolutionHops_OverLimit_ThrowsModuleGraphLimitException() + { + var modules = new Dictionary + { + ["a.js"] = "import './b.js'; export default 1;", + ["b.js"] = "import './c.js'; export default 2;", + ["c.js"] = "export default 3;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleResolutionHops = 2); + + Invoking(() => engine.Modules.Import("a.js")) + .Should().Throw(); + } + + [Fact] + public void ResolutionHops_PerOperation_NotCumulative() + { + var modules = new Dictionary + { + ["a.js"] = "export default 1;", + ["b.js"] = "export default 2;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleResolutionHops = 1); + + // Each import is its own operation with its own budget. + engine.Modules.Import("a.js"); + engine.Modules.Import("b.js"); + } + + [Fact] + public void ResolutionHops_AsyncGraphKeepsOneBudgetAfterRootSettles() + { + var loader = new DeferredLoader(); + var engine = new Engine(o => o.EnableModules(loader) + .Modules.MaxModuleResolutionHops = 2); + + engine.Modules.StartImport("a.js"); + loader.Deliver("a.js", "import './b.js';"); + engine.Advanced.ProcessTasks(); + loader.Deliver("./b.js", "import './c.js';"); + + Invoking(() => engine.Advanced.ProcessTasks()) + .Should().Throw(); + } + + [Fact] + public void ResolutionHops_RegistrationIndexingDoesNotConsumeTheBudget() + { + var engine = new Engine(o => o.EnableModules(new PrefixingLoader()) + .Modules.MaxModuleResolutionHops = 1); + for (var i = 0; i < 20; i++) + { + engine.Modules.Add($"module-{i}", $"export default {i};"); + } + + engine.Modules.Import("module-19"); + } + + [Fact] + public void ResolutionHops_InFlightDuplicateRequestsEachConsumeAHop() + { + var loader = new DeferredLoader(); + var engine = new Engine(o => o.EnableModules(loader) + .Modules.MaxModuleResolutionHops = 2); + + engine.Modules.StartImport("root.js"); + loader.Deliver( + "root.js", + "import defer * as deferred from './dep.js'; import './dep.js';"); + + Invoking(() => engine.Advanced.ProcessTasks()) + .Should().Throw(); + loader.LoadsFor("./dep.js").Should().Be(1); + } + + [Fact] + public void GraphDepth_AsyncGraphFailsFromThePump() + { + var loader = new DeferredLoader(); + var engine = new Engine(o => o.EnableModules(loader) + .Modules.MaxModuleGraphDepth = 2); + + engine.Modules.StartImport("a.js"); + loader.Deliver("a.js", "import './b.js';"); + engine.Advanced.ProcessTasks(); + loader.Deliver("./b.js", "import './c.js';"); + engine.Advanced.ProcessTasks(); + loader.Deliver("./c.js", "export default 1;"); + + Invoking(() => engine.Advanced.ProcessTasks()) + .Should().Throw(); + } + + [Fact] + public void ModuleCount_AsyncDiamondCoalescesSharedLoad() + { + var loader = new DeferredLoader(); + var engine = new Engine(o => o.EnableModules(loader) + .Modules.MaxModuleCount = 4); + + var operation = engine.Modules.StartImport("root.js"); + loader.Deliver("root.js", "import './left.js'; import './right.js';"); + engine.Advanced.ProcessTasks(); + loader.Deliver("./left.js", "import './shared.js';"); + engine.Advanced.ProcessTasks(); + loader.Deliver("./right.js", "import './shared.js';"); + engine.Advanced.ProcessTasks(); + loader.Deliver("./shared.js", "export default 1;"); + engine.Advanced.ProcessTasks(); + + operation.IsCompleted.Should().BeTrue(); + loader.LoadsFor("./shared.js").Should().Be(1); + } + + // Limit exception propagation + + [Fact] + public void LimitException_IsNotJavaScriptException() + { + var modules = new Dictionary + { + ["a.js"] = "export const x = 1;", + ["b.js"] = "export const y = 2;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleCount = 1); + + engine.Modules.Import("a.js"); + + try + { + engine.Modules.Import("b.js"); + throw new Exception("Should have thrown"); + } + catch (ModuleGraphLimitException) + { + // Expected: not a JavaScriptException, propagates like a constraint. + } + } + + [Fact] + public void LimitException_NotCatchableByScript() + { + // Dynamic import that would exceed the limit should not be catchable as a rejection. + var modules = new Dictionary + { + ["a.js"] = "export default 1;", + ["b.js"] = "export default 2;", + }; + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleCount = 1); + + engine.Modules.Import("a.js"); + + // StartImport should throw the limit exception directly. + Invoking(() => engine.Modules.StartImport("b.js")) + .Should().Throw(); + } + + [Fact] + public void LimitException_FromDynamicImportIsNotARejection() + { + var modules = new Dictionary + { + ["a.js"] = "export default 1;", + ["b.js"] = "export default 2;", + }; + var engine = new Engine(o => o.EnableModules(new DictLoader(modules)) + .Modules.MaxModuleCount = 1); + engine.Modules.Import("a.js"); + + Invoking(() => engine.Execute("import('b.js').catch(() => globalThis.caught = true);")) + .Should().Throw(); + } + + [Fact] + public void OrdinaryCancellationFromSynchronousLoaderIsALoadFailure() + { + var engine = new Engine(o => o.EnableModules( + new ThrowingModuleLoader(new OperationCanceledException("transport canceled")))); + + Invoking(() => engine.Modules.Import("cancel")) + .Should().Throw() + .WithMessage("*Could not load module cancel*"); + } + + [Fact] + public void OrdinaryTimeoutFromSynchronousLoaderIsALoadFailure() + { + var engine = new Engine(o => o.EnableModules( + new ThrowingModuleLoader(new TimeoutException("transport timed out")))); + + Invoking(() => engine.Modules.Import("timeout")) + .Should().Throw() + .WithMessage("*Could not load module timeout*"); + } + + [Fact] + public void OrdinaryTimeoutFromStartImportResolveIsALoadFailure() + { + var engine = new Engine(o => o.EnableModules( + new ThrowingResolveLoader(new TimeoutException("transport timed out")))); + + var operation = engine.Modules.StartImport("timeout"); + engine.Advanced.ProcessTasks(); + + operation.IsCompleted.Should().BeTrue(); + Invoking(() => operation.GetResult()).Should().Throw(); + } + + [Fact] + public void OrdinaryTimeoutFromAsyncLoaderIsALoadFailure() + { + var engine = new Engine(o => o.EnableModules( + new FaultingAsyncModuleLoader(new TimeoutException("transport timed out")))); + + var operation = engine.Modules.StartImport("timeout"); + engine.Advanced.ProcessTasks(); + + operation.IsCompleted.Should().BeTrue(); + Invoking(() => operation.GetResult()).Should().Throw(); + } + + [Fact] + public void RegisteredCancellationFromSynchronousLoaderPropagates() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var engine = new Engine(o => + { + o.CancellationToken(cancellation.Token); + o.EnableModules(new ThrowingModuleLoader( + new OperationCanceledException(cancellation.Token))); + }); + + Invoking(() => engine.Modules.Import("cancel")) + .Should().Throw(); + } + + [Fact] + public void EngineTimeoutFromSynchronousLoaderPropagates() + { + var engine = new Engine(o => + { + o.TimeoutInterval(TimeSpan.FromMilliseconds(1)); + o.EnableModules(new TimingOutEngineModuleLoader()); + }); + + Invoking(() => engine.Modules.Import("timeout")) + .Should().ThrowExactly(); + } + + [Fact] + public void EngineTimeoutFromAsyncLoaderPropagates() + { + var engine = new Engine(o => + { + o.TimeoutInterval(TimeSpan.FromMilliseconds(1)); + o.EnableModules(new TimingOutAsyncModuleLoader()); + }); + + Invoking(() => engine.Modules.StartImport("timeout")) + .Should().ThrowExactly(); + } + + [Fact] + public void EngineRegexTimeoutFromSynchronousLoaderPropagates() + { + var engine = new Engine(o => + { + o.RegexTimeoutInterval(TimeSpan.FromMilliseconds(1)); + o.EnableModules(new RegexTimingOutEngineModuleLoader()); + }); + + Invoking(() => engine.Modules.Import("timeout")) + .Should().ThrowExactly(); + } + + [Fact] + public void RegisteredCancellationFromAsyncLoaderPropagates() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var engine = new Engine(o => + { + o.CancellationToken(cancellation.Token); + o.EnableModules(new CancelingAsyncModuleLoader()); + }); + + Invoking(() => engine.Modules.StartImport("cancel")) + .Should().Throw(); + } + + // Custom policy + + [Fact] + public void CustomPolicy_DenyAll_ThrowsModuleResolutionException() + { + var modules = new Dictionary { ["a.js"] = "export default 1;" }; + + var engine = new Engine(o => + { + o.EnableModules(new DictLoader(modules)); + o.Modules.LoadPolicy = new DenyAllPolicy(); + }); + + Invoking(() => engine.Modules.Import("a.js")) + .Should().Throw(); + } + + [Fact] + public void CustomPolicy_AllowAll_Succeeds() + { + var modules = new Dictionary { ["a.js"] = "export default 1;" }; + + var engine = new Engine(o => + { + o.EnableModules(new DictLoader(modules)); + o.Modules.LoadPolicy = new AllowAllPolicy(); + }); + + engine.Modules.Import("a.js"); + } + + // Built-in allowlist policy + + [Fact] + public void AllowlistPolicy_AllowedScheme_Succeeds() + { + var modules = new Dictionary { ["a.js"] = "export default 1;" }; + + var policy = new ModuleAllowlistPolicy { AllowedSchemes = { "file" } }; + + var engine = new Engine(o => + { + o.EnableModules(new DictLoader(modules)); + o.Modules.LoadPolicy = policy; + }); + + engine.Modules.Import("a.js"); + } + + [Fact] + public void AllowlistPolicy_DisallowedScheme_Denied() + { + var modules = new Dictionary { ["a.js"] = "export default 1;" }; + + var policy = new ModuleAllowlistPolicy { AllowedSchemes = { "https" } }; + + var engine = new Engine(o => + { + o.EnableModules(new DictLoader(modules)); + o.Modules.LoadPolicy = policy; + }); + + Invoking(() => engine.Modules.Import("a.js")) + .Should().Throw(); + } + + [Fact] + public void AllowlistPolicy_BareSpecifier_DeniedByDefault() + { + var modules = new Dictionary { ["a.js"] = "export default 1;" }; + + // Any dimension configured triggers bare check. + var policy = new ModuleAllowlistPolicy { AllowedSchemes = { "file" } }; + + var engine = new Engine(o => + { + o.EnableModules(new BareLoader(modules)); + o.Modules.LoadPolicy = policy; + }); + + Invoking(() => engine.Modules.Import("a.js")) + .Should().Throw(); + } + + [Fact] + public void AllowlistPolicy_BareSpecifier_AllowedWhenExplicit() + { + var modules = new Dictionary { ["a.js"] = "export default 1;" }; + + var policy = new ModuleAllowlistPolicy + { + AllowedSchemes = { "file" }, + AllowBareSpecifiers = true, + }; + + var engine = new Engine(o => + { + o.EnableModules(new BareLoader(modules)); + o.Modules.LoadPolicy = policy; + }); + + engine.Modules.Import("a.js"); + } + + [Fact] + public void AllowlistPolicy_RequiresEveryConfiguredUriDimension() + { + var policy = new ModuleAllowlistPolicy + { + AllowedSchemes = { "https" }, + AllowedHosts = { "cdn.example.com" }, + AllowedOrigins = { "https://cdn.example.com" }, + }; + var engine = new Engine(o => + { + o.EnableModules(new UriLoader()); + o.Modules.LoadPolicy = policy; + }); + + engine.Modules.Import("https://cdn.example.com/a.js"); + Invoking(() => engine.Modules.Import("https://cdn.example.com:444/b.js")) + .Should().Throw(); + Invoking(() => engine.Modules.Import("https://other.example.com/c.js")) + .Should().Throw(); + } + + [Fact] + public void AllowlistPolicy_FileRootUsesASeparatorBoundary() + { + var policy = new ModuleAllowlistPolicy { AllowedFileRoots = { "/allowed" } }; + var allowed = new Engine(o => + { + o.EnableModules(new DictLoader( + new Dictionary { ["a.js"] = "export default 1;" }, + "/allowed")); + o.Modules.LoadPolicy = policy; + }); + var sibling = new Engine(o => + { + o.EnableModules(new DictLoader( + new Dictionary { ["a.js"] = "export default 1;" }, + "/allowed-sibling")); + o.Modules.LoadPolicy = policy; + }); + + allowed.Modules.Import("a.js"); + Invoking(() => sibling.Modules.Import("a.js")) + .Should().Throw(); + } + + [Fact] + public void AllowlistPolicy_FileRootsDenyNonFileUris() + { + var engine = new Engine(o => + { + o.EnableModules(new UriLoader()); + o.Modules.LoadPolicy = new ModuleAllowlistPolicy + { + AllowedFileRoots = { "/allowed" }, + }; + }); + + Invoking(() => engine.Modules.Import("https://cdn.example.com/a.js")) + .Should().Throw(); + } + + [Fact] + public void AllowlistPolicy_HostsDenyFileUris() + { + var engine = new Engine(o => + { + o.EnableModules(new DictLoader( + new Dictionary { ["a.js"] = "export default 1;" }, + "/allowed")); + o.Modules.LoadPolicy = new ModuleAllowlistPolicy + { + AllowedHosts = { "cdn.example.com" }, + }; + }); + + Invoking(() => engine.Modules.Import("a.js")) + .Should().Throw(); + } + + [Fact] + public void DefaultModuleLoader_RemainsRestrictedToItsBasePath() + { + var root = Path.Combine(Path.GetTempPath(), "jint-module-policy-" + Guid.NewGuid().ToString("N")); + var child = Path.Combine(root, "child"); + try + { + Directory.CreateDirectory(child); + File.WriteAllText(Path.Combine(root, "outside.js"), "export default 1;"); + var engine = new Engine(o => o.EnableModules(child)); + + Invoking(() => engine.Modules.Import("../outside.js")) + .Should().Throw(); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + // Invalid limits at construction + + [Fact] + public void InvalidLimits_Zero_ThrowsArgumentException() + { + Invoking(() => new Engine(o => + { + o.EnableModules(new DictLoader(new Dictionary())); + o.Modules.MaxModuleCount = 0; + })).Should().Throw(); + } + + [Fact] + public void InvalidLimits_Negative_ThrowsArgumentException() + { + Invoking(() => new Engine(o => + { + o.EnableModules(new DictLoader(new Dictionary())); + o.Modules.MaxModuleResolutionHops = -1; + })).Should().Throw(); + } + + // Default (unlimited) behavior unchanged + + [Fact] + public void DefaultLimits_NoRestriction() + { + var modules = new Dictionary(); + for (var i = 0; i < 50; i++) + { + modules[$"m{i}.js"] = "export default " + i + ";"; + } + + var engine = new Engine(o => o.EnableModules(new DictLoader(modules))); + + for (var i = 0; i < 50; i++) + { + engine.Modules.Import($"m{i}.js"); + } + } + + // Modules disabled unchanged + + [Fact] + public void ModulesDisabled_UnchangedBehavior() + { + var engine = new Engine(); + Invoking(() => engine.Modules.Import("anything")) + .Should().Throw(); + } + +} diff --git a/Jint/Engine.Modules.cs b/Jint/Engine.Modules.cs index 86c4af3e67..21c0712567 100644 --- a/Jint/Engine.Modules.cs +++ b/Jint/Engine.Modules.cs @@ -113,6 +113,14 @@ public class ModuleOperations private JsValue? _lastLoadFailureError; private ExceptionDispatchInfo? _lastLoadFailure; + // --- Module graph limit accounting --- + + /// Cumulative count of distinct module records registered in this engine. + private int _registeredModuleCount; + + /// Cumulative total UTF-8 source bytes of all registered modules. + private long _totalModuleSourceBytes; + public ModuleOperations(Engine engine, IModuleLoader moduleLoader) { ModuleLoader = moduleLoader; @@ -144,9 +152,117 @@ public ModuleOperations(Engine engine, IModuleLoader moduleLoader) /// internal int PendingModuleLoadCount => _pendingLoads?.Count ?? 0; + // --- Guarded resolution and registration --- + + /// + /// Resolves a module request through the loader, enforcing policy and consuming one resolution hop. + /// All import-driven resolution call sites must go through this method so policy cannot be bypassed. + /// + private ResolvedSpecifier GuardedResolve( + string? referrerLocation, + ModuleRequest request, + ModuleLoadBudget budget) + { + budget.ConsumeResolutionHop(request.Specifier); + + var resolved = ModuleLoader.Resolve(referrerLocation, request); + + var policy = _engine.Options.Modules.LoadPolicy; + if (policy is not null && !policy.AllowLoad(referrerLocation, request, resolved)) + { + Throw.ModuleResolutionException("Module load denied by policy", request.Specifier, referrerLocation); + } + + return resolved; + } + + /// + /// Resolves for registration indexing. Applies policy but does NOT consume a resolution hop, so + /// accumulated registrations do not exhaust a per-operation budget on pooled engines. + /// + private ResolvedSpecifier? GuardedResolveForIndex(string specifier) + { + var resolved = ModuleLoader.Resolve(referencingModuleLocation: null, new ModuleRequest(specifier, [])); + + var policy = _engine.Options.Modules.LoadPolicy; + if (policy is not null && !policy.AllowLoad(null, new ModuleRequest(specifier, []), resolved)) + { + return null; // Silently skip indexing for denied specifiers. + } + + return resolved; + } + + /// + /// Checks module count and source byte limits, then registers the module. + /// + internal void EnsureModuleRegistrationAllowed( + ModuleCacheKey cacheKey, + long sourceByteCount) + { + var opts = _engine.Options.Modules; + + if (opts.MaxModuleCount != int.MaxValue && _registeredModuleCount >= opts.MaxModuleCount) + { + throw new ModuleGraphLimitException( + $"Module count limit of {opts.MaxModuleCount} exceeded while registering module '{cacheKey.Key}'."); + } + + if (sourceByteCount < 0) + { + Throw.InvalidOperationException("A module source byte count cannot be negative."); + } + + if (opts.MaxTotalModuleSourceBytes != long.MaxValue + && sourceByteCount > opts.MaxTotalModuleSourceBytes - _totalModuleSourceBytes) + { + throw new ModuleGraphLimitException( + $"Module total source byte limit of {opts.MaxTotalModuleSourceBytes} exceeded while registering module '{cacheKey.Key}'."); + } + } + + internal void RegisterModuleWithAccounting(ModuleCacheKey cacheKey, Module module, long sourceByteCount) + { + if (_modules.TryGetValue(cacheKey, out var existing)) + { + if (!ReferenceEquals(existing, module)) + { + Throw.InvalidOperationException( + $"Module '{cacheKey.Key}' is already registered with a different module record."); + } + + return; + } + + EnsureModuleRegistrationAllowed(cacheKey, sourceByteCount); + var sourceBytesReserved = false; + _registeredModuleCount++; + try + { + _totalModuleSourceBytes = checked(_totalModuleSourceBytes + sourceByteCount); + sourceBytesReserved = true; + // Reserve the budget before the debugger callback inside RegisterModule can re-enter the + // engine. A nested load must observe this module's pending charge rather than over-commit the + // limit, and a throwing callback rolls the reservation back with the failed registration. + RegisterModule(cacheKey, module); + } + catch + { + _registeredModuleCount--; + if (sourceBytesReserved) + { + _totalModuleSourceBytes -= sourceByteCount; + } + throw; + } + } + internal Module Load(string? referencingModuleLocation, ModuleRequest request) { - var moduleResolution = ModuleLoader.Resolve(referencingModuleLocation, request); + var moduleResolution = GuardedResolve( + referencingModuleLocation, + request, + new ModuleLoadBudget(_engine.Options.Modules)); var cacheKey = ModuleCacheKey.From(moduleResolution); if (_modules.TryGetValue(cacheKey, out var module)) @@ -207,14 +323,15 @@ private void LoadImportedModuleCore(IScriptOrModule? referrer, string? referrerL ResolvedSpecifier moduleResolution; try { - moduleResolution = ModuleLoader.Resolve(referrerLocation, request); + moduleResolution = GuardedResolve(referrerLocation, request, payload.Budget); } catch (JavaScriptException ex) { Finish(module: null, ex.Error, ex); return; } - catch (Exception ex) when (_engine._eventLoop.IsRunningJob && !ModuleLoadCompletion.MustPropagate(ex)) + catch (Exception ex) when (_engine._eventLoop.IsRunningJob + && !ModuleLoadCompletion.MustPropagateLoaderException(_engine, ex)) { // A loader signals refusal with whatever exception it likes — DefaultModuleLoader throws // ModuleResolutionException, a sibling of JavaScriptException that the catch above does not @@ -276,6 +393,7 @@ private void LoadImportedModuleCore(IScriptOrModule? referrer, string? referrerL if (AsyncModuleLoader is not null) { + EnsureModuleRegistrationAllowed(cacheKey, sourceByteCount: 0); var completion = new ModuleLoadCompletion(this, moduleResolution, cacheKey); completion.AddWaiter(referrer, referrerLocation, request, payload); (_pendingLoads ??= new Dictionary())[cacheKey] = completion; @@ -293,7 +411,7 @@ private void LoadImportedModuleCore(IScriptOrModule? referrer, string? referrerL // a rejection rather than an exception on whatever thread happened to be evaluating. The // filter is what keeps an exception thrown *after* an inline settle propagating: SetError // on a settled completion is a no-op, and Build deliberately rethrows non-module failures. - if (ModuleLoadCompletion.MustPropagate(ex)) + if (ModuleLoadCompletion.MustPropagateLoaderException(_engine, ex)) { // A constraint that becomes a rejection no longer bounds anything: the same cancelled // engine with a synchronous loader surfaces ExecutionCanceledException, and a host's @@ -490,6 +608,7 @@ private void IndexBuilderKeys() // the pass as already running and cannot start a second one over the same registrations. The // trade-off is that such a re-entrant call consults the index before this pass has finished // filling it and may miss a still-unindexed registration. + var previousIndexedBuildersVersion = _indexedBuildersVersion; _indexedBuildersVersion = _buildersVersion; List? pending = null; @@ -506,69 +625,89 @@ private void IndexBuilderKeys() return; } - foreach (var specifier in pending) + string? indexingSpecifier = null; + try { - // Marked before resolving, so a specifier the loader refuses is attempted once rather than on - // every load. - (_indexedBuilders ??= new HashSet(StringComparer.Ordinal)).Add(specifier); - - string? resolvedKey; - try + foreach (var specifier in pending) { - // A registration is a top-level name, so it is resolved the way Modules.Import resolves - // one: against no referrer. - resolvedKey = ModuleLoader.Resolve(referencingModuleLocation: null, new ModuleRequest(specifier, [])).Key; - } + indexingSpecifier = specifier; + + // Marked before resolving, so a specifier the loader refuses is attempted once rather than + // on every load. + (_indexedBuilders ??= new HashSet(StringComparer.Ordinal)).Add(specifier); + + string? resolvedKey; + try + { + // A registration is a top-level name, so it is resolved the way Modules.Import resolves + // one: against no referrer. Uses GuardedResolveForIndex so policy applies but no + // resolution-hop budget is consumed — registration indexing is not per-import work. + var resolved = GuardedResolveForIndex(specifier); + resolvedKey = resolved?.Key; + } #pragma warning disable CA1031 // a loader may signal "I will not resolve that" with any exception it likes - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException + && !ModuleLoadCompletion.MustPropagateLoaderException(_engine, ex)) #pragma warning restore CA1031 - { - // A loader is free to reject a specifier outright - DefaultModuleLoader throws for a - // directory import, an unauthorized path or an invalid specifier. Such a registration is - // simply left unindexed, leaving it exactly as reachable as it was before this index - // existed, and the attempt is not repeated. Letting the exception out would instead fail - // an unrelated import that merely happened to run the indexing pass. Cancellation is - // different: it is the host calling off the whole operation, not the loader refusing one - // name, so it propagates. - continue; - } + { + // A loader is free to reject a specifier outright - DefaultModuleLoader throws for a + // directory import, an unauthorized path or an invalid specifier. Such a registration is + // simply left unindexed, leaving it exactly as reachable as it was before this index + // existed, and the attempt is not repeated. Letting the exception out would instead fail + // an unrelated import that merely happened to run the indexing pass. Cancellation is + // different: it is the host calling off the whole operation, not the loader refusing one + // name, so it propagates. + continue; + } - if (resolvedKey is null) - { - // A loader compiled without nullable annotations can hand back a null key instead of - // throwing; treat it as the refusal it is rather than failing the triggering import. - continue; - } + if (resolvedKey is null) + { + // A loader compiled without nullable annotations can hand back a null key instead of + // throwing; treat it as the refusal it is rather than failing the triggering import. + continue; + } - if (string.Equals(resolvedKey, specifier, StringComparison.Ordinal)) - { - continue; - } + if (string.Equals(resolvedKey, specifier, StringComparison.Ordinal)) + { + continue; + } - string? collidingRegistration = null; - if (_builders.ContainsKey(resolvedKey)) - { - collidingRegistration = resolvedKey; - } - else if (_builderKeys is not null - && _builderKeys.TryGetValue(resolvedKey, out var other) - && !string.Equals(other, specifier, StringComparison.Ordinal) - && _builders.ContainsKey(other)) - { - collidingRegistration = other; - } + string? collidingRegistration = null; + if (_builders.ContainsKey(resolvedKey)) + { + collidingRegistration = resolvedKey; + } + else if (_builderKeys is not null + && _builderKeys.TryGetValue(resolvedKey, out var other) + && !string.Equals(other, specifier, StringComparison.Ordinal) + && _builders.ContainsKey(other)) + { + collidingRegistration = other; + } + + if (collidingRegistration is not null) + { + // Two live registrations sharing one resolved identity: whichever lost would be silently + // unreachable under every name - its own raw name resolves to the shared key too - which + // is the exact failure this index exists to eliminate. So it fails as loudly as + // registering the same spelling twice fails in Add. + Throw.InvalidOperationException( + $"Module '{specifier}' resolves to '{resolvedKey}', which already identifies the registration '{collidingRegistration}'. Two registrations must not resolve to the same specifier."); + } - if (collidingRegistration is not null) + (_builderKeys ??= new Dictionary(StringComparer.Ordinal))[resolvedKey] = specifier; + indexingSpecifier = null; + } + } + catch + { + if (indexingSpecifier is not null) { - // Two live registrations sharing one resolved identity: whichever lost would be silently - // unreachable under every name - its own raw name resolves to the shared key too - which - // is the exact failure this index exists to eliminate. So it fails as loudly as - // registering the same spelling twice fails in Add. - Throw.InvalidOperationException( - $"Module '{specifier}' resolves to '{resolvedKey}', which already identifies the registration '{collidingRegistration}'. Two registrations must not resolve to the same specifier."); + _indexedBuilders?.Remove(indexingSpecifier); } - (_builderKeys ??= new Dictionary(StringComparer.Ordinal))[resolvedKey] = specifier; + _indexedBuildersVersion = previousIndexedBuildersVersion; + throw; } } @@ -600,10 +739,12 @@ private BuilderModule LoadFromBuilder(string specifier, ModuleBuilder moduleBuil // registration name would leave a builder module reached through the index resolving its nested // imports against a name the loader never produced. A module supplied pre-compiled keeps its // prepare-time name instead; see ModuleBuilder.AddModule. + var sourceByteCount = moduleBuilder.GetSourceByteCount(); + EnsureModuleRegistrationAllowed(cacheKey, sourceByteCount); var parsedModule = moduleBuilder.Parse(cacheKey.Key); var hasTopLevelAwait = HoistingScope.HasTopLevelAwait(parsedModule.Program!); var module = new BuilderModule(_engine, _engine.Realm, in parsedModule, location: parsedModule.Program!.Location.SourceFile, async: hasTopLevelAwait); - RegisterModule(cacheKey, module); + RegisterModuleWithAccounting(cacheKey, module, sourceByteCount); moduleBuilder.BindExportedValues(module); _builders.Remove(specifier); return module; @@ -611,8 +752,10 @@ private BuilderModule LoadFromBuilder(string specifier, ModuleBuilder moduleBuil private Module LoadFromModuleLoader(ResolvedSpecifier moduleResolution, ModuleCacheKey cacheKey) { + EnsureModuleRegistrationAllowed(cacheKey, sourceByteCount: 0); var module = ModuleLoader.LoadModule(_engine, moduleResolution); - RegisterModule(cacheKey, module); + var sourceBytes = module.SourceByteLength; + RegisterModuleWithAccounting(cacheKey, module, sourceBytes); return module; } @@ -706,7 +849,8 @@ internal ObjectInstance Import(ModuleRequest request, string? referencingModuleL private ObjectInstance ImportCore(ModuleRequest request, string? referencingModuleLocation) { - var module = LoadRootModule(request, referencingModuleLocation); + var budget = new ModuleLoadBudget(_engine.Options.Modules); + var module = LoadRootModule(request, referencingModuleLocation, budget); // The specification's load phase. Everything the module imports has to be present before it can // be linked, and with an asynchronous loader "present" is not something the engine can arrange by @@ -719,7 +863,7 @@ private ObjectInstance ImportCore(ModuleRequest request, string? referencingModu // load either way. if (module is CyclicModule { Status: ModuleStatus.New }) { - RunLoadPhaseBlocking(module); + RunLoadPhaseBlocking(module, budget); } if (module is not CyclicModule cyclicModule) @@ -798,7 +942,11 @@ private ModuleImportOperation StartImportCore(string specifier, string? referenc { var request = new ModuleRequest(specifier, []); var capability = PromiseConstructor.NewPromiseCapability(_engine, _engine.Realm.Intrinsics.Promise); - var payload = new DynamicImportPayload(_engine, request, capability); + var payload = new DynamicImportPayload( + _engine, + request, + capability, + new ModuleLoadBudget(_engine.Options.Modules)); try { @@ -808,7 +956,7 @@ private ModuleImportOperation StartImportCore(string specifier, string? referenc { capability.Reject(ex.Error); } - catch (Exception ex) when (!ModuleLoadCompletion.MustPropagate(ex)) + catch (Exception ex) when (!ModuleLoadCompletion.MustPropagateLoaderException(_engine, ex)) { // Resolution refusals arrive as ModuleResolutionException — not JavaScriptException — and the // same failure delivered through the loader's asynchronous settle would arrive as the @@ -817,7 +965,6 @@ private ModuleImportOperation StartImportCore(string specifier, string? referenc // start call. capability.Reject(_engine.Realm.Intrinsics.Error.Construct(ex.Message)); } - var operation = new ModuleImportOperation(_engine, capability.PromiseInstance); // Reactions rather than polling, so that the operation is also what marks the rejection handled and @@ -892,9 +1039,12 @@ private static async Task CompleteImportAsync(Task task /// Loads the root of a module graph: a HostLoadImportedModule call like any other, so with an /// asynchronous loader it may only finish on a later turn of the event loop. /// - private Module LoadRootModule(ModuleRequest request, string? referencingModuleLocation) + private Module LoadRootModule( + ModuleRequest request, + string? referencingModuleLocation, + ModuleLoadBudget budget) { - var payload = new RootModuleLoadPayload(); + var payload = new RootModuleLoadPayload(budget); _engine._host.LoadImportedModule(referrer: null, referencingModuleLocation, request, payload); if (!payload.IsCompleted) @@ -937,9 +1087,12 @@ private void ThrowIfBlockedInsideJob(string specifier) /// Runs and, for an asynchronous loader, drives the event /// loop until the graph is in. Throws whatever the load failed with. /// - private void RunLoadPhaseBlocking(Module module) + private void RunLoadPhaseBlocking(Module module, ModuleLoadBudget budget) { - if (module.LoadRequestedModules() is not JsPromise loadPromise) + var loadResult = module is CyclicModule cyclicModule + ? cyclicModule.LoadRequestedModulesWithBudget(budget) + : module.LoadRequestedModules(); + if (loadResult is not JsPromise loadPromise) { return; } diff --git a/Jint/Engine.cs b/Jint/Engine.cs index 87df6a4de5..29bbbc97d7 100644 --- a/Jint/Engine.cs +++ b/Jint/Engine.cs @@ -1027,6 +1027,8 @@ private Engine(Options? options, Action? configure) Options.Apply(this); + ValidateModuleOptions(Options.Modules); + // Read after Options.Apply so the snapshot observes the same value JintCallStack does // (Apply runs the user's configuration callbacks, which can still touch Constraints). _maxRecursionDepth = Options.Constraints.MaxRecursionDepth; @@ -1056,6 +1058,29 @@ private Engine(Options? options, Action? configure) _defaultParser = JintParser.Create(_defaultParserOptions, in _parsingConstraints); } + private static void ValidateModuleOptions(Options.ModuleOptions modules) + { + if (modules.MaxModuleCount is <= 0 and not int.MaxValue) + { + throw new ArgumentException("MaxModuleCount must be positive or int.MaxValue.", nameof(modules)); + } + + if (modules.MaxTotalModuleSourceBytes is <= 0 and not long.MaxValue) + { + throw new ArgumentException("MaxTotalModuleSourceBytes must be positive or long.MaxValue.", nameof(modules)); + } + + if (modules.MaxModuleGraphDepth is <= 0 and not int.MaxValue) + { + throw new ArgumentException("MaxModuleGraphDepth must be positive or int.MaxValue.", nameof(modules)); + } + + if (modules.MaxModuleResolutionHops is <= 0 and not int.MaxValue) + { + throw new ArgumentException("MaxModuleResolutionHops must be positive or int.MaxValue.", nameof(modules)); + } + } + private void Reset() { _host = Options.Host.Factory(this); diff --git a/Jint/Options.cs b/Jint/Options.cs index eff9b976ef..ddfdac2107 100644 --- a/Jint/Options.cs +++ b/Jint/Options.cs @@ -896,6 +896,54 @@ public class ModuleOptions /// Module loader implementation, by default exception will be thrown if module loading is not enabled. /// public IModuleLoader ModuleLoader { get; set; } = FailFastModuleLoader.Instance; + + /// + /// Maximum number of distinct module records an engine may register over its lifetime. Charged once per + /// successfully registered module (cache key); cached, duplicate or coalesced loads do not recharge. + /// Programmatic modules () participate. + /// means unlimited (the default). A non-positive finite value throws at + /// engine construction. + /// + public int MaxModuleCount { get; set; } = int.MaxValue; + + /// + /// Maximum cumulative UTF-8 encoded source bytes across all modules registered in an engine's lifetime. + /// Charged once per distinct module at registration time. Modules whose original encoded source size is + /// unknowable — exports-only builders, prepared modules, and custom records — + /// charge 0 bytes; raw byte modules use their exact byte length; string sources use their UTF-8 byte + /// count. means unlimited (the default). A non-positive finite value throws + /// at engine construction. + /// + public long MaxTotalModuleSourceBytes { get; set; } = long.MaxValue; + + /// + /// Maximum conservative import-chain depth in a module graph load. Root is depth 1. Strongly connected + /// cycles are collapsed for traversal but contribute every module in the cycle to the depth; the longest + /// path through that acyclic component graph is enforced. The result is deterministic regardless of DFS + /// sibling order or asynchronous completion order and never lets a cycle increase depth without bound. + /// A depth that exceeds this limit throws . + /// means unlimited (the default). A non-positive finite value throws at + /// engine construction. + /// + public int MaxModuleGraphDepth { get; set; } = int.MaxValue; + + /// + /// Maximum number of module-loader calls per top-level import or + /// load operation. Cached [[LoadedModules]] hits do not count. The budget resets for each + /// , + /// , or host call to + /// . Registration indexing does not consume hops, so pooled + /// engines never fail from accumulated registrations. means unlimited + /// (the default). A non-positive finite value throws at engine construction. + /// + public int MaxModuleResolutionHops { get; set; } = int.MaxValue; + + /// + /// An optional policy consulted after resolution but before a module is loaded or fetched. A denial + /// throws and follows existing sync/rejection behaviour. + /// null means no policy (the default — everything the loader resolves is allowed). + /// + public IModuleLoadPolicy? LoadPolicy { get; set; } } /// diff --git a/Jint/Runtime/ConstraintFailure.cs b/Jint/Runtime/ConstraintFailure.cs index b22327420e..0c88e91bc5 100644 --- a/Jint/Runtime/ConstraintFailure.cs +++ b/Jint/Runtime/ConstraintFailure.cs @@ -1,3 +1,5 @@ +using Jint.Runtime.Modules; + namespace Jint.Runtime; /// @@ -31,6 +33,7 @@ or ParsingLimitException or MemoryLimitExceededException or StatementsCountOverflowException or RecursionDepthOverflowException + or ModuleGraphLimitException or TimeoutException or OperationCanceledException or PlatformNotSupportedException diff --git a/Jint/Runtime/Host.cs b/Jint/Runtime/Host.cs index bcbcd9fd43..d73c5d0785 100644 --- a/Jint/Runtime/Host.cs +++ b/Jint/Runtime/Host.cs @@ -203,7 +203,11 @@ internal virtual void FinishLoadingImportedModule( /// onto the previous stage's promise, so a load that is still in flight simply means the next stage runs /// on a later event-loop turn. /// - internal virtual void ContinueDynamicImport(Module module, ModuleRequest moduleRequest, PromiseCapability payload) + internal virtual void ContinueDynamicImport( + Module module, + ModuleRequest moduleRequest, + PromiseCapability payload, + ModuleLoadBudget budget) { // Step 3 of https://tc39.es/proposal-source-phase-imports/#sec-ContinueDynamicImport: a source-phase // import settles here and goes no further — the module is never linked or evaluated. It resolves with @@ -239,7 +243,9 @@ internal virtual void ContinueDynamicImport(Module module, ModuleRequest moduleR JsValue loadResult; try { - loadResult = module.LoadRequestedModules(); + loadResult = module is CyclicModule cyclicModule + ? cyclicModule.LoadRequestedModulesWithBudget(budget) + : module.LoadRequestedModules(); } catch (JavaScriptException ex) { diff --git a/Jint/Runtime/Modules/AsyncModuleLoader.cs b/Jint/Runtime/Modules/AsyncModuleLoader.cs index 9b43928859..db5de4340e 100644 --- a/Jint/Runtime/Modules/AsyncModuleLoader.cs +++ b/Jint/Runtime/Modules/AsyncModuleLoader.cs @@ -177,7 +177,14 @@ private static bool TryFail(Task task, ModuleLoadCompletion completion) if (task.IsCanceled) { - completion.SetError($"Loading module '{completion.Resolved.ModuleRequest.Specifier}' was canceled."); + if (completion.CancellationToken.IsCancellationRequested) + { + completion.SetConstraintError(new OperationCanceledException(completion.CancellationToken)); + } + else + { + completion.SetError($"Loading module '{completion.Resolved.ModuleRequest.Specifier}' was canceled."); + } return true; } diff --git a/Jint/Runtime/Modules/CyclicModule.cs b/Jint/Runtime/Modules/CyclicModule.cs index a4427beebe..2c38cbc63f 100644 --- a/Jint/Runtime/Modules/CyclicModule.cs +++ b/Jint/Runtime/Modules/CyclicModule.cs @@ -60,6 +60,11 @@ internal CyclicModule(Engine engine, Realm realm, string location, bool isAsync) /// /// public override JsValue LoadRequestedModules() + { + return LoadRequestedModulesWithBudget(new ModuleLoadBudget(_engine.Options.Modules)); + } + + internal JsValue LoadRequestedModulesWithBudget(ModuleLoadBudget budget) { var capability = PromiseConstructor.NewPromiseCapability(_engine, _realm.Intrinsics.Promise); @@ -69,53 +74,85 @@ public override JsValue LoadRequestedModules() // unhandled-rejection event for a promise the host never created and cannot observe. ((JsPromise) capability.PromiseInstance).PromiseIsHandled = true; - var state = new GraphLoadingState(capability); - InnerModuleLoading(state, this); + var state = new GraphLoadingState(capability, this, budget); + InnerModuleLoading(state, this, depth: 1); return capability.PromiseInstance; } /// /// https://tc39.es/ecma262/#sec-InnerModuleLoading /// - internal static void InnerModuleLoading(GraphLoadingState state, Module module) + internal static void InnerModuleLoading(GraphLoadingState state, Module module, int depth) { if (!state.IsLoading) { Throw.InvalidOperationException("Error while loading module: the graph loading state is no longer loading"); } - if (module is CyclicModule cyclicModule - && cyclicModule.Status == ModuleStatus.New - && !state.Visited.Contains(cyclicModule)) + state.Enqueue(module, depth); + if (!state.TryBeginProcessing()) { - state.Visited.Add(cyclicModule); - state.PendingModulesCount += cyclicModule._requestedModules.Count; + return; + } - foreach (var request in cyclicModule._requestedModules) + try + { + while (state.IsLoading && state.TryDequeue(out var pending)) { - if (cyclicModule.TryGetLoadedModule(request, out var loaded)) + var pendingModule = pending.Module; + if (pendingModule is CyclicModule { Status: ModuleStatus.New } cyclicModule + && !state.TryExpand(cyclicModule)) { - InnerModuleLoading(state, loaded); + state.SettleOnePendingModule(); + continue; } - else + + var maximumDepth = state.Budget.MaximumGraphDepth; + if (maximumDepth != int.MaxValue && pending.Depth > maximumDepth) { - // HostLoadImportedModule finishes the request - now or on a later turn - by calling - // FinishLoadingImportedModule, which re-enters this method through - // GraphLoadingState.Continue. - cyclicModule._engine._host.LoadImportedModule(cyclicModule, request, state); + state.IsLoading = false; + throw new ModuleGraphLimitException( + $"Module graph depth limit of {maximumDepth} exceeded while loading '{pendingModule.Location}'."); } - // Step 2.d.iii: a failed sibling ends the whole load; the remaining requests must not be - // started. Only reachable for a loader that answers synchronously - an asynchronous one has - // not settled anything by the time control returns here. - if (!state.IsLoading) + if (pendingModule is CyclicModule { Status: ModuleStatus.New } newCyclicModule) { - return; + state.PendingModulesCount += newCyclicModule._requestedModules.Count; + var childDepth = checked(pending.Depth + 1); + + foreach (var request in newCyclicModule._requestedModules) + { + if (newCyclicModule.TryGetLoadedModule(request, out var loaded)) + { + state.RecordEdge(newCyclicModule, loaded); + state.Enqueue(loaded, childDepth); + } + else + { + // A synchronous completion enqueues its module while this loop is running; an + // asynchronous one resumes the same loop on the later engine turn that settles it. + newCyclicModule._engine._host.LoadImportedModule( + newCyclicModule, + request, + new GraphModuleLoadPayload(state, newCyclicModule, childDepth)); + } + + // Step 2.d.iii: a failed sibling ends the whole load; the remaining requests must not be + // started. Only reachable for a loader that answers synchronously. + if (!state.IsLoading) + { + return; + } + } } + + state.SettleOnePendingModule(); } } - - state.SettleOnePendingModule(); + finally + { + state.EndProcessing(); + } } /// diff --git a/Jint/Runtime/Modules/IModuleLoadPolicy.cs b/Jint/Runtime/Modules/IModuleLoadPolicy.cs new file mode 100644 index 0000000000..cf0a55fa9a --- /dev/null +++ b/Jint/Runtime/Modules/IModuleLoadPolicy.cs @@ -0,0 +1,30 @@ +namespace Jint.Runtime.Modules; + +/// +/// A host-supplied policy that can deny a module load based on the resolved specifier and referrer context. +/// Implement this interface and assign it to to restrict which +/// modules may be loaded. +/// +/// +/// +/// Policy is consulted after the module loader has resolved the specifier but before the module source is +/// fetched or built. A denial throws and follows existing +/// sync/rejection behavior — it does not propagate like a constraint exception. +/// +/// +/// The 's own base-path restriction runs earlier and independently; a policy +/// registered here applies to the final , regardless of loader. +/// +/// +public interface IModuleLoadPolicy +{ + /// + /// Returns true if the module identified by may be loaded. + /// + /// The of the importing module, or + /// null for a root/host import. + /// The import request as written in source. + /// The resolved specifier the module loader produced. + /// true to allow; false to deny. + bool AllowLoad(string? referrerLocation, ModuleRequest request, ResolvedSpecifier resolved); +} diff --git a/Jint/Runtime/Modules/Module.cs b/Jint/Runtime/Modules/Module.cs index b0465aa344..0aabab3bc0 100644 --- a/Jint/Runtime/Modules/Module.cs +++ b/Jint/Runtime/Modules/Module.cs @@ -54,6 +54,14 @@ internal Module(Engine engine, Realm realm, string location) : base(InternalType Location = location; } + /// + /// The UTF-8 encoded byte length of the module's original source, set at creation time. Modules whose + /// source size is unknowable — exports-only builders, prepared modules, and custom + /// records — report 0. Raw byte modules use their exact byte length; string sources use their UTF-8 + /// byte count. + /// + internal long SourceByteLength { get; set; } + public abstract List GetExportedNames(List exportStarSet = null); internal abstract ResolvedBinding ResolveExport(string exportName, List resolveSet = null); public abstract void Link(); diff --git a/Jint/Runtime/Modules/ModuleAllowlistPolicy.cs b/Jint/Runtime/Modules/ModuleAllowlistPolicy.cs new file mode 100644 index 0000000000..2c8825de25 --- /dev/null +++ b/Jint/Runtime/Modules/ModuleAllowlistPolicy.cs @@ -0,0 +1,207 @@ +namespace Jint.Runtime.Modules; + +/// +/// A reusable that allowlists modules by URI scheme, host, origin +/// (scheme+host+effective port), and filesystem root path. +/// +/// +/// +/// Composition rules: dimensions are combined with AND — a module must satisfy every configured +/// dimension. Within each dimension, entries are combined with OR — satisfying any one entry in a list is +/// enough. An unconfigured dimension (empty list) imposes no restriction. +/// +/// +/// A file target cannot satisfy a configured host or origin dimension, and a non-file target cannot satisfy a +/// configured filesystem-root dimension, so either mismatch is denied. Configure only dimensions meaningful +/// for the target kind, plus when both file and non-file targets are permitted. +/// +/// +/// Bare specifiers — those with no URI at all — are denied by default when any dimension is configured. +/// Set to true to permit them. When no dimension is configured +/// (all lists empty), everything is allowed. +/// +/// +/// Filesystem path comparisons are case-insensitive on Windows and case-sensitive elsewhere. Both configured +/// roots and resolved files are canonicalized with and checked with +/// separator-aware boundary logic so /app does not match /application/. Relative roots never +/// match. This is lexical containment: it does not resolve symbolic links or Windows reparse points, so an +/// allowed root must not contain attacker-controlled links to files outside it. +/// +/// +public sealed class ModuleAllowlistPolicy : IModuleLoadPolicy +{ + private static readonly StringComparison FilePathComparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + /// + /// Allowed URI schemes (case-insensitive). Example: "https", "file". + /// Empty means no scheme restriction. + /// + public List AllowedSchemes { get; } = new(); + + /// + /// Allowed exact hosts (case-insensitive). Example: "cdn.example.com". + /// A configured host list denies file URIs, which cannot satisfy it. Empty means no host restriction. + /// + public List AllowedHosts { get; } = new(); + + /// + /// Allowed origins as scheme://host[:port] (case-insensitive). Omitted default ports are compared by + /// their effective value, so "https://cdn.example.com" matches port 443. + /// A configured origin list denies file URIs, which cannot satisfy it. Empty means no origin restriction. + /// + public List AllowedOrigins { get; } = new(); + + /// + /// Allowed filesystem root directories. Each entry must be an absolute path. A module file must reside under + /// (or equal to) at least one entry. Example: "/app/scripts". + /// A configured root list denies non-file URIs, which cannot satisfy it. Empty means no file-path + /// restriction. + /// + public List AllowedFileRoots { get; } = new(); + + /// + /// Whether to allow bare specifiers (those that resolve with no URI). Default is false. When any + /// policy dimension is configured and this is false, bare specifiers are denied. When no policy is + /// active (the engine default), this property has no effect. + /// + public bool AllowBareSpecifiers { get; set; } + + private bool HasAnyRestriction => + AllowedSchemes.Count > 0 + || AllowedHosts.Count > 0 + || AllowedOrigins.Count > 0 + || AllowedFileRoots.Count > 0; + + /// + public bool AllowLoad(string? referrerLocation, ModuleRequest request, ResolvedSpecifier resolved) + { + var uri = resolved.Uri; + + // Bare / no-URI specifiers. + if (uri is null || !uri.IsAbsoluteUri) + { + return !HasAnyRestriction || AllowBareSpecifiers; + } + + // Scheme check — applies to every absolute URI. + if (AllowedSchemes.Count > 0) + { + var allowed = false; + foreach (var scheme in AllowedSchemes) + { + if (string.Equals(uri.Scheme, scheme, StringComparison.OrdinalIgnoreCase)) + { + allowed = true; + break; + } + } + + if (!allowed) + { + return false; + } + } + + if (uri.IsFile) + { + // A file URI cannot satisfy a host or origin restriction. + if (AllowedHosts.Count > 0 || AllowedOrigins.Count > 0) + { + return false; + } + + if (AllowedFileRoots.Count > 0) + { + var filePath = uri.LocalPath; + var allowed = false; + foreach (var root in AllowedFileRoots) + { + if (IsUnderRoot(filePath, root)) + { + allowed = true; + break; + } + } + + if (!allowed) + { + return false; + } + } + } + else + { + // A non-file URI cannot satisfy a filesystem root restriction. + if (AllowedFileRoots.Count > 0) + { + return false; + } + + if (AllowedHosts.Count > 0) + { + var allowed = false; + foreach (var host in AllowedHosts) + { + if (string.Equals(uri.IdnHost, host, StringComparison.OrdinalIgnoreCase)) + { + allowed = true; + break; + } + } + + if (!allowed) + { + return false; + } + } + + if (AllowedOrigins.Count > 0) + { + var allowed = false; + foreach (var o in AllowedOrigins) + { + if (Uri.TryCreate(o, UriKind.Absolute, out var allowedOrigin) + && string.Equals(uri.Scheme, allowedOrigin.Scheme, StringComparison.OrdinalIgnoreCase) + && string.Equals(uri.IdnHost, allowedOrigin.IdnHost, StringComparison.OrdinalIgnoreCase) + && uri.Port == allowedOrigin.Port) + { + allowed = true; + break; + } + } + + if (!allowed) + { + return false; + } + } + } + + return true; + } + + private static bool IsUnderRoot(string filePath, string root) + { + if (!Path.IsPathRooted(root)) + { + return false; + } + + var canonicalFile = Path.GetFullPath(filePath); + var canonicalRoot = Path.GetFullPath(root); + if (string.Equals(canonicalFile, canonicalRoot, FilePathComparison)) + { + return true; + } + + if (!canonicalRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + && !canonicalRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + canonicalRoot += Path.DirectorySeparatorChar; + } + + return canonicalFile.StartsWith(canonicalRoot, FilePathComparison); + } +} diff --git a/Jint/Runtime/Modules/ModuleBuilder.cs b/Jint/Runtime/Modules/ModuleBuilder.cs index df214d4c65..af66cc089d 100644 --- a/Jint/Runtime/Modules/ModuleBuilder.cs +++ b/Jint/Runtime/Modules/ModuleBuilder.cs @@ -136,6 +136,31 @@ public ModuleBuilder WithOptions(Func + /// Returns the UTF-8 byte count of the accumulated source, or 0 for exports-only/prepared modules. + /// + internal long GetSourceByteCount() + { + if (_module is not null) + { + return 0; // Prepared module — original source size unknowable. + } + + if (_sourceRaw.Count == 0) + { + return 0; // Exports-only — no source. + } + + long bytes = 0; + foreach (var s in _sourceRaw) + { + bytes += System.Text.Encoding.UTF8.GetByteCount(s); + } + + bytes += (long) (_sourceRaw.Count - 1) * System.Text.Encoding.UTF8.GetByteCount(Environment.NewLine); + return bytes; + } + /// /// Parses the accumulated source, naming the module . That is the key the loader /// resolved the registration to rather than the name it was registered under, since it is what the module's diff --git a/Jint/Runtime/Modules/ModuleFactory.cs b/Jint/Runtime/Modules/ModuleFactory.cs index 6ce9b86ee2..f8b2e3b4c1 100644 --- a/Jint/Runtime/Modules/ModuleFactory.cs +++ b/Jint/Runtime/Modules/ModuleFactory.cs @@ -92,10 +92,12 @@ public static Module BuildSourceTextModule(Engine engine, ResolvedSpecifier reso var parser = engine.CreateModuleParser(parserOptions, parsingOptions); var module = parser.ParseModuleGuarded(engine, code, source); - return BuildSourceTextModule(engine, new Prepared( + var result = BuildSourceTextModule(engine, new Prepared( module, parserOptions, parsingConstraints: parser.Constraints)); + result.SourceByteLength = System.Text.Encoding.UTF8.GetByteCount(code); + return result; } /// @@ -142,7 +144,9 @@ public static Module BuildJsonModule(Engine engine, ResolvedSpecifier resolved, module = null; } - return BuildJsonModule(engine, module, source); + var result = BuildJsonModule(engine, module, source); + result.SourceByteLength = System.Text.Encoding.UTF8.GetByteCount(jsonString); + return result; } /// @@ -177,7 +181,9 @@ public static Module BuildBytesModule(Engine engine, ResolvedSpecifier resolved, var uint8Array = engine.Realm.Intrinsics.Uint8Array.Construct([arrayBuffer], engine.Realm.Intrinsics.Uint8Array); - return new SyntheticModule(engine, engine.Realm, uint8Array, LocationOf(resolved)); + var result = new SyntheticModule(engine, engine.Realm, uint8Array, LocationOf(resolved)); + result.SourceByteLength = bytes.Length; + return result; } /// @@ -194,7 +200,9 @@ public static Module BuildBytesModule(Engine engine, ResolvedSpecifier resolved, /// public static Module BuildTextModule(Engine engine, ResolvedSpecifier resolved, string text) { - return new SyntheticModule(engine, engine.Realm, JsString.Create(text), LocationOf(resolved)); + var result = new SyntheticModule(engine, engine.Realm, JsString.Create(text), LocationOf(resolved)); + result.SourceByteLength = System.Text.Encoding.UTF8.GetByteCount(text); + return result; } /// @@ -232,10 +240,12 @@ internal static Module BuildFromContents( var parserOptions = ModuleParsingOptions.Default.GetParserOptions(); var parser = JintParser.Create(parserOptions, in parsingConstraints); var module = parser.ParseModuleGuarded(engine, code, source); - return BuildSourceTextModule(engine, new Prepared( + var result = BuildSourceTextModule(engine, new Prepared( module, parserOptions, parsingConstraints: parsingConstraints)); + result.SourceByteLength = System.Text.Encoding.UTF8.GetByteCount(code); + return result; } /// @@ -256,6 +266,12 @@ internal static Module BuildFromContents( return BuildBytesModule(engine, resolved, bytes); } - return BuildFromContents(engine, resolved, System.Text.Encoding.UTF8.GetString(bytes), parsingConstraints); + var module = BuildFromContents( + engine, + resolved, + System.Text.Encoding.UTF8.GetString(bytes), + parsingConstraints); + module.SourceByteLength = bytes.Length; + return module; } } diff --git a/Jint/Runtime/Modules/ModuleGraphLimitException.cs b/Jint/Runtime/Modules/ModuleGraphLimitException.cs new file mode 100644 index 0000000000..1a985ddabd --- /dev/null +++ b/Jint/Runtime/Modules/ModuleGraphLimitException.cs @@ -0,0 +1,14 @@ +namespace Jint.Runtime.Modules; + +/// +/// Thrown when a module graph exceeds one of the host-configured limits: module count, total source bytes, +/// graph depth, or resolution hops. Like other constraint exceptions (, +/// ), it propagates through the module-load pipeline rather than becoming a +/// catchable import rejection, so script cannot catch-and-ignore it. +/// +public sealed class ModuleGraphLimitException : JintException +{ + public ModuleGraphLimitException(string message) : base(message) + { + } +} diff --git a/Jint/Runtime/Modules/ModuleLoadCompletion.cs b/Jint/Runtime/Modules/ModuleLoadCompletion.cs index 13a239598e..8536728265 100644 --- a/Jint/Runtime/Modules/ModuleLoadCompletion.cs +++ b/Jint/Runtime/Modules/ModuleLoadCompletion.cs @@ -59,6 +59,13 @@ public sealed class ModuleLoadCompletion private readonly MemoryLimitConstraint.OperationState? _memoryState; private readonly ParsingConstraints _parsingConstraints; + /// + /// The engine cancellation token that governed the load when it was registered. A deferred settle can run + /// on a background thread while an async host operation owns the engine, so it must neither enter the + /// engine to rediscover this state nor observe a token installed for a later operation. + /// + private readonly CancellationToken _cancellationToken; + private readonly List _waiters = new(); private int _settled; @@ -83,6 +90,7 @@ internal ModuleLoadCompletion(Engine.ModuleOperations modules, ResolvedSpecifier _realm = Engine.Realm; _memoryState = Engine.CaptureMemoryLimitState(); _parsingConstraints = Engine.GetActiveParsingConstraints(); + _cancellationToken = Engine.Constraints.Find()?.Token ?? CancellationToken.None; } /// The engine the module is being loaded for. @@ -106,7 +114,13 @@ public void SetSource(string code) Throw.ArgumentNullException(nameof(code)); } - Settle(() => Build(() => ModuleFactory.BuildFromContents(Engine, Resolved, code, _parsingConstraints))); + Settle(() => Build(() => + { + _modules.EnsureModuleRegistrationAllowed( + _cacheKey, + System.Text.Encoding.UTF8.GetByteCount(code)); + return ModuleFactory.BuildFromContents(Engine, Resolved, code, _parsingConstraints); + })); } /// @@ -120,7 +134,11 @@ public void SetSource(byte[] bytes) Throw.ArgumentNullException(nameof(bytes)); } - Settle(() => Build(() => ModuleFactory.BuildFromContents(Engine, Resolved, bytes, _parsingConstraints))); + Settle(() => Build(() => + { + _modules.EnsureModuleRegistrationAllowed(_cacheKey, bytes.Length); + return ModuleFactory.BuildFromContents(Engine, Resolved, bytes, _parsingConstraints); + })); } /// @@ -146,7 +164,8 @@ public void SetModule(Module module) /// /// Fails the load. Every importer waiting on it — the promise from a dynamic import(), the load /// phase of a static import graph — is rejected with an Error carrying - /// 's message. + /// 's message. Engine resource-limit and cancellation exceptions propagate + /// instead, because turning them into a script-catchable rejection would defeat the bound. /// public void SetError(Exception exception) { @@ -155,7 +174,7 @@ public void SetError(Exception exception) Throw.ArgumentNullException(nameof(exception)); } - if (exception is ParsingLimitException) + if (MustPropagateLoaderException(exception, _cancellationToken)) { Settle(() => Propagate(exception)); return; @@ -176,6 +195,16 @@ public void SetError(string message) Settle(() => Fail(exception: null, error: null, message ?? "Could not load module")); } + internal void SetConstraintError(Exception exception) + { + Settle(() => + { + _modules.RemovePendingLoad(_cacheKey); + _waiters.Clear(); + ExceptionDispatchInfo.Capture(exception).Throw(); + }); + } + internal void AddWaiter(IScriptOrModule? referrer, string? referrerLocation, ModuleRequest request, ModuleLoadPayload payload) { _waiters.Add(new Waiter(referrer, referrerLocation, request, payload)); @@ -185,6 +214,8 @@ internal void AddWaiter(IScriptOrModule? referrer, string? referrerLocation, Mod internal void CloseInlineSettleWindow() => _inlineSettleThreadId = -1; + internal CancellationToken CancellationToken => _cancellationToken; + private void Settle(Action onEngineThread) { if (Interlocked.CompareExchange(ref _settled, 1, 0) != 0) @@ -240,7 +271,7 @@ private void Build(Func build) // Registration is inside the try so a throwing debugger callback cannot leave the waiters // stranded below with the module half-registered above them. _modules.RemovePendingLoad(_cacheKey); - _modules.RegisterModule(_cacheKey, module); + _modules.RegisterModuleWithAccounting(_cacheKey, module, module.SourceByteLength); } catch (JavaScriptException ex) { @@ -375,6 +406,34 @@ private void LeaveLoadRealm(bool entered) /// internal static bool MustPropagate(Exception exception) => ConstraintFailure.MustPropagate(exception); + internal static bool MustPropagateLoaderException(Engine engine, Exception exception) + => MustPropagateLoaderException( + exception, + engine.Constraints.Find()?.Token ?? CancellationToken.None); + + private static bool MustPropagateLoaderException(Exception exception, CancellationToken cancellationToken) + { + if (exception is ExecutionCanceledException + or ParsingLimitException + or MemoryLimitExceededException + or StatementsCountOverflowException + or RecursionDepthOverflowException + or ModuleGraphLimitException + or System.Text.RegularExpressions.RegexMatchTimeoutException + or OutOfMemoryException) + { + return true; + } + + if (exception is OperationCanceledException) + { + return Throw.IsEngineAbortException(exception) + || cancellationToken.IsCancellationRequested; + } + + return exception is TimeoutException && Throw.IsEngineAbortException(exception); + } + private Native.Object.ObjectInstance CreateError(string message) => Engine.Realm.Intrinsics.Error.Construct(message); private readonly record struct Waiter( diff --git a/Jint/Runtime/Modules/ModuleLoadPayload.cs b/Jint/Runtime/Modules/ModuleLoadPayload.cs index d6146171e1..6a227f1c7e 100644 --- a/Jint/Runtime/Modules/ModuleLoadPayload.cs +++ b/Jint/Runtime/Modules/ModuleLoadPayload.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using Jint.Native; using Jint.Native.Promise; @@ -21,6 +22,8 @@ namespace Jint.Runtime.Modules; /// internal abstract class ModuleLoadPayload { + internal abstract ModuleLoadBudget Budget { get; } + /// /// Consumes the completion of one HostLoadImportedModule call: exactly one of /// (a normal completion) and (a throw completion) is @@ -29,18 +32,59 @@ internal abstract class ModuleLoadPayload internal abstract void Continue(Module? module, JsValue? error); } +internal sealed class ModuleLoadBudget +{ + private int _resolutionHopsRemaining; + + internal ModuleLoadBudget(Options.ModuleOptions options) + { + MaximumGraphDepth = options.MaxModuleGraphDepth; + MaximumResolutionHops = options.MaxModuleResolutionHops; + _resolutionHopsRemaining = MaximumResolutionHops; + } + + internal int MaximumGraphDepth { get; } + + private int MaximumResolutionHops { get; } + + internal void ConsumeResolutionHop(string specifier) + { + if (_resolutionHopsRemaining == int.MaxValue) + { + return; + } + + if (_resolutionHopsRemaining == 0) + { + throw new ModuleGraphLimitException( + $"Module resolution hop limit of {MaximumResolutionHops} exceeded while resolving '{specifier}'."); + } + + _resolutionHopsRemaining--; + } +} + /// /// https://tc39.es/ecma262/#graphloadingstate-record /// -internal sealed class GraphLoadingState : ModuleLoadPayload +internal sealed class GraphLoadingState { - internal GraphLoadingState(PromiseCapability promiseCapability) + private readonly Dictionary _nodeIndexes = new(ReferenceComparer.Instance); + private readonly HashSet _expanded = new(ReferenceComparer.Instance); + private readonly Queue _modulesToProcess = new(); + private bool _isProcessingModules; + + internal GraphLoadingState(PromiseCapability promiseCapability, Module root, ModuleLoadBudget budget) { PromiseCapability = promiseCapability; + Budget = budget; + AddNode(root); } internal PromiseCapability PromiseCapability { get; } + internal ModuleLoadBudget Budget { get; } + /// [[IsLoading]] internal bool IsLoading { get; set; } = true; @@ -48,34 +92,65 @@ internal GraphLoadingState(PromiseCapability promiseCapability) internal int PendingModulesCount { get; set; } = 1; /// - /// [[Visited]]. A list rather than a set on purpose: every inherits - /// , which answers from the value's type alone, so a hash set of module - /// records is one bucket scanned linearly anyway — with the added downside of looking like it is not. - /// The graph reached by a single load is small. + /// Modules reached by this graph load, in discovery order. Identity lookup uses a dictionary with + /// because hashes only the value type. /// - internal List Visited { get; } = []; + internal List Nodes { get; } = []; - /// - /// https://tc39.es/ecma262/#sec-ContinueModuleLoading - /// - internal override void Continue(Module? module, JsValue? error) + internal List Expanded { get; } = []; + + internal List Edges { get; } = []; + + internal void RecordEdge(CyclicModule parent, Module child) + { + Edges.Add(new ModuleGraphEdge(parent, child)); + AddNode(child); + } + + internal bool TryExpand(CyclicModule module) { - // Step 1: If state.[[IsLoading]] is false, return unused. Reached when an earlier sibling has - // already failed the load and rejected the capability, and this completion belongs to a request - // that was still in flight at the time. - if (!IsLoading) + if (!_expanded.Add(module)) { - return; + return false; } - if (error is null) + Expanded.Add(module); + return true; + } + + internal void Enqueue(Module module, int depth) + => _modulesToProcess.Enqueue(new PendingGraphModule(module, depth)); + + internal bool TryBeginProcessing() + { + if (_isProcessingModules) { - CyclicModule.InnerModuleLoading(this, module!); + return false; } - else + + _isProcessingModules = true; + return true; + } + + internal bool TryDequeue(out PendingGraphModule module) + { + if (_modulesToProcess.Count == 0) { - IsLoading = false; - PromiseCapability.Reject(error); + module = default; + return false; + } + + module = _modulesToProcess.Dequeue(); + return true; + } + + internal void EndProcessing() => _isProcessingModules = false; + + private void AddNode(Module module) + { + if (_nodeIndexes.TryAdd(module, Nodes.Count)) + { + Nodes.Add(module); } } @@ -98,13 +173,232 @@ internal void SettleOnePendingModule() } IsLoading = false; - foreach (var loaded in Visited) + ValidateMaximumDepth(); + foreach (var loaded in Expanded) { loaded.OnGraphLoaded(); } PromiseCapability.Resolve(JsValue.Undefined); } + + private void ValidateMaximumDepth() + { + var maximumDepth = Budget.MaximumGraphDepth; + if (maximumDepth == int.MaxValue || Nodes.Count == 0) + { + return; + } + + var count = Nodes.Count; + var adjacency = new List[count]; + var reverseAdjacency = new List[count]; + for (var i = 0; i < count; i++) + { + adjacency[i] = []; + reverseAdjacency[i] = []; + } + + foreach (var edge in Edges) + { + var from = _nodeIndexes[edge.Parent]; + var to = _nodeIndexes[edge.Child]; + if (!adjacency[from].Contains(to)) + { + adjacency[from].Add(to); + reverseAdjacency[to].Add(from); + } + } + + var visited = new bool[count]; + var order = new List(count); + for (var start = 0; start < count; start++) + { + if (visited[start]) + { + continue; + } + + var traversal = new Stack(); + visited[start] = true; + traversal.Push(new SccTraversalFrame(start, 0)); + while (traversal.Count > 0) + { + var frame = traversal.Pop(); + var children = adjacency[frame.Node]; + if (frame.NextChild < children.Count) + { + traversal.Push(new SccTraversalFrame(frame.Node, frame.NextChild + 1)); + var child = children[frame.NextChild]; + if (!visited[child]) + { + visited[child] = true; + traversal.Push(new SccTraversalFrame(child, 0)); + } + } + else + { + order.Add(frame.Node); + } + } + } + + var componentCount = 0; + var components = new int[count]; + for (var i = 0; i < components.Length; i++) + { + components[i] = -1; + } + + var members = new Stack(); + for (var i = order.Count - 1; i >= 0; i--) + { + var start = order[i]; + if (components[start] >= 0) + { + continue; + } + + components[start] = componentCount; + members.Push(start); + while (members.Count > 0) + { + var node = members.Pop(); + foreach (var parent in reverseAdjacency[node]) + { + if (components[parent] < 0) + { + components[parent] = componentCount; + members.Push(parent); + } + } + } + + componentCount++; + } + + var componentSizes = new int[componentCount]; + var componentEdges = new List[componentCount]; + var indegrees = new int[componentCount]; + for (var i = 0; i < componentCount; i++) + { + componentEdges[i] = []; + } + + for (var node = 0; node < count; node++) + { + var component = components[node]; + componentSizes[component]++; + foreach (var child in adjacency[node]) + { + var childComponent = components[child]; + if (component != childComponent && !componentEdges[component].Contains(childComponent)) + { + componentEdges[component].Add(childComponent); + indegrees[childComponent]++; + } + } + } + + var distances = new int[componentCount]; + distances[components[0]] = componentSizes[components[0]]; + var ready = new Queue(); + for (var i = 0; i < componentCount; i++) + { + if (indegrees[i] == 0) + { + ready.Enqueue(i); + } + } + + while (ready.Count > 0) + { + var component = ready.Dequeue(); + foreach (var child in componentEdges[component]) + { + if (distances[component] > 0) + { + distances[child] = Math.Max( + distances[child], + checked(distances[component] + componentSizes[child])); + } + + indegrees[child]--; + if (indegrees[child] == 0) + { + ready.Enqueue(child); + } + } + } + + var actualDepth = 0; + for (var i = 0; i < distances.Length; i++) + { + actualDepth = Math.Max(actualDepth, distances[i]); + } + if (actualDepth > maximumDepth) + { + throw new ModuleGraphLimitException( + $"Module graph depth limit of {maximumDepth} exceeded; the graph requires depth {actualDepth}."); + } + } +} + +[StructLayout(LayoutKind.Auto)] +internal readonly record struct ModuleGraphEdge(CyclicModule Parent, Module Child); + +[StructLayout(LayoutKind.Auto)] +internal readonly record struct SccTraversalFrame(int Node, int NextChild); + +[StructLayout(LayoutKind.Auto)] +internal readonly record struct PendingGraphModule(Module Module, int Depth); + +internal sealed class ReferenceComparer : IEqualityComparer where T : class +{ + internal static readonly ReferenceComparer Instance = new(); + + private ReferenceComparer() + { + } + + public bool Equals(T? x, T? y) => ReferenceEquals(x, y); + + public int GetHashCode(T obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); +} + +internal sealed class GraphModuleLoadPayload : ModuleLoadPayload +{ + private readonly GraphLoadingState _state; + private readonly CyclicModule _parent; + private readonly int _depth; + + internal GraphModuleLoadPayload(GraphLoadingState state, CyclicModule parent, int depth) + { + _state = state; + _parent = parent; + _depth = depth; + } + + internal override ModuleLoadBudget Budget => _state.Budget; + + internal override void Continue(Module? module, JsValue? error) + { + if (!_state.IsLoading) + { + return; + } + + if (error is null) + { + _state.RecordEdge(_parent, module!); + CyclicModule.InnerModuleLoading(_state, module!, _depth); + } + else + { + _state.IsLoading = false; + _state.PromiseCapability.Reject(error); + } + } } /// @@ -117,15 +411,22 @@ internal sealed class DynamicImportPayload : ModuleLoadPayload private readonly Engine _engine; private readonly ModuleRequest _moduleRequest; - internal DynamicImportPayload(Engine engine, ModuleRequest moduleRequest, PromiseCapability promiseCapability) + internal DynamicImportPayload( + Engine engine, + ModuleRequest moduleRequest, + PromiseCapability promiseCapability, + ModuleLoadBudget? budget = null) { _engine = engine; _moduleRequest = moduleRequest; PromiseCapability = promiseCapability; + Budget = budget ?? new ModuleLoadBudget(engine.Options.Modules); } internal PromiseCapability PromiseCapability { get; } + internal override ModuleLoadBudget Budget { get; } + internal override void Continue(Module? module, JsValue? error) { if (error is not null) @@ -134,7 +435,7 @@ internal override void Continue(Module? module, JsValue? error) return; } - _engine._host.ContinueDynamicImport(module!, _moduleRequest, PromiseCapability); + _engine._host.ContinueDynamicImport(module!, _moduleRequest, PromiseCapability, Budget); } } @@ -146,6 +447,13 @@ internal override void Continue(Module? module, JsValue? error) /// internal sealed class RootModuleLoadPayload : ModuleLoadPayload { + internal RootModuleLoadPayload(ModuleLoadBudget budget) + { + Budget = budget; + } + + internal override ModuleLoadBudget Budget { get; } + /// The loaded module, or null while the load is in flight or has failed. internal Module? Module { get; private set; } diff --git a/Jint/Runtime/Modules/ModuleLoader.cs b/Jint/Runtime/Modules/ModuleLoader.cs index 05bb9d3260..6c3ba79aa3 100644 --- a/Jint/Runtime/Modules/ModuleLoader.cs +++ b/Jint/Runtime/Modules/ModuleLoader.cs @@ -20,12 +20,16 @@ public Module LoadModule(Engine engine, ResolvedSpecifier resolved) { bytes = LoadModuleContentsAsBytes(engine, resolved); } - catch (Exception ex) when (ex is not NotSupportedException and not ParsingLimitException) + catch (Exception ex) when (ex is not NotSupportedException + && !ModuleLoadCompletion.MustPropagateLoaderException(engine, ex)) { Throw.JavaScriptException(engine, $"Could not load module {resolved.ModuleRequest.Specifier}", in AstExtensions.DefaultLocation); return default!; } + engine.Modules.EnsureModuleRegistrationAllowed( + Engine.ModuleCacheKey.From(resolved), + bytes.Length); moduleRecord = ModuleFactory.BuildBytesModule(engine, resolved, bytes); } else @@ -35,12 +39,16 @@ public Module LoadModule(Engine engine, ResolvedSpecifier resolved) { code = LoadModuleContents(engine, resolved); } - catch (Exception ex) when (ex is not NotSupportedException and not ParsingLimitException) + catch (Exception ex) when (ex is not NotSupportedException + && !ModuleLoadCompletion.MustPropagateLoaderException(engine, ex)) { Throw.JavaScriptException(engine, $"Could not load module {resolved.ModuleRequest.Specifier}", in AstExtensions.DefaultLocation); return default!; } + engine.Modules.EnsureModuleRegistrationAllowed( + Engine.ModuleCacheKey.From(resolved), + System.Text.Encoding.UTF8.GetByteCount(code)); if (resolved.ModuleRequest.IsTextModule()) { moduleRecord = ModuleFactory.BuildTextModule(engine, resolved, code); @@ -70,8 +78,10 @@ public Module LoadModule(Engine engine, ResolvedSpecifier resolved) => GetModuleSource(engine, resolved); /// - /// Loads the module's source text. Anything this throws is a failed load, reported to script as - /// Could not load module {specifier} — with one exception: + /// Loads the module's source text. An ordinary loader or transport failure is reported to script as + /// Could not load module {specifier}. Engine constraint failures and host-requested cancellation + /// propagate instead, because reducing either to a catchable import rejection would defeat the bound. + /// One further exception: /// propagates as itself, reserved for telling a host that it reached /// this loader the wrong way rather than that a module is missing. /// is the in-box use of it. @@ -88,9 +98,9 @@ public Module LoadModule(Engine engine, ResolvedSpecifier resolved) protected virtual Jint.Native.Object.ObjectInstance? GetModuleSource(Engine engine, ResolvedSpecifier resolved) => null; /// - /// Loads module contents as raw bytes. Override in derived classes for efficient binary loading. Failure - /// is reported exactly as for , - /// included. + /// Loads module contents as raw bytes. Override in derived classes for efficient binary loading. Failure is + /// reported exactly as for , including its constraint/cancellation and + /// exceptions. /// protected virtual byte[] LoadModuleContentsAsBytes(Engine engine, ResolvedSpecifier resolved) { diff --git a/Jint/Runtime/Throw.cs b/Jint/Runtime/Throw.cs index a4fd24e532..a411142f19 100644 --- a/Jint/Runtime/Throw.cs +++ b/Jint/Runtime/Throw.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Threading; using Jint.Native; @@ -48,6 +49,18 @@ public static string SafeToDisplayString(JsValue value) return value.IsObject() ? "[object]" : value.ToString(); } + internal static bool IsEngineAbortException(Exception exception) + => EngineAbortRegistry.Exceptions.TryGetValue(exception, out _); + + private static void MarkEngineAbort(Exception exception) + => EngineAbortRegistry.Exceptions.Add(exception, EngineAbortRegistry.Marker); + + private static class EngineAbortRegistry + { + internal static readonly ConditionalWeakTable Exceptions = new(); + internal static readonly object Marker = new(); + } + [DoesNotReturn] public static void SyntaxError(Realm realm, string? message = null) { @@ -201,19 +214,25 @@ public static void OverflowException(string message) [DoesNotReturn] public static void TimeoutException() { - throw new TimeoutException(); + var exception = new TimeoutException(); + MarkEngineAbort(exception); + throw exception; } [DoesNotReturn] public static void TimeoutException(string message) { - throw new TimeoutException(message); + var exception = new TimeoutException(message); + MarkEngineAbort(exception); + throw exception; } [DoesNotReturn] public static void OperationCanceledException(CancellationToken cancellationToken) { - throw new OperationCanceledException(cancellationToken); + var exception = new OperationCanceledException(cancellationToken); + MarkEngineAbort(exception); + throw exception; } [DoesNotReturn] diff --git a/README.md b/README.md index df3e9024df..8647004806 100644 --- a/README.md +++ b/README.md @@ -2602,6 +2602,44 @@ The synchronous `Engine.Modules.Import` still works with an async loader, but th A completion settled *before* `LoadModuleAsync` returns — a cache hit, source already in hand — is the exception to all of the above: the load finishes on the calling stack, no event loop involved, so a graph made entirely of such answers keeps the blocking `Import` fully synchronous, exactly as if a synchronous `IModuleLoader` had served it. `AsyncModuleLoader` does this automatically whenever `LoadModuleContentsAsync` returns an already-completed task. +### Bounding and restricting module graphs + +When running untrusted or semi-trusted scripts, you can limit the size and shape of the module graph and restrict which modules may be loaded: + +```csharp +var engine = new Engine(options => +{ + options.EnableModules("/scripts"); + + // Numeric limits — all default to unlimited (int.MaxValue / long.MaxValue). + options.Modules.MaxModuleCount = 50; // distinct modules per engine lifetime + options.Modules.MaxTotalModuleSourceBytes = 1_000_000; // cumulative UTF-8 source bytes + options.Modules.MaxModuleGraphDepth = 10; // import-chain depth per graph load + options.Modules.MaxModuleResolutionHops = 200; // Resolve calls per import operation + + // URI/path allowlist policy + options.Modules.LoadPolicy = new ModuleAllowlistPolicy + { + AllowedSchemes = { "file" }, // only file:// URIs + AllowedFileRoots = { "/scripts" }, // must be under this directory + AllowBareSpecifiers = true, // allow programmatic modules + }; +}); +``` + +**Counting semantics:** + +| Limit | Scope | What counts | +|---|---|---| +| `MaxModuleCount` | Engine lifetime | Each distinct successfully registered module record. Duplicates, cached lookups, and coalesced async fetches do not recount. Programmatic modules (`Engine.Modules.Add`) participate. | +| `MaxTotalModuleSourceBytes` | Engine lifetime | UTF-8 byte count of source text for string-based modules; exact byte length for `byte[]` modules. Prepared modules, exports-only modules, and custom `Module` records whose original encoded size is unknowable charge **0 bytes** — this is a known limitation. | +| `MaxModuleGraphDepth` | Per graph load | Conservative import-chain depth (root = 1). Cycles are collapsed into strongly connected components but every module in a cycle contributes to its component's weight; Jint then enforces the longest weighted path through the resulting acyclic graph. The answer is independent of source traversal and async completion order. | +| `MaxModuleResolutionHops` | Per import operation | Each actual `Resolve` call caused by an import. Cached `[[LoadedModules]]` hits do not resolve and do not count. Registration indexing does not consume hops. Budget resets per `Import`/`StartImport` call, so pooled engines never fail from accumulated operations. | + +**Failure behavior:** Exceeding a numeric limit throws `ModuleGraphLimitException`, a `JintException` subclass that is **not** `JavaScriptException` and propagates like a constraint violation — it cannot be caught by script and is not turned into a promise rejection. Invalid finite limits (≤ 0) throw `ArgumentException` at engine construction. Policy denials throw `ModuleResolutionException` and follow existing sync/rejection behavior. + +**Policy composition:** `ModuleAllowlistPolicy` applies AND across configured dimensions (schemes, hosts, origins, file roots) and OR within each list. An unconfigured dimension imposes no restriction. A file target cannot satisfy a configured host/origin dimension, and a non-file target cannot satisfy a configured file-root dimension, so cross-kind mismatches are denied rather than ignored. Bare/no-URI specifiers are denied by default when any dimension is configured; set `AllowBareSpecifiers = true` to permit them. File roots and resolved files are canonicalized before a separator-aware lexical containment check; symbolic links and Windows reparse points are not resolved, so allowed roots must not contain attacker-controlled links. The policy applies to the final `ResolvedSpecifier`; `DefaultModuleLoader`'s base-path restriction runs independently as an earlier check. Transport redirects occur inside a custom loader and are not visible to Jint's resolver, so that loader must apply the same policy and its own redirect limit to every redirect target. + ## Asynchronous Execution Jint supports non-blocking execution of JavaScript that involves `async`/`await` and Promises. This is important in ASP.NET Core and other environments where blocking a thread while waiting for I/O can cause thread-pool exhaustion.