diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index af5c6bc9..3f373e51 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -47,3 +47,8 @@ jobs: msbuild ./src/XmlNotepad.sln /p:Configuration=$env:Configuration "/p:Platform=Any CPU" /verbosity:minimal env: Configuration: ${{ matrix.configuration }} + + - name: Run headless model regression tests + run: dotnet test .\src\Model.Tests\Model.Tests.csproj --configuration $env:Configuration --no-build --verbosity normal + env: + Configuration: ${{ matrix.configuration }} diff --git a/src/Application/XmlDiffWrapper.cs b/src/Application/XmlDiffWrapper.cs index 8defef3a..7442db8c 100644 --- a/src/Application/XmlDiffWrapper.cs +++ b/src/Application/XmlDiffWrapper.cs @@ -70,14 +70,14 @@ public void DoCompare(Form owner, XmlCache model, string otherXmlFile, XmlDiffOp // diffView.Load call. XmlDocument original = new XmlDocument(); XmlReaderSettings settings = model.GetReaderSettings(); - using (XmlReader reader = XmlReader.Create(filename, settings)) + using (XmlReader reader = XmlHelpers.ReadDocument(filename, settings)) { original.Load(reader); } XmlDocument doc = new XmlDocument(); settings = model.GetReaderSettings(); - using (XmlReader reader = XmlReader.Create(otherXmlFile, settings)) + using (XmlReader reader = XmlHelpers.ReadDocument(otherXmlFile, settings)) { doc.Load(reader); } @@ -111,10 +111,13 @@ public void DoCompare(Form owner, XmlCache model, string otherXmlFile, XmlDiffOp using (XmlReader diffGram = XmlReader.Create(diffFile, settings)) { XmlDiffView diffView = new XmlDiffView(); - using (var reader = new XmlTextReader(filename)) + var location = new Uri(filename); + // XmlDiffView requires XmlTextReader; only the selected root uses the unguarded transport. + using (var input = (Stream)new XmlUrlResolver().GetEntity(location, null, typeof(Stream))) + using (var reader = new XmlTextReader(location.AbsoluteUri, input)) { - var ignoreDtd = Settings.Instance.GetBoolean("IgnoreDTD"); - reader.DtdProcessing = ignoreDtd ? DtdProcessing.Ignore : DtdProcessing.Parse; + reader.DtdProcessing = settings.DtdProcessing; + reader.XmlResolver = new XmlResourceResolver(model.Settings.Resolver ?? new XmlUrlResolver()); diffView.Load(reader, diffGram); using (TextWriter htmlWriter = new StreamWriter(tempFile, false, Encoding.UTF8)) { diff --git a/src/Model.Tests/ExternalResourceTests.cs b/src/Model.Tests/ExternalResourceTests.cs new file mode 100644 index 00000000..47929468 --- /dev/null +++ b/src/Model.Tests/ExternalResourceTests.cs @@ -0,0 +1,585 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Xsl; +using XmlNotepad; + +// Settings.Instance and the delayed-action dispatcher are process-global. +[assembly: DoNotParallelize] + +namespace XmlNotepad.Model.Tests +{ + [TestClass] + public sealed class ExternalResourceTests : IServiceProvider + { + private const string NetworkSchemaLocation = @"\\mock-schema.invalid\schemas\schema.xsd"; + private const string NetworkFileUri = "file://mock-schema.invalid/schemas/resource.xml"; + private const string LocalSchema = "" + + ""; + private MockSchemaSource _source = null!; + private TestProxyResolver _resolver = null!; + private Settings _settings = null!; + private SchemaCache _cache = null!; + private XmlCache _model = null!; + private string _documentFile = null!; + + [TestInitialize] + public void Initialize() + { + _source = new MockSchemaSource(); + _resolver = new TestProxyResolver(this, _source); + _settings = new Settings + { + StartupPath = AppContext.BaseDirectory, + Resolver = _resolver + }; + _settings.SetDefaults(); + var actions = new DelayedActions(action => action()); + _settings.DelayedActions = actions; + _cache = new SchemaCache(this, _source); + _model = new XmlCache(this, _cache, actions); + _documentFile = Path.GetTempFileName(); + File.Delete(_documentFile); // The in-memory input must not enable file watching. + } + + [TestCleanup] + public void Cleanup() + { + _model?.Dispose(); + if (_documentFile != null) + { + File.Delete(_documentFile); + } + _settings?.Dispose(); + } + + public object? GetService(Type serviceType) + { + return serviceType == typeof(Settings) ? _settings : null; + } + + [TestMethod] + [DataRow("noNamespaceSchemaLocation", NetworkSchemaLocation)] + [DataRow("schemaLocation", NetworkSchemaLocation)] + [DataRow("noNamespaceSchemaLocation", NetworkFileUri)] + [DataRow("schemaLocation", NetworkFileUri)] + [DataRow("noNamespaceSchemaLocation", "file:///%5C%5Cmock-schema.invalid/schemas/schema.xsd")] + [DataRow("schemaLocation", "file:///%5C%5Cmock-schema.invalid/schemas/schema.xsd")] + public void OpeningDocumentDoesNotRequestNetworkSchema(string schemaAttribute, string schemaLocation) + { + LoadXml(CreateXml(schemaAttribute, schemaLocation)); + + Assert.IsEmpty(_source.Requests, + "Opening XML must not implicitly request a schema from a network share. " + + "Mock source requests: " + string.Join(", ", _source.Requests)); + } + + [TestMethod] + [DataRow("noNamespaceSchemaLocation", NetworkSchemaLocation)] + [DataRow("schemaLocation", NetworkSchemaLocation)] + [DataRow("noNamespaceSchemaLocation", NetworkFileUri)] + [DataRow("schemaLocation", NetworkFileUri)] + public void ValidatingDocumentDoesNotRequestNetworkSchema(string schemaAttribute, string schemaLocation) + { + var document = new XmlDocument { XmlResolver = _resolver }; + document.LoadXml(CreateXml(schemaAttribute, schemaLocation)); + _model.Load(document); + + _model.ValidateModel(new ValidationErrorHandler()); + + Assert.IsEmpty(_source.Requests, + "Validation must not implicitly request a schema from a network share. " + + "Mock source requests: " + string.Join(", ", _source.Requests)); + } + + [TestMethod] + public void OpeningDocumentLoadsLocalSchemaFromMockSource() + { + var schemaUri = new Uri(new Uri(_documentFile), "schema.xsd"); + _source.Documents.Add(schemaUri, LocalSchema); + LoadXml(CreateXml("noNamespaceSchemaLocation", "schema.xsd")); + + Assert.HasCount(1, _source.Requests); + Assert.AreEqual(schemaUri, _source.Requests[0]); + Assert.AreEqual(1, _model.Document.Schemas.Count); + } + + [TestMethod] + [DataRow("http://mock-schema.invalid/schema.xsd")] + [DataRow("https://mock-schema.invalid/schema.xsd")] + public void OpeningDocumentLoadsHttpSchemaFromMockSource(string location) + { + var schemaUri = new Uri(location); + _source.Documents.Add(schemaUri, LocalSchema); + + LoadXml(CreateXml("noNamespaceSchemaLocation", location)); + + Assert.HasCount(1, _source.Requests); + Assert.AreEqual(schemaUri, _source.Requests[0]); + Assert.AreEqual(1, _model.Document.Schemas.Count); + } + + [TestMethod] + [DataRow("include")] + [DataRow("import")] + public void OpeningDocumentDoesNotRequestNetworkSchemaDependency(string directive) + { + var schemaUri = new Uri(new Uri(_documentFile), "schema.xsd"); + string targetNamespace = directive == "import" ? " namespace=\"urn:external\"" : ""; + _source.Documents.Add(schemaUri, + "" + + "" + + ""); + + LoadXml(CreateXml("noNamespaceSchemaLocation", "schema.xsd")); + + Assert.HasCount(1, _source.Requests, + "Only the local parent schema may reach the source. Requests: " + + string.Join(", ", _source.Requests)); + Assert.AreEqual(schemaUri, _source.Requests[0]); + } + + [TestMethod] + public void OpeningDocumentWithoutSchemaLocationDoesNotRequestSchema() + { + LoadXml(""); + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ParsingDtdDoesNotRequestNetworkResource(bool externalSubset) + { + _settings["IgnoreDTD"] = false; + string declaration = externalSubset + ? "" + : "]>"; + string xml = declaration + (externalSubset ? "" : "&external;"); + using (var text = new StringReader(xml)) + using (var reader = XmlHelpers.ReadXml(text, _resolver)) + { + var document = new XmlDocument(); + Assert.Throws(() => document.Load(reader)); + } + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public void ParsingDtdLoadsLocalEntityFromMockSource() + { + _settings["IgnoreDTD"] = false; + var entityUri = new Uri(new Uri(_documentFile), "entity.txt"); + _source.Documents.Add(entityUri, "allowed"); + string xml = "]>&external;"; + using (var text = new StringReader(xml)) + using (var reader = XmlHelpers.ReadXml(text, _resolver)) + { + var document = new XmlDocument(); + document.Load(reader); + Assert.AreEqual("allowed", document.InnerText); + } + + Assert.HasCount(1, _source.Requests); + } + + [TestMethod] + public void ExpandingIncludesDoesNotRequestNetworkResource() + { + LoadXml("" + + ""); + + Assert.Throws(() => _model.ExpandIncludes()); + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public void ExpandingIncludesLoadsLocalResourceFromMockSource() + { + var includeUri = new Uri(new Uri(_documentFile), "include.xml"); + _source.Documents.Add(includeUri, "allowed"); + LoadXml("" + + ""); + + _model.ExpandIncludes(); + + Assert.AreEqual("allowed", _model.Document.DocumentElement?.InnerText); + Assert.HasCount(1, _source.Requests); + } + + [TestMethod] + [DataRow("include")] + [DataRow("import")] + public void LoadingStylesheetDoesNotRequestNetworkResource(string directive) + { + string stylesheet = "" + + ""; + using (var text = new StringReader(stylesheet)) + using (var reader = XmlReader.Create(text)) + { + var transform = new XslCompiledTransform(); + Assert.Throws(() => transform.Load(reader, XsltSettings.Default, _resolver)); + } + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public void TransformingDoesNotRequestNetworkDocument() + { + var transform = CreateDocumentTransform(NetworkFileUri); + using (var text = new StringReader("")) + using (var reader = XmlReader.Create(text)) + using (var output = new StringWriter()) + using (var writer = XmlWriter.Create(output, transform.OutputSettings)) + { + Assert.Throws(() => transform.Transform(reader, null, writer, _resolver)); + } + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public void TransformingLoadsLocalDocumentFromMockSource() + { + var documentUri = new Uri(new Uri(_documentFile), "external.xml"); + _source.Documents.Add(documentUri, "allowed"); + var transform = CreateDocumentTransform(documentUri.AbsoluteUri); + using (var text = new StringReader("")) + using (var reader = XmlReader.Create(text)) + using (var output = new StringWriter()) + { + using (var writer = XmlWriter.Create(output, transform.OutputSettings)) + { + transform.Transform(reader, null, writer, _resolver); + } + StringAssert.Contains(output.ToString(), "allowed"); + } + + Assert.HasCount(1, _source.Requests); + } + + [TestMethod] + public async Task ResolvingNetworkResourceAsyncDoesNotReachSource() + { + await Assert.ThrowsAsync(() => + _resolver.GetEntityAsync(new Uri(NetworkFileUri), null, typeof(Stream))); + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + [DataRow("file:////mock-schema.invalid/schemas/resource.xml")] + [DataRow("file:///%5C%5C%3F%5CUNC%5Cmock-schema.invalid%5Cschemas%5Cresource.xml")] + public void ResolvingNetworkFileAliasDoesNotReachSource(string location) + { + Assert.Throws(() => + _resolver.GetEntity(new Uri(location), null, typeof(Stream))); + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + [DataRow(DriveType.Network)] + [DataRow(DriveType.Unknown)] + [DataRow(DriveType.NoRootDirectory)] + public void ResolvingRestrictedDriveDoesNotReachSource(DriveType driveType) + { + var uri = new Uri(_documentFile); + _source.Documents.Add(uri, ""); + var resolver = new ClassifiedDriveResolver(_source, driveType); + + Assert.Throws(() => resolver.GetEntity(uri, null, typeof(Stream))); + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public void RestrictedDriveIsClassifiedBeforeNormalizingShortNameTail() + { + var uri = new Uri(new Uri(_documentFile), "SHORT~1|invalid/schema.xsd"); + var resolver = new ClassifiedDriveResolver(_source, DriveType.Network); + + Assert.Throws(() => resolver.GetEntity(uri, null, typeof(Stream))); + + Assert.HasCount(1, resolver.Roots); + Assert.AreEqual(Path.GetPathRoot(_documentFile), resolver.Roots[0]); + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public void RootSuggestionsResolveDeferredImportsThroughSchemaResolver() + { + var childUri = new Uri("mock://schemas/child.xsd"); + const string parentSchema = + "" + + ""; + XmlSchema parent; + using (var text = new StringReader(parentSchema)) + using (var reader = XmlReader.Create(text, new XmlReaderSettings(), + "https://mock-schema.invalid/parent.xsd")) + { + parent = XmlSchema.Read(reader, null)!; + } + parent.SourceUri = "https://mock-schema.invalid/parent.xsd"; + _cache.Add(parent); + _source.Documents.Add(childUri, + "" + + "" + + ""); + _source.Requests.Clear(); + + var roots = new List(_cache.GetPossibleTopLevelElements()); + + Assert.HasCount(1, roots); + Assert.AreEqual(new XmlQualifiedName("root", "urn:parent"), roots[0].QualifiedName); + Assert.HasCount(1, _source.Requests); + Assert.AreEqual(childUri, _source.Requests[0]); + } + + [TestMethod] + public void LoadingResourceDocumentBlocksNetworkSource() + { + var uri = new Uri(NetworkFileUri); + _source.Documents.Add(uri, ""); + + Assert.Throws(() => XmlHelpers.LoadXmlResource(uri, _resolver)); + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + [DataRow("stylesheet.xsl")] + [DataRow("https://mock-schema.invalid/stylesheet.xsl")] + public void LoadingResourceDocumentUsesAllowedMockSource(string location) + { + var uri = new Uri(new Uri(_documentFile), location); + _source.Documents.Add(uri, ""); + + var document = XmlHelpers.LoadXmlResource(uri, _resolver); + + Assert.AreEqual("html", document.DocumentElement?.GetAttribute("method")); + Assert.HasCount(1, _source.Requests); + } + + [TestMethod] + [DataRow(NetworkSchemaLocation)] + [DataRow(NetworkFileUri)] + public void ReadingExplicitlySelectedNetworkDocumentDoesNotResolveItsRoot(string filename) + { + Uri? opened = null; + var input = new MemoryStream(Encoding.UTF8.GetBytes("selected")); + using (var reader = XmlHelpers.ReadDocument(filename, _model.GetReaderSettings(), location => + { + opened = location; + return input; + })) + { + var document = new XmlDocument(); + document.Load(reader); + Assert.AreEqual("selected", document.InnerText); + } + + Assert.AreEqual(new Uri(filename), opened); + Assert.IsFalse(input.CanRead); + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public void ReadingSelectedNetworkDocumentStillBlocksItsExternalEntities() + { + _settings["IgnoreDTD"] = false; + const string xml = "]>&external;"; + using (var reader = XmlHelpers.ReadDocument(NetworkFileUri, _model.GetReaderSettings(), + location => new MemoryStream(Encoding.UTF8.GetBytes(xml)))) + { + var document = new XmlDocument(); + Assert.Throws(() => document.Load(reader)); + } + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public async Task ResolvingMappedDriveAsyncDoesNotReachSource() + { + var uri = new Uri(_documentFile); + _source.Documents.Add(uri, ""); + var resolver = new ClassifiedDriveResolver(_source, DriveType.Network); + + await Assert.ThrowsAsync(() => resolver.GetEntityAsync(uri, null, typeof(Stream))); + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public async Task ResolvingLocalResourceAsyncUsesMockSource() + { + var uri = new Uri(_documentFile); + _source.Documents.Add(uri, "allowed"); + var resolver = new ClassifiedDriveResolver(_source, DriveType.Fixed); + using (var stream = (Stream)await resolver.GetEntityAsync(uri, null, typeof(Stream))) + using (var reader = new StreamReader(stream)) + { + Assert.AreEqual("allowed", await reader.ReadToEndAsync()); + } + + Assert.HasCount(1, _source.Requests); + } + + [TestMethod] + public void ReaderGuardsCustomResolverBeforeReadingExternalEntity() + { + _settings["IgnoreDTD"] = false; + string xml = "]>&external;"; + using (var text = new StringReader(xml)) + using (var reader = XmlHelpers.ReadXml(text, _source)) + { + var document = new XmlDocument(); + Assert.Throws(() => document.Load(reader)); + } + + Assert.IsEmpty(_source.Requests); + } + + [TestMethod] + public void LoadingSelectedLocalFilePreservesRelativeEntityBaseUri() + { + _settings["IgnoreDTD"] = false; + string filename = Path.GetTempFileName(); + var entityUri = new Uri(new Uri(filename), "entity.txt"); + _source.Documents.Add(entityUri, "allowed"); + try + { + File.WriteAllText(filename, + "]>&external;"); + + _model.Load(filename); + + Assert.AreEqual("allowed", _model.Document.DocumentElement?.InnerText); + Assert.HasCount(1, _source.Requests); + Assert.AreEqual(entityUri, _source.Requests[0]); + } + finally + { + _model.Clear(); + File.Delete(filename); + } + } + + private XslCompiledTransform CreateDocumentTransform(string location) + { + string stylesheet = "" + + ""; + var transform = new XslCompiledTransform(); + using (var text = new StringReader(stylesheet)) + using (var reader = XmlReader.Create(text)) + { + transform.Load(reader, new XsltSettings(true, false), _resolver); + } + return transform; + } + + private void LoadXml(string xml) + { + using (var text = new StringReader(xml)) + using (var reader = XmlReader.Create(text, _model.GetReaderSettings(), new Uri(_documentFile).AbsoluteUri)) + { + _model.Load(reader, _documentFile); + } + Assert.AreEqual("root", _model.Document.DocumentElement?.LocalName); + } + + private static string CreateXml(string schemaAttribute, string schemaLocation) + { + string namespaceAttribute = ""; + if (schemaAttribute == "schemaLocation") + { + namespaceAttribute = " xmlns=\"urn:mock-schema\""; + schemaLocation = "urn:mock-schema " + schemaLocation; + } + return ""; + } + + private sealed class MockSchemaSource : XmlResolver + { + public List Requests { get; } = new List(); + public Dictionary Documents { get; } = new Dictionary(); + + public override ICredentials? Credentials + { + set => throw new NotSupportedException("The mock source never uses credentials."); + } + + public override object GetEntity(Uri absoluteUri, string? role, Type? ofObjectToReturn) + { + Requests.Add(absoluteUri); + if (ofObjectToReturn != null && ofObjectToReturn != typeof(Stream)) + { + throw new NotSupportedException("The mock source only provides streams."); + } + + if (!Documents.TryGetValue(absoluteUri, out string? document)) + { + throw new XmlException("The mock source recorded the request without performing I/O."); + } + return new MemoryStream(Encoding.UTF8.GetBytes(document)); + } + + public override Task GetEntityAsync(Uri absoluteUri, string? role, Type? ofObjectToReturn) + { + return Task.FromResult(GetEntity(absoluteUri, role, ofObjectToReturn)); + } + } + + private sealed class TestProxyResolver : XmlProxyResolver + { + public TestProxyResolver(IServiceProvider site, XmlResolver source) : base(site, source) + { + } + } + + private sealed class ClassifiedDriveResolver : XmlResourceResolver + { + private readonly DriveType _driveType; + public List Roots { get; } = new List(); + + public ClassifiedDriveResolver(XmlResolver source, DriveType driveType) : base(source) + { + _driveType = driveType; + } + + protected override DriveType GetDriveType(string root) + { + Roots.Add(root); + return _driveType; + } + } + + private sealed class ValidationErrorHandler : ErrorHandler + { + public override void HandleError(Severity sev, string reason, string filename, int line, int col, object data) + { + Trace.WriteLine(sev + ": " + reason); + } + } + } +} diff --git a/src/Model.Tests/Model.Tests.csproj b/src/Model.Tests/Model.Tests.csproj new file mode 100644 index 00000000..88c02deb --- /dev/null +++ b/src/Model.Tests/Model.Tests.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net48 + 8.0 + enable + false + + + + + + + + + + + diff --git a/src/Model/Model.csproj b/src/Model/Model.csproj index b7edffa5..2a8f5a61 100644 --- a/src/Model/Model.csproj +++ b/src/Model/Model.csproj @@ -22,6 +22,7 @@ + True True diff --git a/src/Model/SchemaCache.cs b/src/Model/SchemaCache.cs index 57afa517..f2dbc25f 100644 --- a/src/Model/SchemaCache.cs +++ b/src/Model/SchemaCache.cs @@ -38,10 +38,7 @@ public Uri Location { _location = value; _schema = null; - if (_location.IsFile) - { - _fileName = _location.LocalPath; - } + _fileName = _location.IsFile ? _location.LocalPath : null; } } @@ -52,6 +49,10 @@ public bool HasUpToDateSchema if (_schema == null) return false; if (_fileName != null) { + if (XmlResourceResolver.IsRestrictedFileUri(_location)) + { + return false; + } DateTime lastWriteTime = File.GetLastWriteTime(_fileName); if (lastWriteTime > this._lastModified) { @@ -69,8 +70,12 @@ public XmlSchema Schema { if (_schema != value) { + if (value != null && _fileName != null) + { + XmlResourceResolver.ValidateUri(_location); + } _schema = value; - if (_fileName != null) + if (value != null && _fileName != null) { this._lastModified = File.GetLastWriteTime(_fileName); } @@ -172,11 +177,15 @@ public class SchemaCache : IXmlSerializable private XmlResolver resolver; private List globalElementCache; - public SchemaCache(IServiceProvider site) + public SchemaCache(IServiceProvider site) : this(site, null) + { + } + + public SchemaCache(IServiceProvider site, XmlResolver sourceResolver) { this.site = site; this.pfn = new PersistentFileNames(Settings.Instance.StartupPath); - this.resolver = new SchemaResolver(this.site, this); + this.resolver = new SchemaResolver(this.site, this, sourceResolver); } void FireOnChanged() @@ -770,8 +779,9 @@ public IEnumerable GetPossibleTopLevelElements() { if (globalElementCache == null || globalElementCache.Count == 0) { - globalElementCache = new List(); + var elements = new List(); XmlSchemaSet set = new XmlSchemaSet(); + set.XmlResolver = this.Resolver; HashSet knownTargetNamespaces = new HashSet(); foreach (var entry in this.GetSchemas()) { @@ -800,9 +810,10 @@ public IEnumerable GetPossibleTopLevelElements() { if (o is XmlSchemaElement e) { - globalElementCache.Add(e); + elements.Add(e); } } + globalElementCache = elements; } return globalElementCache; } @@ -820,7 +831,12 @@ public class SchemaResolver : XmlProxyResolver SchemaCache cache; ValidationEventHandler handler; - public SchemaResolver(IServiceProvider site, SchemaCache cache) : base(site) + public SchemaResolver(IServiceProvider site, SchemaCache cache) : this(site, cache, null) + { + } + + internal SchemaResolver(IServiceProvider site, SchemaCache cache, XmlResolver sourceResolver) + : base(site, sourceResolver) { this.cache = cache; } @@ -833,6 +849,7 @@ public ValidationEventHandler Handler public override object GetEntity(Uri absoluteUri, string role, Type ofObjectToReturn) { + XmlResourceResolver.ValidateUri(absoluteUri); CacheEntry ce = cache.FindSchemaByUri(absoluteUri); if (ce != null && ce.HasUpToDateSchema) return ce.Schema; diff --git a/src/Model/Settings.cs b/src/Model/Settings.cs index c34ac24c..2b981535 100644 --- a/src/Model/Settings.cs +++ b/src/Model/Settings.cs @@ -1047,6 +1047,10 @@ public string GetPersistentFileName(Uri uri) result = uri.OriginalString; int len = 0; string path = uri.AbsolutePath; + if (XmlResourceResolver.IsRestrictedFileUri(uri)) + { + return uri.OriginalString; + } if (uri.IsFile && !File.Exists(uri.LocalPath)) // sanity check! return null; diff --git a/src/Model/Strings.Designer.cs b/src/Model/Strings.Designer.cs index 64746f0e..80d1d930 100644 --- a/src/Model/Strings.Designer.cs +++ b/src/Model/Strings.Designer.cs @@ -105,6 +105,15 @@ internal static string InvalidCharacter { } } + /// + /// Looks up a localized string similar to External access to file location '{0}' is blocked. Use a local file or an HTTP(S) URL.. + /// + internal static string ExternalFileResourceBlocked { + get { + return ResourceManager.GetString("ExternalFileResourceBlocked", resourceCulture); + } + } + /// /// Looks up a localized string similar to Error loading schema '{0}' ///{1}. diff --git a/src/Model/Strings.resx b/src/Model/Strings.resx index da85af0d..fd13312d 100644 --- a/src/Model/Strings.resx +++ b/src/Model/Strings.resx @@ -115,6 +115,10 @@ {1} Message box contents, arg0=filename, arg1=Exception.Message. + + External access to file location '{0}' is blocked. Use a local file or an HTTP(S) URL. + Exception message, arg0=resource URI. + Unexpected token '{0}' at line {1} column {2} Message box, arg0=token, arg1=number, arg2=number diff --git a/src/Model/XmlCache.cs b/src/Model/XmlCache.cs index f70da590..3d480fdb 100644 --- a/src/Model/XmlCache.cs +++ b/src/Model/XmlCache.cs @@ -200,7 +200,8 @@ public void Load(string file) } var handler = new ValidationEventHandler(OnValidationEvent); - using (XmlReader reader = XmlHelpers.ReadXml(file, null, handler)) + using (var reader = XmlHelpers.ReadDocument(file, + XmlHelpers.CreateXmlSettings(Settings.Instance.Resolver, handler))) { this.Load(reader, file); } diff --git a/src/Model/XmlHelpers.cs b/src/Model/XmlHelpers.cs index ef530943..9a8de2fc 100644 --- a/src/Model/XmlHelpers.cs +++ b/src/Model/XmlHelpers.cs @@ -1,7 +1,7 @@ using Microsoft.Xml; +using System; using System.Collections.Generic; using System.IO; -using System.Net; using System.Xml; using System.Xml.Schema; @@ -231,17 +231,7 @@ public static XmlReaderSettings CreateXmlSettings(XmlResolver resolver = null, V var ignoreDtd = Settings.Instance.GetBoolean("IgnoreDTD"); rs.DtdProcessing = ignoreDtd ? DtdProcessing.Ignore : DtdProcessing.Parse; rs.NameTable = new NameTable(); - if (resolver != null) - { - rs.XmlResolver = resolver; - } - else if (!ignoreDtd) - { - rs.XmlResolver = new XmlUrlResolver - { - Credentials = CredentialCache.DefaultCredentials - }; - } + rs.XmlResolver = resolver == null ? new XmlResourceResolver() : new XmlResourceResolver(resolver); if (handler != null) { rs.ValidationEventHandler += handler; @@ -255,6 +245,53 @@ public static XmlReader ReadXml(string path, XmlResolver resolver = null, Valida return XmlReader.Create(path, rs); } + public static XmlDocument LoadXmlResource(Uri location, XmlResolver resolver) + { + XmlResourceResolver.ValidateUri(location); + var document = new XmlDocument(); + using (var reader = ReadXml(location.AbsoluteUri, resolver)) + { + document.Load(reader); + } + return document; + } + + public static XmlReader ReadDocument(string filename, XmlReaderSettings settings, + Func openFile = null) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + var location = new Uri(filename, UriKind.RelativeOrAbsolute); + if (!location.IsAbsoluteUri) + { + location = new Uri(new Uri(Directory.GetCurrentDirectory() + Path.DirectorySeparatorChar), location); + } + if (!location.IsFile) + { + return XmlReader.Create(location.AbsoluteUri, settings); + } + + // Only the explicitly selected input bypasses resource resolution, not its references. + Stream input = openFile == null ? File.OpenRead(location.LocalPath) : openFile(location); + if (input == null) + { + throw new InvalidOperationException("The selected document source did not return a stream."); + } + try + { + var readerSettings = settings.Clone(); + readerSettings.CloseInput = true; + return XmlReader.Create(input, readerSettings, location.AbsoluteUri); + } + catch + { + input.Dispose(); + throw; + } + } + public static XmlReader ReadXml(Stream stm, XmlResolver resolver = null, ValidationEventHandler handler = null) { var rs = CreateXmlSettings(resolver, handler); diff --git a/src/Model/XmlResourceResolver.cs b/src/Model/XmlResourceResolver.cs new file mode 100644 index 00000000..90c5aacc --- /dev/null +++ b/src/Model/XmlResourceResolver.cs @@ -0,0 +1,114 @@ +using System; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using System.Xml; + +namespace XmlNotepad +{ + public class XmlResourceResolver : XmlResolver + { + private readonly XmlResolver _resolver; + + public XmlResourceResolver() : this(new XmlUrlResolver()) + { + } + + public XmlResourceResolver(XmlResolver resolver) + { + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + } + + public override ICredentials Credentials + { + set => _resolver.Credentials = value; + } + + public override Uri ResolveUri(Uri baseUri, string relativeUri) + { + return _resolver.ResolveUri(baseUri, relativeUri); + } + + public override bool SupportsType(Uri absoluteUri, Type type) + { + return _resolver.SupportsType(absoluteUri, type); + } + + public override object GetEntity(Uri absoluteUri, string role, Type ofObjectToReturn) + { + ValidateUri(absoluteUri, GetDriveType); + return _resolver.GetEntity(absoluteUri, role, ofObjectToReturn); + } + + public override Task GetEntityAsync(Uri absoluteUri, string role, Type ofObjectToReturn) + { + ValidateUri(absoluteUri, GetDriveType); + return _resolver.GetEntityAsync(absoluteUri, role, ofObjectToReturn); + } + + public static void ValidateUri(Uri absoluteUri) + { + ValidateUri(absoluteUri, GetSystemDriveType); + } + + protected virtual DriveType GetDriveType(string root) + { + return GetSystemDriveType(root); + } + + internal static bool IsRestrictedFileUri(Uri absoluteUri) + { + return IsRestrictedFileUri(absoluteUri, GetSystemDriveType); + } + + private static void ValidateUri(Uri absoluteUri, Func getDriveType) + { + if (absoluteUri == null) + { + throw new ArgumentNullException(nameof(absoluteUri)); + } + if (IsRestrictedFileUri(absoluteUri, getDriveType)) + { + throw new XmlException(string.Format(Strings.ExternalFileResourceBlocked, absoluteUri)); + } + } + + private static bool IsRestrictedFileUri(Uri absoluteUri, Func getDriveType) + { + if (!absoluteUri.IsFile) + { + return false; + } + + string path = absoluteUri.LocalPath; + if (absoluteUri.IsUnc || path.StartsWith(@"\\", StringComparison.Ordinal) || + path.StartsWith("//", StringComparison.Ordinal)) + { + return true; + } + + string root = Path.DirectorySeparatorChar.ToString(); + if (Path.DirectorySeparatorChar == '\\') + { + if (path.Length < 3 || path[1] != ':' || (path[2] != '\\' && path[2] != '/')) + { + return true; + } + root = path.Substring(0, 2) + "\\"; + } + else if (path.Length == 0 || path[0] != Path.DirectorySeparatorChar) + { + return true; + } + // Framework path normalization can probe short names, so classify the lexical root first. + DriveType driveType = getDriveType(root); + return driveType == DriveType.Network || driveType == DriveType.Unknown || + driveType == DriveType.NoRootDirectory; + } + + private static DriveType GetSystemDriveType(string root) + { + return new DriveInfo(root).DriveType; + } + } +} diff --git a/src/Model/proxy.cs b/src/Model/proxy.cs index 695e80ff..c45c8df7 100644 --- a/src/Model/proxy.cs +++ b/src/Model/proxy.cs @@ -16,10 +16,16 @@ namespace XmlNotepad public class XmlProxyResolver : XmlUrlResolver { private readonly WebProxyService _ps; + private readonly XmlResolver _sourceResolver; - public XmlProxyResolver(IServiceProvider site) + public XmlProxyResolver(IServiceProvider site) : this(site, null) + { + } + + protected XmlProxyResolver(IServiceProvider site, XmlResolver sourceResolver) { _ps = site.GetService(typeof(WebProxyService)) as WebProxyService; + _sourceResolver = sourceResolver; Proxy = HttpWebRequest.DefaultWebProxy; } @@ -29,6 +35,11 @@ public override object GetEntity(Uri absoluteUri, string role, Type ofObjectToRe { throw new ArgumentNullException("absoluteUri"); } + XmlResourceResolver.ValidateUri(absoluteUri); + if (_sourceResolver != null) + { + return _sourceResolver.GetEntity(absoluteUri, role, ofObjectToReturn); + } if ((absoluteUri.Scheme == "http" || absoluteUri.Scheme == "https") && (ofObjectToReturn == null || ofObjectToReturn == typeof(Stream))) { @@ -58,6 +69,16 @@ public override object GetEntity(Uri absoluteUri, string role, Type ofObjectToRe } } + public override System.Threading.Tasks.Task GetEntityAsync(Uri absoluteUri, string role, Type ofObjectToReturn) + { + XmlResourceResolver.ValidateUri(absoluteUri); + if (_sourceResolver != null) + { + return _sourceResolver.GetEntityAsync(absoluteUri, role, ofObjectToReturn); + } + return base.GetEntityAsync(absoluteUri, role, ofObjectToReturn); + } + Stream GetResponse(Uri uri) { Debug.WriteLine($"Loading Uri {uri}"); diff --git a/src/XmlNotepad/AsyncXslt.cs b/src/XmlNotepad/AsyncXslt.cs index 6a7f97f9..2f9ab6a7 100644 --- a/src/XmlNotepad/AsyncXslt.cs +++ b/src/XmlNotepad/AsyncXslt.cs @@ -31,7 +31,7 @@ internal class AsyncXsltContext public bool hasDefaultXsltOutput; public bool enableScripts; public bool disableOutputFile; - public XmlUrlResolver resolver; + public XmlResolver resolver; public CancellationTokenSource token = new CancellationTokenSource(); public string defaultSSResource; public bool running; @@ -249,6 +249,7 @@ async System.Threading.Tasks.Task InternalRunTransform() else { resolved = new Uri(_context.baseUri, this._context.xsltfilename); + XmlResourceResolver.ValidateUri(resolved); if (resolved != this._xsltUri || IsModified()) { try @@ -263,8 +264,7 @@ async System.Threading.Tasks.Task InternalRunTransform() } // the XSLT DOM is also handy to have around for GetOutputMethod - this._xsltdoc = new XmlDocument(); - this._xsltdoc.Load(resolved.AbsoluteUri); + this._xsltdoc = XmlHelpers.LoadXmlResource(resolved, this._context.resolver); } catch (Exception) { @@ -321,7 +321,10 @@ async System.Threading.Tasks.Task InternalRunTransform() { using (StringWriter writer = new StringWriter()) { - transform.Transform(xmlReader, null, writer); + using (XmlWriter output = XmlWriter.Create(writer, transform.OutputSettings)) + { + transform.Transform(xmlReader, null, output, this._context.resolver); + } this._xsltUri = resolved; this._context.output = writer.ToString(); } @@ -339,7 +342,10 @@ async System.Threading.Tasks.Task InternalRunTransform() // cache to an inmemory stream so we can strip the BOM. using (MemoryStream ms = new MemoryStream()) { - transform.Transform(xmlReader, null, ms); + using (XmlWriter output = XmlWriter.Create(ms, transform.OutputSettings)) + { + transform.Transform(xmlReader, null, output, this._context.resolver); + } ms.Seek(0, SeekOrigin.Begin); EncodingHelpers.WriteFileWithoutBOM(ms, outpath); } @@ -356,7 +362,10 @@ async System.Threading.Tasks.Task InternalRunTransform() this._context.writer = wrapper; Stopwatch watch = new Stopwatch(); watch.Start(); - transform.Transform(xmlReader, null, wrapper); + using (XmlWriter output = XmlWriter.Create(wrapper, transform.OutputSettings)) + { + transform.Transform(xmlReader, null, output, this._context.resolver); + } watch.Stop(); this._context.info.XsltMilliseconds = watch.ElapsedMilliseconds; Debug.WriteLine("Transform in {0} milliseconds", watch.ElapsedMilliseconds); @@ -411,8 +420,7 @@ public string GetDefaultOutputExtension(string customFileName = null) { string path = customFileName; var resolved = new Uri(_context.baseUri, path); - this._xsltdoc = new XmlDocument(); - this._xsltdoc.Load(resolved.AbsoluteUri); + this._xsltdoc = XmlHelpers.LoadXmlResource(resolved, _context.resolver); } var method = GetOutputMethod(this._xsltdoc); if (method.ToLower() == "html") @@ -577,7 +585,7 @@ XslCompiledTransform GetDefaultStylesheet() using (XmlReader reader = XmlHelpers.ReadXml(new StringReader(html))) { XslCompiledTransform t = new XslCompiledTransform(); - t.Load(reader); + t.Load(reader, XsltSettings.Default, _context.resolver); _defaultss = t; } // the XSLT DOM is also handy to have around for GetOutputMethod diff --git a/src/XmlNotepad/XsdInstanceGenerator.cs b/src/XmlNotepad/XsdInstanceGenerator.cs index d78a9c43..0d7acb94 100644 --- a/src/XmlNotepad/XsdInstanceGenerator.cs +++ b/src/XmlNotepad/XsdInstanceGenerator.cs @@ -46,8 +46,8 @@ public XsdInstanceGenerator(XmlSchema schema, IXmlNamespaceResolver mgr, XmlReso } this.xmlResolver = xmlResolver; this.schemaSet = new XmlSchemaSet(); - this.schemaSet.Add(schema); Init(mgr); + this.schemaSet.Add(schema); } private void Init(IXmlNamespaceResolver mgr) diff --git a/src/XmlNotepad/XsltControl.cs b/src/XmlNotepad/XsltControl.cs index 8503ebb5..5ca9c61a 100644 --- a/src/XmlNotepad/XsltControl.cs +++ b/src/XmlNotepad/XsltControl.cs @@ -32,7 +32,7 @@ public partial class XsltControl : UserControl private Uri _baseUri; private PerformanceInfo _info = null; private ISite _site; - private XmlUrlResolver _resolver; + private XmlResolver _resolver; private Settings _settings; private bool _webInitialized; private bool _webView2Initialized; @@ -50,7 +50,7 @@ public partial class XsltControl : UserControl public XsltControl() { InitializeComponent(); - _resolver = new XmlUrlResolver(); + _resolver = new XmlResourceResolver(); _delayedActions = new DelayedActions(); } diff --git a/src/XmlNotepad/XsltViewer.cs b/src/XmlNotepad/XsltViewer.cs index 5f422875..27d83506 100644 --- a/src/XmlNotepad/XsltViewer.cs +++ b/src/XmlNotepad/XsltViewer.cs @@ -103,10 +103,15 @@ private void OnOutputFileNameKeyDown(object sender, KeyEventArgs e) } } - private bool IsValidPath(string path) + private bool IsValidPath(string path, bool externalResource = false) { try { + if (externalResource) + { + XmlResourceResolver.ValidateUri(this.xsltControl.ResolveRelativePath(path)); + return true; + } Uri uri = new Uri(path, UriKind.RelativeOrAbsolute); if (uri.IsAbsoluteUri && uri.Scheme == Uri.UriSchemeFile) { @@ -130,7 +135,7 @@ public async void DisplayXsltResults() string xpath = this.SourceFileName.Text.Trim().Trim('"'); if (!string.IsNullOrEmpty(xpath) && this._xsltFiles != null) { - if (!IsValidPath(xpath)) + if (!IsValidPath(xpath, externalResource: true)) { return; } diff --git a/src/xmlnotepad.sln b/src/xmlnotepad.sln index 51e1c929..324bfb92 100644 --- a/src/xmlnotepad.sln +++ b/src/xmlnotepad.sln @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsInput", "WindowsInpu EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UpdateVersions", "UpdateVersions\UpdateVersions.csproj", "{DEEBB5D0-2B54-4A9D-9DC3-CD3060E7A533}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Model.Tests", "Model.Tests\Model.Tests.csproj", "{62171386-7CA3-4DDE-B81E-440BF8D222FC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -241,6 +243,30 @@ Global {DEEBB5D0-2B54-4A9D-9DC3-CD3060E7A533}.Release|x64.Build.0 = Release|Any CPU {DEEBB5D0-2B54-4A9D-9DC3-CD3060E7A533}.Release|x86.ActiveCfg = Release|Any CPU {DEEBB5D0-2B54-4A9D-9DC3-CD3060E7A533}.Release|x86.Build.0 = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|ARM.ActiveCfg = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|ARM.Build.0 = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|ARM64.Build.0 = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|x64.ActiveCfg = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|x64.Build.0 = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|x86.ActiveCfg = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Debug|x86.Build.0 = Debug|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|Any CPU.Build.0 = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|ARM.ActiveCfg = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|ARM.Build.0 = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|ARM64.ActiveCfg = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|ARM64.Build.0 = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|x64.ActiveCfg = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|x64.Build.0 = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|x86.ActiveCfg = Release|Any CPU + {62171386-7CA3-4DDE-B81E-440BF8D222FC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE