diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs index 8eb2df6a16..3509690f3c 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.Razor.Internal; @@ -32,7 +33,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor private const string ControllerKey = "controller"; private const string AreaKey = "area"; + private const string ParentDirectoryToken = ".."; private static readonly TimeSpan _cacheExpirationDuration = TimeSpan.FromMinutes(20); + private static readonly char[] _pathSeparators = new[] { '/', '\\' }; private readonly IRazorPageFactoryProvider _pageFactory; private readonly IRazorPageActivator _pageActivator; @@ -329,19 +332,66 @@ namespace Microsoft.AspNetCore.Mvc.Razor return pagePath; } - // Given a relative path i.e. not yet application-relative (starting with "~/" or "/"), interpret - // path relative to currently-executing view, if any. + string absolutePath; if (string.IsNullOrEmpty(executingFilePath)) { + // Given a relative path i.e. not yet application-relative (starting with "~/" or "/"), interpret + // path relative to currently-executing view, if any. // Not yet executing a view. Start in app root. - return "/" + pagePath; + absolutePath = "/" + pagePath; + } + else + { + // Get directory name (including final slash) but do not use Path.GetDirectoryName() to preserve path + // normalization. + var index = executingFilePath.LastIndexOf('/'); + Debug.Assert(index >= 0); + absolutePath = executingFilePath.Substring(0, index + 1) + pagePath; + if (!RequiresPathResolution(pagePath)) + { + return absolutePath; + } } - // Get directory name (including final slash) but do not use Path.GetDirectoryName() to preserve path - // normalization. - var index = executingFilePath.LastIndexOf('/'); - Debug.Assert(index >= 0); - return executingFilePath.Substring(0, index + 1) + pagePath; + if (!RequiresPathResolution(pagePath)) + { + return absolutePath; + } + + var pathSegments = new List(); + var tokenizer = new StringTokenizer(absolutePath, _pathSeparators); + foreach (var segment in tokenizer) + { + if (segment.Length == 0) + { + // Ignore multiple directory separators + continue; + } + if (segment.Equals(ParentDirectoryToken, StringComparison.Ordinal)) + { + if (pathSegments.Count == 0) + { + // Don't resolve the path if we ever escape the file system root. We can't reason about it in a + // consistent way. + return absolutePath; + } + pathSegments.RemoveAt(pathSegments.Count - 1); + } + else + { + pathSegments.Add(segment); + } + } + + var builder = new StringBuilder(); + for (var i = 0; i < pathSegments.Count; i++) + { + var segment = pathSegments[i]; + builder.Append('/'); + builder.Append(segment.Buffer, segment.Offset, segment.Length); + } + + return builder.ToString(); } private ViewLocationCacheResult OnCacheMiss( @@ -490,5 +540,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor // Though ./ViewName looks like a relative path, framework searches for that view using view locations. return name.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase); } + + private static bool RequiresPathResolution(string path) + { + return path.IndexOf(ParentDirectoryToken, StringComparison.Ordinal) != -1; + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs index 88a1a4235d..cfa8378b50 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ViewEngineTests.cs @@ -246,6 +246,44 @@ ViewWithNestedLayout-Content Assert.Equal(expected, body.Trim(), ignoreLineEndingDifferences: true); } + [Fact] + public async Task RazorViewEngine_RendersViewsFromEmbeddedFileProvider() + { + // Arrange + var expected = +@"Hello from EmbeddedShared/_Partial +Hello from Shared/_EmbeddedPartial +"; + + // Act + var body = await Client.GetStringAsync("/EmbeddedViews"); + + // Assert + Assert.Equal(expected, body.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task RazorViewEngine_UpdatesViewsReferencedViaRelativePathsOnChange() + { + // Arrange + var expected1 = "Original content"; + var expected2 = "New content"; + + // Act - 1 + var body = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Assert - 1 + Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true); + + // Act - 2 + var response = await Client.PostAsync("/UpdateableFileProvider/Update", new StringContent(string.Empty)); + response.EnsureSuccessStatusCode(); + body = await Client.GetStringAsync("/UpdateableFileProvider"); + + // Assert - 1 + Assert.Equal(expected2, body.Trim(), ignoreLineEndingDifferences: true); + } + [Fact] public async Task LayoutValueIsPassedBetweenNestedViewStarts() { diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs index 5b8869680b..a2fffe78ab 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorViewEngineTest.cs @@ -1419,6 +1419,39 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test Assert.Same(pagePath, result); } + [Theory] + [InlineData("/Views/Home/Index.cshtml", "../Shared/_Partial.cshtml")] + [InlineData("/Views/Home/Index.cshtml", "..\\Shared\\_Partial.cshtml")] + [InlineData("/Areas/MyArea/Views/Home/Index.cshtml", "../../../../Views/Shared/_Partial.cshtml")] + [InlineData("/Views/Accounts/Users.cshtml", "../Test/../Shared/_Partial.cshtml")] + public void GetAbsolutePath_ResolvesPathTraversals(string executingFilePath, string pagePath) + { + // Arrange + var viewEngine = CreateViewEngine(); + + // Act + var result = viewEngine.GetAbsolutePath(executingFilePath, pagePath); + + // Assert + Assert.Equal("/Views/Shared/_Partial.cshtml", result); + } + + [Theory] + [InlineData("../Shared/_Layout.cshtml")] + [InlineData("Folder1/../Folder2/../../File.cshtml")] + public void GetAbsolutePath_DoesNotResolvePathIfTraversalsEscapeTheRoot(string pagePath) + { + // Arrange + var expected = '/' + pagePath; + var viewEngine = CreateViewEngine(); + + // Act + var result = viewEngine.GetAbsolutePath("/Index.cshtml", pagePath); + + // Assert + Assert.Equal(expected, result); + } + [Theory] [InlineData(null, "/Page")] [InlineData(null, "~/Folder/Page.cshtml")] diff --git a/test/WebSites/RazorWebSite/Controllers/EmbeddedViewsController.cs b/test/WebSites/RazorWebSite/Controllers/EmbeddedViewsController.cs new file mode 100644 index 0000000000..33837ea026 --- /dev/null +++ b/test/WebSites/RazorWebSite/Controllers/EmbeddedViewsController.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Mvc; + +namespace RazorWebSite.Controllers +{ + public class EmbeddedViewsController : Controller + { + public IActionResult Index() => View("/Views/EmbeddedHome/Index.cshtml"); + } +} diff --git a/test/WebSites/RazorWebSite/Controllers/UpdateableFileProviderController.cs b/test/WebSites/RazorWebSite/Controllers/UpdateableFileProviderController.cs new file mode 100644 index 0000000000..a504b11cb6 --- /dev/null +++ b/test/WebSites/RazorWebSite/Controllers/UpdateableFileProviderController.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Mvc; + +namespace RazorWebSite +{ + public class UpdateableFileProviderController : Controller + { + public IActionResult Index() => View("/Views/UpdateableIndex/Index.cshtml"); + + [HttpPost] + public IActionResult Update([FromServices] UpdateableFileProvider fileProvider) + { + fileProvider.UpdateContent("/Views/UpdateableShared/_Partial.cshtml", "New content"); + return Ok(); + } + } +} diff --git a/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedHome/Index.cshtml b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedHome/Index.cshtml new file mode 100644 index 0000000000..b06c70fd55 --- /dev/null +++ b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedHome/Index.cshtml @@ -0,0 +1,3 @@ +@{ Layout = "/Views/EmbeddedShared/_Layout.cshtml"; } +@Html.Partial("../EmbeddedShared/_Partial.cshtml") +@Html.Partial("_EmbeddedPartial") diff --git a/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedShared/_Layout.cshtml b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedShared/_Layout.cshtml new file mode 100644 index 0000000000..e16c087dfd --- /dev/null +++ b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedShared/_Layout.cshtml @@ -0,0 +1 @@ +@RenderBody() \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedShared/_Partial.cshtml b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedShared/_Partial.cshtml new file mode 100644 index 0000000000..b34fab2eec --- /dev/null +++ b/test/WebSites/RazorWebSite/EmbeddedViews/Views/EmbeddedShared/_Partial.cshtml @@ -0,0 +1 @@ +Hello from EmbeddedShared/_Partial \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/EmbeddedViews/Views/Shared/_EmbeddedPartial.cshtml b/test/WebSites/RazorWebSite/EmbeddedViews/Views/Shared/_EmbeddedPartial.cshtml new file mode 100644 index 0000000000..ef09462726 --- /dev/null +++ b/test/WebSites/RazorWebSite/EmbeddedViews/Views/Shared/_EmbeddedPartial.cshtml @@ -0,0 +1 @@ +Hello from Shared/_EmbeddedPartial \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Services/UpdateableFileProvider.cs b/test/WebSites/RazorWebSite/Services/UpdateableFileProvider.cs new file mode 100644 index 0000000000..394230d275 --- /dev/null +++ b/test/WebSites/RazorWebSite/Services/UpdateableFileProvider.cs @@ -0,0 +1,88 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace RazorWebSite +{ + public class UpdateableFileProvider : IFileProvider + { + private readonly Dictionary _content = new Dictionary() + { + { + "/Views/UpdateableIndex/Index.cshtml", + new TestFileInfo(@"@Html.Partial(""../UpdateableShared/_Partial.cshtml"")") + }, + { + "/Views/UpdateableShared/_Partial.cshtml", + new TestFileInfo("Original content") + }, + }; + + public IDirectoryContents GetDirectoryContents(string subpath) + { + throw new NotImplementedException(); + } + + public void UpdateContent(string subpath, string content) + { + var old = _content[subpath]; + old.TokenSource.Cancel(); + _content[subpath] = new TestFileInfo(content); + } + + public IFileInfo GetFileInfo(string subpath) + { + TestFileInfo fileInfo; + if (!_content.TryGetValue(subpath, out fileInfo)) + { + fileInfo = new TestFileInfo(null); + } + + return fileInfo; + } + + public IChangeToken Watch(string filter) + { + TestFileInfo fileInfo; + if (_content.TryGetValue(filter, out fileInfo)) + { + return fileInfo.ChangeToken; + } + + return NullChangeToken.Singleton; + } + + private class TestFileInfo : IFileInfo + { + private readonly string _content; + + public TestFileInfo(string content) + { + _content = content; + ChangeToken = new CancellationChangeToken(TokenSource.Token); + Exists = _content != null; + } + + public bool Exists { get; } + public bool IsDirectory => false; + public DateTimeOffset LastModified => DateTimeOffset.MinValue; + public long Length => -1; + public string Name => null; + public string PhysicalPath => null; + public CancellationTokenSource TokenSource { get; } = new CancellationTokenSource(); + public CancellationChangeToken ChangeToken { get; } + + public Stream CreateReadStream() + { + return new MemoryStream(Encoding.UTF8.GetBytes(_content)); + } + } + } +} diff --git a/test/WebSites/RazorWebSite/Startup.cs b/test/WebSites/RazorWebSite/Startup.cs index 2b2974efcc..f9b2f2a7f1 100644 --- a/test/WebSites/RazorWebSite/Startup.cs +++ b/test/WebSites/RazorWebSite/Startup.cs @@ -4,11 +4,13 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; namespace RazorWebSite { @@ -16,10 +18,16 @@ namespace RazorWebSite { public void ConfigureServices(IServiceCollection services) { + var updateableFileProvider = new UpdateableFileProvider(); + services.AddSingleton(updateableFileProvider); services .AddMvc() .AddRazorOptions(options => { + options.FileProviders.Add(new EmbeddedFileProvider( + typeof(Startup).GetTypeInfo().Assembly, + $"{nameof(RazorWebSite)}.EmbeddedViews")); + options.FileProviders.Add(updateableFileProvider); options.ViewLocationExpanders.Add(new NonMainPageViewLocationExpander()); #if NET451 options.ParseOptions = options.ParseOptions.WithPreprocessorSymbols("DNX451", "NET451_CUSTOM_DEFINE"); diff --git a/test/WebSites/RazorWebSite/project.json b/test/WebSites/RazorWebSite/project.json index b732d11d01..321eac89f8 100644 --- a/test/WebSites/RazorWebSite/project.json +++ b/test/WebSites/RazorWebSite/project.json @@ -1,7 +1,10 @@ { "buildOptions": { "emitEntryPoint": true, - "preserveCompilationContext": true + "preserveCompilationContext": true, + "embed": { + "include": "EmbeddedViews/**" + } }, "publishOptions": { "include": [ @@ -17,7 +20,8 @@ }, "Microsoft.AspNetCore.Server.IISIntegration": "1.2.0-*", "Microsoft.AspNetCore.Server.Kestrel": "1.2.0-*", - "Microsoft.AspNetCore.StaticFiles": "1.2.0-*" + "Microsoft.AspNetCore.StaticFiles": "1.2.0-*", + "Microsoft.Extensions.FileProviders.Embedded": "1.2.0-*" }, "frameworks": { "net451": {