From 2c7fbaa9d9f7478dba904246f5db66bd2de31bcd Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:25:39 +0100 Subject: [PATCH 1/3] Load every module through the resource loader JsModuleLoader fetched only http and https modules through AngleSharp's IResourceLoader. Every other URL went to Jint's DefaultModuleLoader, which reads it from the local disk, so any page could import file: URLs (through an import map, a direct specifier or import()) and read or run local files. Bytes modules (with { type: 'bytes' }) always took Jint's file path. - LoadModuleContents and LoadModuleContentsAsBytes both go through the resource loader for every scheme, so the registered requesters decide what a page can reach; a host that wants local modules registers a requester for file: - A missing response is reported as an error instead of a NullReferenceException - Module resolution is unchanged - Tests: a page at https://example.com/ cannot read a temporary local file as a JSON, JavaScript or bytes module, through any of the three ways in Fixes #150 --- CHANGELOG.md | 1 + src/AngleSharp.Js.Tests/EcmaTests.cs | 45 +++++++++++++++++++++++ src/AngleSharp.Js/JsModuleLoader.cs | 53 ++++++++++++++++++++++------ 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1836abd..ae4fdfc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Released on Friday, November 6 2026. - Added classes with implicit conversions standing for a WebIDL union, e.g. of a string and a sequence of strings, which fixes such conversions failing; an implicit conversion from such a class is what a script receives for it - Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close` - Added `performance.mark`, `measure`, `getEntries`, `getEntriesByName`, `getEntriesByType`, `clearMarks`, `clearMeasures`, along with `PerformanceEntry`, `PerformanceMark` and `PerformanceMeasure` +- Fixed modules being read from the local disk; every module is loaded through the configured requesters (#150) @michaelthyregod # 1.1.4 diff --git a/src/AngleSharp.Js.Tests/EcmaTests.cs b/src/AngleSharp.Js.Tests/EcmaTests.cs index b63af1a7..58b68ed5 100644 --- a/src/AngleSharp.Js.Tests/EcmaTests.cs +++ b/src/AngleSharp.Js.Tests/EcmaTests.cs @@ -6,6 +6,7 @@ namespace AngleSharp.Js.Tests using NUnit.Framework; using System; using System.Collections.Generic; + using System.IO; using System.Threading.Tasks; [TestFixture] @@ -493,5 +494,49 @@ public async Task MicrotasksRunAfterSynchronousWorkInOrder() var document = await context.OpenAsync(r => r.Content(html)).WhenStable().ConfigureAwait(false); Assert.AreEqual("sync,m1,m2", document.GetElementById("test")?.TextContent); } + + [TestCase("direct")] + [TestCase("importmap")] + [TestCase("dynamic")] + [TestCase("script")] + [TestCase("bytes")] + public async Task ModuleIsNotReadFromTheLocalDisk(String way) + { + var json = Path.GetTempFileName(); + var script = Path.GetTempFileName(); + + try + { + File.WriteAllText(json, "{ \"secret\": \"from disk\" }"); + File.WriteAllText(script, "document.getElementById('test').textContent = 'from disk';"); + var jsonUrl = new Uri(json).AbsoluteUri; + var scriptUrl = new Uri(script).AbsoluteUri; + var show = "document.getElementById('test').textContent = "; + var page = way switch + { + "direct" => $"", + "importmap" => $"", + "dynamic" => $"", + "script" => $"", + _ => $"", + }; + var config = Configuration.Default + .WithJs() + .WithEventLoop() + .With(new MockHttpClientRequester(new Dictionary())) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + var html = "
pending
" + page; + var document = await BrowsingContext.New(config) + .OpenAsync(r => r.Content(html).Address("https://example.com/")) + .WhenStable() + .ConfigureAwait(false); + Assert.AreEqual("pending", document.GetElementById("test").TextContent); + } + finally + { + File.Delete(json); + File.Delete(script); + } + } } } diff --git a/src/AngleSharp.Js/JsModuleLoader.cs b/src/AngleSharp.Js/JsModuleLoader.cs index ec547c4d..acc0154b 100644 --- a/src/AngleSharp.Js/JsModuleLoader.cs +++ b/src/AngleSharp.Js/JsModuleLoader.cs @@ -80,16 +80,27 @@ public override ResolvedSpecifier Resolve(string referencingModuleLocation, Modu return base.Resolve(referencingModuleLocation, moduleRequest); } + // Every module comes through AngleSharp's resource loader, whatever its scheme, so the + // requesters the host registered decide what a page can reach. The base implementations + // of both methods would read file: URLs straight from the local disk, for any page. protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) { - if (resolved.Uri?.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https) == true) - { - return FetchModule(resolved.Uri); - } - else + return FetchModule(GetModuleUrl(resolved)); + } + + protected override Byte[] LoadModuleContentsAsBytes(Engine engine, ResolvedSpecifier resolved) + { + return FetchModuleBytes(GetModuleUrl(resolved)); + } + + private static Uri GetModuleUrl(ResolvedSpecifier resolved) + { + if (resolved.Uri == null) { - return base.LoadModuleContents(engine, resolved); + throw new NotSupportedException($"The module '{resolved.ModuleRequest.Specifier}' could not be resolved to a URL."); } + + return resolved.Uri; } private string FetchModule(Uri moduleUrl) @@ -99,11 +110,7 @@ private string FetchModule(Uri moduleUrl) return string.Empty; } - var importUrl = Url.Convert(moduleUrl); - - var request = new ResourceRequest(_scriptElement, importUrl); - - var response = _resourceLoader.FetchAsync(request).Task.Result; + var response = Fetch(moduleUrl); string content; @@ -114,5 +121,29 @@ private string FetchModule(Uri moduleUrl) return content; } + + private Byte[] FetchModuleBytes(Uri moduleUrl) + { + if (_resourceLoader == null) + { + return Array.Empty(); + } + + var response = Fetch(moduleUrl); + + using (var content = new MemoryStream()) + { + response.Content.CopyTo(content); + return content.ToArray(); + } + } + + private IResponse Fetch(Uri moduleUrl) + { + var importUrl = Url.Convert(moduleUrl); + var request = new ResourceRequest(_scriptElement, importUrl); + var response = _resourceLoader.FetchAsync(request).Task.Result; + return response ?? throw new InvalidOperationException($"No requester could load the module '{importUrl.Href}'."); + } } } From b344c15edcc5bf4311fb9269bf977388ebb9c457 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:07:02 +0100 Subject: [PATCH 2/3] Reference the pull request in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4fdfc9..7f76f178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ Released on Friday, November 6 2026. - Added classes with implicit conversions standing for a WebIDL union, e.g. of a string and a sequence of strings, which fixes such conversions failing; an implicit conversion from such a class is what a script receives for it - Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close` - Added `performance.mark`, `measure`, `getEntries`, `getEntriesByName`, `getEntriesByType`, `clearMarks`, `clearMeasures`, along with `PerformanceEntry`, `PerformanceMark` and `PerformanceMeasure` -- Fixed modules being read from the local disk; every module is loaded through the configured requesters (#150) @michaelthyregod +- Fixed modules being read from the local disk; every module is loaded through the configured requesters (#153) @michaelthyregod # 1.1.4 From 93092334e2c551410ed07a9b2c558472696fd8fc Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:04:57 +0100 Subject: [PATCH 3/3] Resolve modules with Url against what imports them --- CHANGELOG.md | 1 + src/AngleSharp.Js.Tests/EcmaTests.cs | 26 ++++++ src/AngleSharp.Js/EngineInstance.cs | 2 +- src/AngleSharp.Js/JsModuleLoader.cs | 104 ++++++++++++++---------- src/AngleSharp.Js/JsScriptingService.cs | 8 +- 5 files changed, 92 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f76f178..47bb3484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Released on Friday, November 6 2026. - Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close` - Added `performance.mark`, `measure`, `getEntries`, `getEntriesByName`, `getEntriesByType`, `clearMarks`, `clearMeasures`, along with `PerformanceEntry`, `PerformanceMark` and `PerformanceMeasure` - Fixed modules being read from the local disk; every module is loaded through the configured requesters (#153) @michaelthyregod +- Fixed modules imported by a relative address being requested from the wrong address (e.g. `./a.js` of `/dir/page.html` from `/dir/page.html/a.js`, and the imports of a module of another origin from the own origin); they are resolved against what imports them (#153) @michaelthyregod # 1.1.4 diff --git a/src/AngleSharp.Js.Tests/EcmaTests.cs b/src/AngleSharp.Js.Tests/EcmaTests.cs index 58b68ed5..39ad976e 100644 --- a/src/AngleSharp.Js.Tests/EcmaTests.cs +++ b/src/AngleSharp.Js.Tests/EcmaTests.cs @@ -538,5 +538,31 @@ public async Task ModuleIsNotReadFromTheLocalDisk(String way) File.Delete(script); } } + + [TestCase("", "/dir/a.js")] + [TestCase("", "/a.js")] + [TestCase("", "/a.js")] + [TestCase("", "/dir/a.js")] + [TestCase("", "/other/a.js")] + [TestCase("", "/dir/a.js")] + public async Task RelativeModuleIsLoadedFromTheAddressOfWhatImportsIt(String page, String path) + { + // Only the expected address serves the module; any other one answers with nothing. + var config = Configuration.Default + .WithJs() + .WithEventLoop() + .With(new MockHttpClientRequester(new Dictionary + { + { "/dir/mod.js", "import x from './a.js'; show(x);" }, + { path, "export default 'ok';" }, + })) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + var html = "
pending
" + page; + var document = await BrowsingContext.New(config) + .OpenAsync(r => r.Content(html).Address("https://example.com/dir/page.html")) + .WhenStable() + .ConfigureAwait(false); + Assert.AreEqual("ok", document.GetElementById("test").TextContent); + } } } diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index 96ec0628..b63f10d7 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -49,7 +49,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I { _importMap = new JsImportMap(); var engineOptions = new Options(); - engineOptions.EnableModules(new JsModuleLoader(this, window.Document, false)); + engineOptions.EnableModules(new JsModuleLoader(this, window.Document)); // The handler uses the caches initialized below. The creator must return the // engine before DOM objects are wrapped or page scripts are evaluated. engineOptions.SetWrapObjectHandler(WrapObject); diff --git a/src/AngleSharp.Js/JsModuleLoader.cs b/src/AngleSharp.Js/JsModuleLoader.cs index acc0154b..a200cbf3 100644 --- a/src/AngleSharp.Js/JsModuleLoader.cs +++ b/src/AngleSharp.Js/JsModuleLoader.cs @@ -9,23 +9,28 @@ namespace AngleSharp.Js using System; using System.Linq; - internal class JsModuleLoader : DefaultModuleLoader + internal class JsModuleLoader : ModuleLoader { private readonly EngineInstance _instance; private readonly IResourceLoader _resourceLoader; private readonly IElement _scriptElement; - private readonly string _documentUrl; + private readonly IDocument _document; - public JsModuleLoader(EngineInstance instance, IDocument document, bool restrictToBasePath = true) : base (document.Url, restrictToBasePath) + public JsModuleLoader(EngineInstance instance, IDocument document) { _instance = instance; _resourceLoader = document.Context.GetService(); _scriptElement = document.CreateElement(TagNames.Script); - _documentUrl = document.Url; + _document = document; } + // The key of a resolved module is its address, and its Uri stays null: Jint gives a module + // loaded with a Uri only the path of it as location, so the modules it imports would lose + // the origin. With the key as location, they are resolved against the full address. public override ResolvedSpecifier Resolve(string referencingModuleLocation, ModuleRequest moduleRequest) { + var specifier = moduleRequest.Specifier; + if (referencingModuleLocation != null && _instance.ImportMap.Scopes.Count > 0) { foreach (var scopePath in _instance.ImportMap.Scopes.Keys.OrderByDescending(k => k.Length)) @@ -34,55 +39,38 @@ public override ResolvedSpecifier Resolve(string referencingModuleLocation, Modu { var scopeImports = _instance.ImportMap.Scopes[scopePath]; - if (scopeImports.TryGetValue(moduleRequest.Specifier, out var scopeModuleUrl)) + if (scopeImports.TryGetValue(specifier, out var scopeModuleUrl)) { - if (!scopeModuleUrl.IsAbsoluteUri) - { - scopeModuleUrl = new Uri(new Uri(_documentUrl), scopeModuleUrl); - } - - return new ResolvedSpecifier( - moduleRequest, - moduleRequest.Specifier, - scopeModuleUrl, - SpecifierType.RelativeOrAbsolute); + return Resolved(moduleRequest, new Url(GetDocumentBaseUrl(), scopeModuleUrl.OriginalString)); } } } } - if (_instance.ImportMap.Imports.TryGetValue(moduleRequest.Specifier, out var moduleUrl)) + if (_instance.ImportMap.Imports.TryGetValue(specifier, out var moduleUrl)) { - if (!moduleUrl.IsAbsoluteUri) - { - moduleUrl = new Uri(new Uri(_documentUrl), moduleUrl); - } - - return new ResolvedSpecifier( - moduleRequest, - moduleRequest.Specifier, - moduleUrl, - SpecifierType.RelativeOrAbsolute); + return Resolved(moduleRequest, new Url(GetDocumentBaseUrl(), moduleUrl.OriginalString)); } - // Before passing to the base default module loader, make sure any relative paths are prepended with a dot - // as Jint will otherwise resolve them as absolute file paths when running on Linux - if (moduleRequest.Specifier.StartsWith("/")) + if (specifier.StartsWith("/") || specifier.StartsWith("./") || specifier.StartsWith("../")) { - moduleRequest = new ModuleRequest($".{moduleRequest.Specifier}", moduleRequest.Attributes); + return Resolved(moduleRequest, new Url(GetBaseUrl(referencingModuleLocation), specifier)); } - if (referencingModuleLocation?.StartsWith("/") == true) + var url = new Url(specifier); + + if (!url.IsInvalid && url.IsAbsolute) { - referencingModuleLocation = $".{referencingModuleLocation}"; + return Resolved(moduleRequest, url); } - - return base.Resolve(referencingModuleLocation, moduleRequest); + + // A bare name, which no address belongs to. The module scripts of the document are + // added to the engine under such a name, so it must stay what it is to find them. + return new ResolvedSpecifier(moduleRequest, specifier, null, SpecifierType.Bare); } // Every module comes through AngleSharp's resource loader, whatever its scheme, so the - // requesters the host registered decide what a page can reach. The base implementations - // of both methods would read file: URLs straight from the local disk, for any page. + // requesters the host registered decide what a page can reach. protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) { return FetchModule(GetModuleUrl(resolved)); @@ -93,17 +81,44 @@ protected override Byte[] LoadModuleContentsAsBytes(Engine engine, ResolvedSpeci return FetchModuleBytes(GetModuleUrl(resolved)); } - private static Uri GetModuleUrl(ResolvedSpecifier resolved) + private static ResolvedSpecifier Resolved(ModuleRequest moduleRequest, Url url) + { + return new ResolvedSpecifier(moduleRequest, url.Href, null, SpecifierType.RelativeOrAbsolute); + } + + // A module resolves against the address of what imports it, which is a module or a script + // that was loaded; one written into the document resolves against the base URL of it. + private Url GetBaseUrl(string referencingModuleLocation) + { + if (referencingModuleLocation != null) + { + var url = new Url(referencingModuleLocation); + + if (!url.IsInvalid && url.IsAbsolute) + { + return url; + } + } + + return GetDocumentBaseUrl(); + } + + private Url GetDocumentBaseUrl() + { + return _document.BaseUrl ?? new Url(_document.Url); + } + + private static Url GetModuleUrl(ResolvedSpecifier resolved) { - if (resolved.Uri == null) + if (resolved.Type != SpecifierType.RelativeOrAbsolute) { throw new NotSupportedException($"The module '{resolved.ModuleRequest.Specifier}' could not be resolved to a URL."); } - return resolved.Uri; + return new Url(resolved.Key); } - private string FetchModule(Uri moduleUrl) + private string FetchModule(Url moduleUrl) { if (_resourceLoader == null) { @@ -122,7 +137,7 @@ private string FetchModule(Uri moduleUrl) return content; } - private Byte[] FetchModuleBytes(Uri moduleUrl) + private Byte[] FetchModuleBytes(Url moduleUrl) { if (_resourceLoader == null) { @@ -138,12 +153,11 @@ private Byte[] FetchModuleBytes(Uri moduleUrl) } } - private IResponse Fetch(Uri moduleUrl) + private IResponse Fetch(Url moduleUrl) { - var importUrl = Url.Convert(moduleUrl); - var request = new ResourceRequest(_scriptElement, importUrl); + var request = new ResourceRequest(_scriptElement, moduleUrl); var response = _resourceLoader.FetchAsync(request).Task.Result; - return response ?? throw new InvalidOperationException($"No requester could load the module '{importUrl.Href}'."); + return response ?? throw new InvalidOperationException($"No requester could load the module '{moduleUrl.Href}'."); } } } diff --git a/src/AngleSharp.Js/JsScriptingService.cs b/src/AngleSharp.Js/JsScriptingService.cs index 0501203c..f7c89cdd 100644 --- a/src/AngleSharp.Js/JsScriptingService.cs +++ b/src/AngleSharp.Js/JsScriptingService.cs @@ -100,10 +100,12 @@ public async Task EvaluateScriptAsync(IResponse response, ScriptOptions options, var content = await reader.ReadToEndAsync().ConfigureAwait(false); var type = options.Element?.Type; // A module is imported by this, so it has to tell it apart from other modules: - // one written into the document would have the address of the document. + // one written into the document would have the address of the document. One that + // was loaded has its address, which is what the modules it imports resolve against. var isModule = type.Isi("module"); - var sourceUrl = isModule ? options.Element?.Source : response.Address?.Href ?? options.Element?.Source; - var position = isModule || !String.IsNullOrEmpty(options.Element?.Source) ? default : FindInDocument(options.Document, content); + var isInline = String.IsNullOrEmpty(options.Element?.Source); + var sourceUrl = isModule && isInline ? null : response.Address?.Href ?? options.Element?.Source; + var position = isModule || !isInline ? default : FindInDocument(options.Document, content); await options.EventLoop.EnqueueAsync(_ => { var document = options.Document ?? throw new ArgumentNullException(nameof(options));