From 2b1b2b85f8beb254fc7f2bed5e74369b9837bb84 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Sat, 21 Mar 2026 21:55:23 +0200 Subject: [PATCH 1/5] Consolidate file path resolution for WebView controls Refactored scattered file-path resolution logic into shared utilities (FileSystemUtils.Combine, WebUtils.ResolveRelativePath) to fix edge cases where Path.Combine drops the root directory when a relative path starts with a separator. Added device tests for path resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Android/AndroidMauiAssetFileProvider.cs | 15 +- .../Maui/Tizen/TizenMauiAssetFileProvider.cs | 15 +- .../src/Maui/Windows/WinUIWebViewManager.cs | 7 +- .../src/Maui/iOS/iOSMauiAssetFileProvider.cs | 15 +- ...lazorWebViewTests.ContentRootResolution.cs | 137 ++++++++++++++++++ src/Controls/src/Core/Controls.Core.csproj | 1 + ...ybridWebViewTests_ContentRootResolution.cs | 114 +++++++++++++++ .../Raw/HybridTestRoot/safe-file.txt | 1 + .../Raw/HybridTestRoot/urlresolution.html | 48 ++++++ .../HybridWebViewHandler.Windows.cs | 12 +- .../HybridWebView/HybridWebViewHandler.iOS.cs | 16 +- .../Android/MauiHybridWebViewClient.cs | 11 +- src/Essentials/src/Email/Email.windows.cs | 6 +- .../src/FileSystem/FileSystemUtils.shared.cs | 43 ++++++ .../src/Types/Shared/WebUtils.shared.cs | 17 +++ 15 files changed, 435 insertions(+), 23 deletions(-) create mode 100644 src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs create mode 100644 src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_ContentRootResolution.cs create mode 100644 src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/safe-file.txt create mode 100644 src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/urlresolution.html diff --git a/src/BlazorWebView/src/Maui/Android/AndroidMauiAssetFileProvider.cs b/src/BlazorWebView/src/Maui/Android/AndroidMauiAssetFileProvider.cs index 41ef886213a1..24c577391fc5 100644 --- a/src/BlazorWebView/src/Maui/Android/AndroidMauiAssetFileProvider.cs +++ b/src/BlazorWebView/src/Maui/Android/AndroidMauiAssetFileProvider.cs @@ -6,6 +6,7 @@ using Android.Content.Res; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; +using Microsoft.Maui.Storage; namespace Microsoft.AspNetCore.Components.WebView.Maui { @@ -24,10 +25,20 @@ public AndroidMauiAssetFileProvider(AssetManager? assets, string contentRootDir) } public IDirectoryContents GetDirectoryContents(string subpath) - => new AndroidMauiAssetDirectoryContents(_assets, Path.Combine(_contentRootDir, subpath)); + { + var resolvedPath = FileSystemUtils.Combine(_contentRootDir, subpath); + if (resolvedPath is null) + return NotFoundDirectoryContents.Singleton; + return new AndroidMauiAssetDirectoryContents(_assets, resolvedPath); + } public IFileInfo GetFileInfo(string subpath) - => new AndroidMauiAssetFileInfo(_assets, Path.Combine(_contentRootDir, subpath)); + { + var resolvedPath = FileSystemUtils.Combine(_contentRootDir, subpath); + if (resolvedPath is null) + return new NotFoundFileInfo(subpath); + return new AndroidMauiAssetFileInfo(_assets, resolvedPath); + } public IChangeToken Watch(string filter) => NullChangeToken.Singleton; diff --git a/src/BlazorWebView/src/Maui/Tizen/TizenMauiAssetFileProvider.cs b/src/BlazorWebView/src/Maui/Tizen/TizenMauiAssetFileProvider.cs index 1902bac976ff..2cc5419b79f8 100644 --- a/src/BlazorWebView/src/Maui/Tizen/TizenMauiAssetFileProvider.cs +++ b/src/BlazorWebView/src/Maui/Tizen/TizenMauiAssetFileProvider.cs @@ -4,6 +4,7 @@ using System.IO; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; +using Microsoft.Maui.Storage; using Tizen.Applications; namespace Microsoft.AspNetCore.Components.WebView.Maui @@ -21,10 +22,20 @@ public TizenMauiAssetFileProvider(string contentRootDir) } public IDirectoryContents GetDirectoryContents(string subpath) - => new TizenMauiAssetDirectoryContents(Path.Combine(_resDir, subpath)); + { + var resolvedPath = FileSystemUtils.Combine(_resDir, subpath); + if (resolvedPath is null) + return NotFoundDirectoryContents.Singleton; + return new TizenMauiAssetDirectoryContents(resolvedPath); + } public IFileInfo GetFileInfo(string subpath) - => new TizenMauiAssetFileInfo(Path.Combine(_resDir, subpath)); + { + var resolvedPath = FileSystemUtils.Combine(_resDir, subpath); + if (resolvedPath is null) + return new NotFoundFileInfo(subpath); + return new TizenMauiAssetFileInfo(resolvedPath); + } public IChangeToken Watch(string filter) => NullChangeToken.Singleton; diff --git a/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs b/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs index d137f05d9695..7fc2db874e80 100644 --- a/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs +++ b/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Microsoft.Maui.Platform; +using Microsoft.Maui.Storage; using Microsoft.Web.WebView2.Core; using Windows.ApplicationModel; using Windows.Storage.Streams; @@ -100,7 +101,7 @@ protected override async Task HandleWebResourceRequest(CoreWebView2WebResourceRe _logger.HandlingWebRequest(requestUri); - var relativePath = AppOriginUri.IsBaseOf(uri) ? AppOriginUri.MakeRelativeUri(uri).ToString() : null; + var relativePath = AppOriginUri.IsBaseOf(uri) ? Microsoft.Maui.WebUtils.ResolveRelativePath(AppOriginUri, uri) : null; // Check if the uri is _framework/blazor.modules.json is a special case as the built-in file provider // brings in a default implementation. @@ -171,8 +172,8 @@ private async Task TryServeFromFolderAsync( } else { - var path = Path.Combine(AppContext.BaseDirectory, relativePath); - if (File.Exists(path)) + var path = FileSystemUtils.Combine(AppContext.BaseDirectory, relativePath); + if (path is not null && File.Exists(path)) { using var contentStream = File.OpenRead(path); stream = await CopyContentToRandomAccessStreamAsync(contentStream); diff --git a/src/BlazorWebView/src/Maui/iOS/iOSMauiAssetFileProvider.cs b/src/BlazorWebView/src/Maui/iOS/iOSMauiAssetFileProvider.cs index 3c3b74a4d824..9f5826989348 100644 --- a/src/BlazorWebView/src/Maui/iOS/iOSMauiAssetFileProvider.cs +++ b/src/BlazorWebView/src/Maui/iOS/iOSMauiAssetFileProvider.cs @@ -5,6 +5,7 @@ using Foundation; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; +using Microsoft.Maui.Storage; namespace Microsoft.AspNetCore.Components.WebView.Maui { @@ -21,10 +22,20 @@ public iOSMauiAssetFileProvider(string contentRootDir) } public IDirectoryContents GetDirectoryContents(string subpath) - => new iOSMauiAssetDirectoryContents(Path.Combine(_bundleRootDir, subpath)); + { + var resolvedPath = FileSystemUtils.Combine(_bundleRootDir, subpath); + if (resolvedPath is null) + return NotFoundDirectoryContents.Singleton; + return new iOSMauiAssetDirectoryContents(resolvedPath); + } public IFileInfo GetFileInfo(string subpath) - => new iOSMauiAssetFileInfo(Path.Combine(_bundleRootDir, subpath)); + { + var resolvedPath = FileSystemUtils.Combine(_bundleRootDir, subpath); + if (resolvedPath is null) + return new NotFoundFileInfo(subpath); + return new iOSMauiAssetFileInfo(resolvedPath); + } public IChangeToken Watch(string filter) => NullChangeToken.Singleton; diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs new file mode 100644 index 000000000000..9ead464445ec --- /dev/null +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs @@ -0,0 +1,137 @@ +using System; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.WebView.Maui; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.MauiBlazorWebView.DeviceTests.Components; +using Xunit; + +namespace Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements; + +/// +/// Tests for URL-to-file path resolution in BlazorWebView. +/// Path.Combine ignores the first argument when the second starts with a path +/// separator, which can cause incorrect file resolution. These tests verify that +/// file resolution correctly handles various URL path formats. +/// +public partial class BlazorWebViewTests +{ + public class UrlResolutionResult + { + public int status { get; set; } + public int bodyLength { get; set; } + public string bodyPreview { get; set; } = ""; + public string url { get; set; } = ""; + } + + [JsonSerializable(typeof(UrlResolutionResult))] + internal partial class UrlResolutionJsonContext : JsonSerializerContext + { + } + + private async Task RunUrlResolutionTest(string path, string mode, Action assertion) + { + await RunTest(async (blazorWebView, handler) => + { + var jsPath = path.Replace("'", "\\'", StringComparison.Ordinal); + var result = await WebViewHelpers.ExecuteAsyncScriptAndWaitForResult( + handler.PlatformView, + "try {" + + " let url;" + + " if ('" + mode + "' === 'origin') {" + + " url = window.location.origin + '" + jsPath + "';" + + " } else {" + + " url = '" + jsPath + "';" + + " }" + + " const response = await fetch(url);" + + " const body = await response.text();" + + " return {" + + " status: response.status," + + " bodyLength: body.length," + + " bodyPreview: body.substring(0, 200)," + + " url: url" + + " };" + + "} catch (e) {" + + " let url = ('" + mode + "' === 'origin') ? window.location.origin + '" + jsPath + "' : '" + jsPath + "';" + + " return {" + + " status: -1," + + " bodyLength: 0," + + " bodyPreview: e.toString()," + + " url: url" + + " };" + + "}"); + + Assert.NotNull(result); + assertion(result); + }); + } + + /// + /// Checks if the response is the host page (SPA fallback) rather than actual file content. + /// BlazorWebView returns the host page for extensionless paths as part of SPA routing. + /// + static bool IsSpaFallback(UrlResolutionResult result) => + result.bodyPreview.Contains("blazor.webview.js", StringComparison.Ordinal) || + result.bodyPreview.Contains("testhtmlloaded", StringComparison.Ordinal) || + result.bodyPreview.Contains("There is no content at", StringComparison.Ordinal); + + // ============================================================ + // Host page — should load correctly + // ============================================================ + + [Fact] + public Task HostPage_LoadsSuccessfully() => + RunUrlResolutionTest("", "relative", result => + { + Assert.Equal(200, result.status); + Assert.True(result.bodyLength > 0, "Host page should return content"); + }); + + // ============================================================ + // Rooted paths — Path.Combine drops the root when the second + // argument starts with a separator, so these should not resolve + // ============================================================ + + [Theory] + [InlineData("//images/logo.png")] + [InlineData("//data/readme.txt")] + [InlineData("//content/page.html")] + public Task Blazor_RootedPath_DoesNotResolve(string path) => + RunUrlResolutionTest(path, "origin", result => + { + Assert.True( + result.status != 200 || IsSpaFallback(result), + $"Path '{path}' unexpectedly returned content (status={result.status}, length={result.bodyLength})"); + }); + + // ============================================================ + // Dot-dot segments — should not resolve above content root + // ============================================================ + + [Theory] + [InlineData("../readme.txt")] + [InlineData("../../data/config.txt")] + [InlineData("subfolder/../../readme.txt")] + public Task Blazor_DotDotSegments_DoNotResolveAboveRoot(string path) => + RunUrlResolutionTest(path, "relative", result => + { + Assert.True( + result.status != 200 || IsSpaFallback(result), + $"Path '{path}' unexpectedly returned content (status={result.status}, length={result.bodyLength})"); + }); + + // ============================================================ + // Encoded separators — should not bypass path resolution + // ============================================================ + + [Theory] + [InlineData("%2F%2Fimages%2Flogo.png")] + [InlineData("%2e%2e/readme.txt")] + public Task Blazor_EncodedSeparators_DoNotBypassResolution(string path) => + RunUrlResolutionTest(path, "relative", result => + { + Assert.True( + result.status != 200 || IsSpaFallback(result), + $"Path '{path}' unexpectedly returned content (status={result.status}, length={result.bodyLength})"); + }); +} diff --git a/src/Controls/src/Core/Controls.Core.csproj b/src/Controls/src/Core/Controls.Core.csproj index 2c26b63250c5..5f867b201d2d 100644 --- a/src/Controls/src/Core/Controls.Core.csproj +++ b/src/Controls/src/Core/Controls.Core.csproj @@ -47,6 +47,7 @@ + diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_ContentRootResolution.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_ContentRootResolution.cs new file mode 100644 index 000000000000..1b19dfa69e33 --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_ContentRootResolution.cs @@ -0,0 +1,114 @@ +#nullable enable +using System; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Maui.Controls; +using Xunit; + +namespace Microsoft.Maui.DeviceTests; + +/// +/// Tests for URL-to-file path resolution in HybridWebView. +/// Path.Combine ignores the first argument when the second starts with a path +/// separator, which can cause incorrect file resolution. These tests verify that +/// file resolution correctly handles various URL path formats. +/// +[Category(TestCategory.HybridWebView)] +#if WINDOWS +[Collection(WebViewsCollection)] +#endif +public partial class HybridWebViewTests_ContentRootResolution : HybridWebViewTestsBase +{ + public class UrlResolutionResult + { + public int status { get; set; } + public int bodyLength { get; set; } + public string bodyPreview { get; set; } = ""; + public string url { get; set; } = ""; + } + + [JsonSourceGenerationOptions(WriteIndented = true)] + [JsonSerializable(typeof(UrlResolutionResult))] + [JsonSerializable(typeof(string))] + internal partial class UrlResolutionJsonContext : JsonSerializerContext + { + } + + private Task RunUrlResolutionTest(string path, string mode, Action assertion) => + RunTest("urlresolution.html", async (hybridWebView) => + { + var result = await hybridWebView.InvokeJavaScriptAsync( + "TestUrlResolution", + UrlResolutionJsonContext.Default.UrlResolutionResult, + [path, mode], + [UrlResolutionJsonContext.Default.String, UrlResolutionJsonContext.Default.String]); + + Assert.NotNull(result); + assertion(result); + }); + + // ============================================================ + // Relative paths — files inside content root resolve correctly + // ============================================================ + + [Theory] + [InlineData("index.html")] + [InlineData("urlresolution.html")] + [InlineData("safe-file.txt")] + public Task RelativePaths_ResolveToContent(string path) => + RunUrlResolutionTest(path, "relative", result => + { + Assert.Equal(200, result.status); + Assert.True(result.bodyLength > 0, $"Expected content for '{path}' but got empty response."); + }); + + [Fact] + public Task KnownFile_ReturnsExpectedContent() => + RunUrlResolutionTest("safe-file.txt", "relative", result => + { + Assert.Equal(200, result.status); + Assert.Contains("content directory", result.bodyPreview, StringComparison.Ordinal); + }); + + // ============================================================ + // Rooted paths — Path.Combine drops the root when the second + // argument starts with a separator, so these should not resolve + // ============================================================ + + [Theory] + [InlineData("//images/logo.png")] + [InlineData("//data/readme.txt")] + [InlineData("//content/page.html")] + public Task RootedPath_DoesNotResolve(string path) => + RunUrlResolutionTest(path, "origin", result => + { + Assert.NotEqual(200, result.status); + }); + + // ============================================================ + // Dot-dot segments — should not resolve above content root + // ============================================================ + + [Theory] + [InlineData("../readme.txt")] + [InlineData("../../data/config.txt")] + [InlineData("subfolder/../../readme.txt")] + public Task DotDotSegments_DoNotResolveAboveRoot(string path) => + RunUrlResolutionTest(path, "relative", result => + { + Assert.NotEqual(200, result.status); + }); + + // ============================================================ + // Encoded separators — should not bypass path resolution + // ============================================================ + + [Theory] + [InlineData("%2F%2Fimages%2Flogo.png")] + [InlineData("%2e%2e/readme.txt")] + public Task EncodedSeparators_DoNotBypassResolution(string path) => + RunUrlResolutionTest(path, "relative", result => + { + Assert.NotEqual(200, result.status); + }); +} diff --git a/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/safe-file.txt b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/safe-file.txt new file mode 100644 index 000000000000..6b1acbffb43b --- /dev/null +++ b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/safe-file.txt @@ -0,0 +1 @@ +This file is inside the HybridRoot content directory. \ No newline at end of file diff --git a/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/urlresolution.html b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/urlresolution.html new file mode 100644 index 000000000000..287d8291ce74 --- /dev/null +++ b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/urlresolution.html @@ -0,0 +1,48 @@ + + + + + + URL Resolution Tests + + + + + + +

URL Resolution Test Page

+
+ + diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Windows.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Windows.cs index 9b4015cf65c4..f9cef27e49cd 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Windows.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Windows.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using System.Web; using Microsoft.Extensions.Logging; +using Microsoft.Maui.Storage; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.Web.WebView2.Core; @@ -167,7 +168,12 @@ private async void OnWebResourceRequested(CoreWebView2 sender, CoreWebView2WebRe if (new Uri(requestUri) is Uri uri && AppOriginUri.IsBaseOf(uri)) { - var relativePath = AppOriginUri.MakeRelativeUri(uri).ToString(); + var relativePath = WebUtils.ResolveRelativePath(AppOriginUri, uri); + if (relativePath is null) + { + logger?.LogDebug("Request for {Url} resolved to an invalid path.", url); + return (Stream: null, ContentType: null, StatusCode: 404, Reason: "Not Found"); + } // 1.a. Try the special "_framework/hybridwebview.js" path if (relativePath == HybridWebViewDotJsPath) @@ -238,8 +244,8 @@ private async void OnWebResourceRequested(CoreWebView2 sender, CoreWebView2WebRe } } - var assetPath = Path.Combine(VirtualView.HybridRoot!, relativePath!); - using var contentStream = await GetAssetStreamAsync(assetPath); + var assetPath = FileSystemUtils.Combine(VirtualView.HybridRoot!, relativePath!); + using var contentStream = assetPath is not null ? await GetAssetStreamAsync(assetPath) : null; if (contentStream is not null) { diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.iOS.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.iOS.cs index a3e47eef5278..dda0ced6418d 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.iOS.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.iOS.cs @@ -227,7 +227,12 @@ public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSche if (new Uri(url) is Uri uri && AppOriginUri.IsBaseOf(uri)) { - var relativePath = AppOriginUri.MakeRelativeUri(uri).ToString(); + var relativePath = WebUtils.ResolveRelativePath(AppOriginUri, uri); + if (relativePath is null) + { + logger?.LogDebug("Request for {Url} resolved to an invalid path.", url); + return (null, ContentType: null, StatusCode: 404); + } var bundleRootDir = Path.Combine(NSBundle.MainBundle.ResourcePath, Handler.VirtualView.HybridRoot!); @@ -298,10 +303,13 @@ public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSche } } - var assetPath = Path.Combine(bundleRootDir, relativePath!); - assetPath = FileSystemUtils.NormalizePath(assetPath); + var assetPath = FileSystemUtils.Combine(bundleRootDir, relativePath!); + if (assetPath is not null) + { + assetPath = FileSystemUtils.NormalizePath(assetPath); + } - if (File.Exists(assetPath)) + if (assetPath is not null && File.Exists(assetPath)) { // 2.a. If something was found, return the content logger?.LogDebug("Request for {Url} will return an app package file.", url); diff --git a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs index 6edd14da6d0b..1c8b1bd3ce1b 100644 --- a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs +++ b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs @@ -76,7 +76,12 @@ public MauiHybridWebViewClient(HybridWebViewHandler handler) logger?.LogDebug("Request for {Url} will be handled by .NET MAUI.", fullUrl); - var relativePath = HybridWebViewHandler.AppOriginUri.MakeRelativeUri(uri).ToString(); + var relativePath = WebUtils.ResolveRelativePath(HybridWebViewHandler.AppOriginUri, uri); + if (relativePath is null) + { + logger?.LogDebug("Request for {Url} resolved to an invalid path.", fullUrl); + return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", GetHeaders("text/plain"), new MemoryStream()); + } // 1.a. Try the special "_framework/hybridwebview.js" path if (relativePath == HybridWebViewHandler.HybridWebViewDotJsPath) @@ -138,8 +143,8 @@ public MauiHybridWebViewClient(HybridWebViewHandler handler) } } - var assetPath = Path.Combine(Handler.VirtualView.HybridRoot!, relativePath!); - var contentStream = PlatformOpenAppPackageFile(assetPath); + var assetPath = FileSystemUtils.Combine(Handler.VirtualView.HybridRoot!, relativePath!); + var contentStream = assetPath is not null ? PlatformOpenAppPackageFile(assetPath) : null; if (contentStream is not null) { diff --git a/src/Essentials/src/Email/Email.windows.cs b/src/Essentials/src/Email/Email.windows.cs index c873084df504..d44e9128dabb 100644 --- a/src/Essentials/src/Email/Email.windows.cs +++ b/src/Essentials/src/Email/Email.windows.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using Microsoft.Maui.Storage; namespace Microsoft.Maui.ApplicationModel.Communication { @@ -30,7 +31,7 @@ async Task PlatformComposeAsync(EmailMessage message) { foreach (var attachment in message.Attachments) { - var path = NormalizePath(attachment.FullPath); + var path = FileSystemUtils.NormalizePath(attachment.FullPath); platformEmailMessage.Attachments.Add(path); } @@ -39,9 +40,6 @@ async Task PlatformComposeAsync(EmailMessage message) await EmailHelper.ShowComposeNewEmailAsync(platformEmailMessage); } - static string NormalizePath(string path) - => path.Replace('/', Path.DirectorySeparatorChar); - void Sync(List recipients, IList nativeRecipients) { if (recipients == null) diff --git a/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs b/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs index e95fdcc688bb..2cc8c696ff27 100644 --- a/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs +++ b/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs @@ -1,4 +1,5 @@ #nullable enable +using System; using System.IO; namespace Microsoft.Maui.Storage @@ -18,5 +19,47 @@ public static string NormalizePath(string filename) => filename .Replace('\\', Path.DirectorySeparatorChar) .Replace('/', Path.DirectorySeparatorChar); + + /// + /// Validates that a relative path does not resolve to an absolute or rooted + /// location and does not contain relative parent (..) segments. + /// + internal static bool IsValidRelativePath(string? relativePath) + { + if (string.IsNullOrEmpty(relativePath)) + return true; + + if (Path.IsPathRooted(relativePath)) + return false; + + if (relativePath.Contains("..", StringComparison.Ordinal)) + return false; + + return true; + } + + /// + /// Combines a root directory with a relative path, validates the relative path, + /// and verifies the resolved full path is still within the root directory. + /// Returns null if the relative path is invalid or the combined path + /// falls outside the root. + /// + internal static string? Combine(string rootDirectory, string relativePath) + { + if (!IsValidRelativePath(relativePath)) + return null; + + var combined = Path.Combine(rootDirectory, relativePath); + var fullPath = Path.GetFullPath(combined); + + var normalizedRoot = Path.GetFullPath(rootDirectory); + if (!normalizedRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + normalizedRoot += Path.DirectorySeparatorChar; + + if (!fullPath.StartsWith(normalizedRoot, StringComparison.Ordinal)) + return null; + + return fullPath; + } } } \ No newline at end of file diff --git a/src/Essentials/src/Types/Shared/WebUtils.shared.cs b/src/Essentials/src/Types/Shared/WebUtils.shared.cs index b01e1a9e76d8..b969577f48f9 100644 --- a/src/Essentials/src/Types/Shared/WebUtils.shared.cs +++ b/src/Essentials/src/Types/Shared/WebUtils.shared.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; namespace Microsoft.Maui { @@ -20,6 +21,22 @@ internal static string RemovePossibleQueryString(string? url) ? url : url.Substring(0, indexOfQueryString); } + + /// + /// Resolves a request URI against an app origin to produce a validated relative path. + /// + internal static string? ResolveRelativePath(Uri appOriginUri, Uri requestUri) + { + if (!appOriginUri.IsBaseOf(requestUri)) + return null; + + var relativePath = appOriginUri.MakeRelativeUri(requestUri).ToString(); + + if (!Storage.FileSystemUtils.IsValidRelativePath(relativePath)) + return null; + + return relativePath ?? string.Empty; + } #endif internal static Dictionary ParseQueryString(Uri uri, bool includeFragment = true) From 30fbdf142f93b6d0ac95fc21884960ae9e1198f9 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 26 Mar 2026 19:03:05 +0200 Subject: [PATCH 2/5] Fix path resolution edge cases and add unit tests - Fix IsValidRelativePath to check '..' per-segment instead of substring - Fix Combine to preserve relative roots for Android/package asset paths - Fix Combine to use case-insensitive comparison on Windows - Remove unused using directive in WebUtils.shared.cs - Rename test methods and comments for clarity - Add FileSystemUtils and WebUtils unit tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...lazorWebViewTests.ContentRootResolution.cs | 4 +- ...ybridWebViewTests_ContentRootResolution.cs | 4 +- .../src/FileSystem/FileSystemUtils.shared.cs | 44 ++- .../src/Types/Shared/WebUtils.shared.cs | 1 - .../test/UnitTests/FileSystemUtils_Tests.cs | 275 ++++++++++++++++++ .../test/UnitTests/WebUtils_Tests.cs | 119 ++++++++ 6 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 src/Essentials/test/UnitTests/FileSystemUtils_Tests.cs create mode 100644 src/Essentials/test/UnitTests/WebUtils_Tests.cs diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs index 9ead464445ec..4ca0b9dd5616 100644 --- a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs @@ -121,13 +121,13 @@ public Task Blazor_DotDotSegments_DoNotResolveAboveRoot(string path) => }); // ============================================================ - // Encoded separators — should not bypass path resolution + // Encoded separators — should not affect path resolution // ============================================================ [Theory] [InlineData("%2F%2Fimages%2Flogo.png")] [InlineData("%2e%2e/readme.txt")] - public Task Blazor_EncodedSeparators_DoNotBypassResolution(string path) => + public Task Blazor_EncodedSeparators_DoNotAffectResolution(string path) => RunUrlResolutionTest(path, "relative", result => { Assert.True( diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_ContentRootResolution.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_ContentRootResolution.cs index 1b19dfa69e33..ae9dcbab36b0 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_ContentRootResolution.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_ContentRootResolution.cs @@ -100,13 +100,13 @@ public Task DotDotSegments_DoNotResolveAboveRoot(string path) => }); // ============================================================ - // Encoded separators — should not bypass path resolution + // Encoded separators — should not affect path resolution // ============================================================ [Theory] [InlineData("%2F%2Fimages%2Flogo.png")] [InlineData("%2e%2e/readme.txt")] - public Task EncodedSeparators_DoNotBypassResolution(string path) => + public Task EncodedSeparators_DoNotAffectResolution(string path) => RunUrlResolutionTest(path, "relative", result => { Assert.NotEqual(200, result.status); diff --git a/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs b/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs index 2cc8c696ff27..73f699dc06d3 100644 --- a/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs +++ b/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs @@ -32,8 +32,14 @@ internal static bool IsValidRelativePath(string? relativePath) if (Path.IsPathRooted(relativePath)) return false; - if (relativePath.Contains("..", StringComparison.Ordinal)) - return false; + // Check for ".." as a path segment, not as a substring, + // so that filenames like "foo..bar.js" are not rejected. + var segments = relativePath.Split(new[] { '\\', '/' }, StringSplitOptions.None); + foreach (var segment in segments) + { + if (string.Equals(segment, "..", StringComparison.Ordinal)) + return false; + } return true; } @@ -50,16 +56,36 @@ internal static bool IsValidRelativePath(string? relativePath) return null; var combined = Path.Combine(rootDirectory, relativePath); - var fullPath = Path.GetFullPath(combined); - var normalizedRoot = Path.GetFullPath(rootDirectory); - if (!normalizedRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) - normalizedRoot += Path.DirectorySeparatorChar; + // When the root directory is absolute, resolve and verify the full path + // stays within the root using the file system's canonical paths. + if (Path.IsPathRooted(rootDirectory)) + { + var fullPath = Path.GetFullPath(combined); - if (!fullPath.StartsWith(normalizedRoot, StringComparison.Ordinal)) - return null; + var normalizedRoot = Path.GetFullPath(rootDirectory); + if (!normalizedRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + normalizedRoot += Path.DirectorySeparatorChar; + + // Use case-insensitive comparison on Windows where the file system is case-insensitive. + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + // The full path must either be exactly the root (for empty relative paths) + // or start with the root + separator (for paths within the root). + if (!fullPath.StartsWith(normalizedRoot, comparison) && + !string.Equals(fullPath + Path.DirectorySeparatorChar, normalizedRoot, comparison)) + return null; + + return fullPath; + } - return fullPath; + // For relative roots (e.g., app-package or asset-relative paths like + // Android's "HybridTestRoot" or "wwwroot"), preserve the relative semantics. + // IsValidRelativePath has already ensured the relativePath is not rooted + // and does not contain "..", so the combined path stays within the root. + return NormalizePath(combined); } } } \ No newline at end of file diff --git a/src/Essentials/src/Types/Shared/WebUtils.shared.cs b/src/Essentials/src/Types/Shared/WebUtils.shared.cs index b969577f48f9..7638d0379a3a 100644 --- a/src/Essentials/src/Types/Shared/WebUtils.shared.cs +++ b/src/Essentials/src/Types/Shared/WebUtils.shared.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; namespace Microsoft.Maui { diff --git a/src/Essentials/test/UnitTests/FileSystemUtils_Tests.cs b/src/Essentials/test/UnitTests/FileSystemUtils_Tests.cs new file mode 100644 index 000000000000..a950a750ed65 --- /dev/null +++ b/src/Essentials/test/UnitTests/FileSystemUtils_Tests.cs @@ -0,0 +1,275 @@ +#nullable enable +using System; +using System.IO; +using Microsoft.Maui.Storage; +using Xunit; + +namespace Tests +{ + public class FileSystemUtils_Tests + { + // ============================================================ + // IsValidRelativePath + // ============================================================ + + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsValidRelativePath_NullOrEmpty_ReturnsTrue(string? path) + { + Assert.True(FileSystemUtils.IsValidRelativePath(path)); + } + + [Theory] + [InlineData("file.txt")] + [InlineData("sub/file.txt")] + [InlineData("sub/deep/file.txt")] + [InlineData("./file.txt")] + [InlineData("sub/./file.txt")] + public void IsValidRelativePath_ValidRelative_ReturnsTrue(string path) + { + Assert.True(FileSystemUtils.IsValidRelativePath(path)); + } + + [Theory] + [InlineData("foo..bar.js")] + [InlineData("image..png")] + [InlineData("name...ext")] + [InlineData("a..b/c..d")] + public void IsValidRelativePath_DoubleDotInFilename_ReturnsTrue(string path) + { + // ".." inside a filename is not a path segment — should be allowed + Assert.True(FileSystemUtils.IsValidRelativePath(path)); + } + + [Theory] + [InlineData("../file.txt")] + [InlineData("../../file.txt")] + [InlineData("sub/../file.txt")] + [InlineData("sub/../../file.txt")] + [InlineData("..\\file.txt")] + [InlineData("sub\\..\\file.txt")] + public void IsValidRelativePath_DotDotSegment_ReturnsFalse(string path) + { + Assert.False(FileSystemUtils.IsValidRelativePath(path)); + } + + [Theory] + [InlineData("/file.txt")] + [InlineData("//file.txt")] + [InlineData("///file.txt")] + public void IsValidRelativePath_RootedPath_ReturnsFalse(string path) + { + Assert.False(FileSystemUtils.IsValidRelativePath(path)); + } + + [Fact] + public void IsValidRelativePath_WindowsDriveLetter_ReturnsFalse() + { + if (!OperatingSystem.IsWindows()) + return; // Path.IsPathRooted behaves differently on non-Windows + + Assert.False(FileSystemUtils.IsValidRelativePath("C:\\file.txt")); + Assert.False(FileSystemUtils.IsValidRelativePath("D:/file.txt")); + } + + // ============================================================ + // Combine — with absolute root directory + // ============================================================ + + [Fact] + public void Combine_AbsoluteRoot_ValidRelative_ReturnsFullPath() + { + var root = Path.GetTempPath(); + var result = FileSystemUtils.Combine(root, "sub/file.txt"); + + Assert.NotNull(result); + Assert.True(Path.IsPathRooted(result)); + Assert.StartsWith(root, result, StringComparison.Ordinal); + Assert.EndsWith("file.txt", result, StringComparison.Ordinal); + } + + [Theory] + [InlineData("../file.txt")] + [InlineData("../../file.txt")] + [InlineData("sub/../../file.txt")] + public void Combine_AbsoluteRoot_DotDotAboveRoot_ReturnsNull(string relativePath) + { + var root = Path.Combine(Path.GetTempPath(), "testroot", "content"); + var result = FileSystemUtils.Combine(root, relativePath); + + Assert.Null(result); + } + + [Theory] + [InlineData("/etc/config.txt")] + [InlineData("//images/logo.png")] + [InlineData("///data/readme.txt")] + public void Combine_AbsoluteRoot_RootedRelative_ReturnsNull(string relativePath) + { + var root = Path.Combine(Path.GetTempPath(), "testroot"); + var result = FileSystemUtils.Combine(root, relativePath); + + Assert.Null(result); + } + + [Fact] + public void Combine_AbsoluteRoot_EmptyRelative_ReturnsRoot() + { + var root = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var result = FileSystemUtils.Combine(root, ""); + + // Empty relative path combined with root should return a path within root + Assert.NotNull(result); + } + + [Fact] + public void Combine_AbsoluteRoot_SingleDot_ReturnsPathWithinRoot() + { + var root = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var result = FileSystemUtils.Combine(root, "./file.txt"); + + Assert.NotNull(result); + Assert.EndsWith("file.txt", result, StringComparison.Ordinal); + } + + [Fact] + public void Combine_AbsoluteRoot_WindowsCaseSensitivity() + { + if (!OperatingSystem.IsWindows()) + return; + + // On Windows, paths are case-insensitive, so even if GetFullPath returns + // a different case, the boundary check should still pass. + var root = Path.Combine(Path.GetTempPath(), "TestRoot"); + var result = FileSystemUtils.Combine(root, "sub/file.txt"); + + Assert.NotNull(result); + } + + // ============================================================ + // Combine — with relative root directory (Android/package paths) + // ============================================================ + + [Fact] + public void Combine_RelativeRoot_ValidRelative_ReturnsRelativePath() + { + var result = FileSystemUtils.Combine("wwwroot", "index.html"); + + Assert.NotNull(result); + // Should NOT be absolute — should stay relative for package/asset APIs + Assert.False(Path.IsPathRooted(result)); + Assert.Contains("wwwroot", result, StringComparison.Ordinal); + Assert.Contains("index.html", result, StringComparison.Ordinal); + } + + [Fact] + public void Combine_RelativeRoot_SubPath_PreservesRelativeStructure() + { + var result = FileSystemUtils.Combine("HybridTestRoot", "sub/file.txt"); + + Assert.NotNull(result); + Assert.False(Path.IsPathRooted(result)); + } + + [Theory] + [InlineData("../file.txt")] + [InlineData("../../file.txt")] + [InlineData("sub/../../file.txt")] + public void Combine_RelativeRoot_DotDotAboveRoot_ReturnsNull(string relativePath) + { + var result = FileSystemUtils.Combine("wwwroot", relativePath); + + Assert.Null(result); + } + + [Theory] + [InlineData("/etc/config.txt")] + [InlineData("//images/logo.png")] + public void Combine_RelativeRoot_RootedRelative_ReturnsNull(string relativePath) + { + var result = FileSystemUtils.Combine("wwwroot", relativePath); + + Assert.Null(result); + } + + [Fact] + public void Combine_RelativeRoot_NormalizesSlashes() + { + var result = FileSystemUtils.Combine("wwwroot", "sub/file.txt"); + + Assert.NotNull(result); + // Verify slashes are normalized for current platform + if (Path.DirectorySeparatorChar == '/') + Assert.DoesNotContain("\\", result, StringComparison.Ordinal); + else + Assert.DoesNotContain("/", result, StringComparison.Ordinal); + } + + // ============================================================ + // Combine — additional edge cases + // ============================================================ + + [Theory] + [InlineData("..\\readme.txt")] + [InlineData("..\\..\\data\\config.txt")] + [InlineData("subfolder\\..\\..\\readme.txt")] + public void Combine_BackslashDotDot_ReturnsNull(string relativePath) + { + var root = Path.Combine(Path.GetTempPath(), "testroot"); + var result = FileSystemUtils.Combine(root, relativePath); + + Assert.Null(result); + } + + [Theory] + [InlineData("///127.0.0.1/share/data.txt")] + [InlineData("///localhost/C$/data.txt")] + public void Combine_NetworkStylePath_ReturnsNull(string relativePath) + { + var root = Path.Combine(Path.GetTempPath(), "testroot"); + var result = FileSystemUtils.Combine(root, relativePath); + + Assert.Null(result); + } + + [Fact] + public void Combine_WindowsDriveLetter_ReturnsNull() + { + if (!OperatingSystem.IsWindows()) + return; + + var root = Path.Combine(Path.GetTempPath(), "testroot"); + Assert.Null(FileSystemUtils.Combine(root, "C:\\Windows\\notepad.exe")); + Assert.Null(FileSystemUtils.Combine(root, "D:/projects/readme.md")); + } + + // ============================================================ + // Combine — filenames containing ".." that are NOT path segments + // ============================================================ + + [Theory] + [InlineData("foo..bar.js")] + [InlineData("image..png")] + [InlineData("a..b/c..d.txt")] + public void Combine_DoubleDotInFilename_Succeeds(string relativePath) + { + var root = Path.Combine(Path.GetTempPath(), "testroot"); + var result = FileSystemUtils.Combine(root, relativePath); + + Assert.NotNull(result); + } + + // ============================================================ + // NormalizePath + // ============================================================ + + [Fact] + public void NormalizePath_ReplacesForwardAndBackSlashes() + { + var result = FileSystemUtils.NormalizePath("a/b\\c"); + var expected = $"a{Path.DirectorySeparatorChar}b{Path.DirectorySeparatorChar}c"; + Assert.Equal(expected, result); + } + } +} diff --git a/src/Essentials/test/UnitTests/WebUtils_Tests.cs b/src/Essentials/test/UnitTests/WebUtils_Tests.cs new file mode 100644 index 000000000000..6a4ef2653ce9 --- /dev/null +++ b/src/Essentials/test/UnitTests/WebUtils_Tests.cs @@ -0,0 +1,119 @@ +#nullable enable +using System; +using Xunit; + +namespace Tests +{ + public class WebUtils_Tests + { + // ============================================================ + // ResolveRelativePath — valid cases + // ============================================================ + + [Fact] + public void ResolveRelativePath_ValidRelative_ReturnsPath() + { + var origin = new Uri("https://0.0.0.0/"); + var request = new Uri("https://0.0.0.0/index.html"); + + var result = Microsoft.Maui.WebUtils.ResolveRelativePath(origin, request); + + Assert.NotNull(result); + Assert.Equal("index.html", result); + } + + [Fact] + public void ResolveRelativePath_SubPath_ReturnsPath() + { + var origin = new Uri("https://0.0.0.0/"); + var request = new Uri("https://0.0.0.0/sub/file.txt"); + + var result = Microsoft.Maui.WebUtils.ResolveRelativePath(origin, request); + + Assert.NotNull(result); + Assert.Equal("sub/file.txt", result); + } + + [Fact] + public void ResolveRelativePath_RootRequest_ReturnsEmpty() + { + var origin = new Uri("https://0.0.0.0/"); + var request = new Uri("https://0.0.0.0/"); + + var result = Microsoft.Maui.WebUtils.ResolveRelativePath(origin, request); + + Assert.NotNull(result); + Assert.Equal(string.Empty, result); + } + + // ============================================================ + // ResolveRelativePath — invalid cases + // ============================================================ + + [Fact] + public void ResolveRelativePath_DifferentOrigin_ReturnsNull() + { + var origin = new Uri("https://0.0.0.0/"); + var request = new Uri("https://other.example.com/file.txt"); + + var result = Microsoft.Maui.WebUtils.ResolveRelativePath(origin, request); + + Assert.Null(result); + } + + [Fact] + public void ResolveRelativePath_DoubleSlash_MakeRelativeUri_ProducesRooted_ReturnsNull() + { + // When the request has a double-slash path like //images/logo.png, + // MakeRelativeUri can produce a path that starts with a separator, + // which should be rejected as rooted. + var origin = new Uri("https://0.0.0.0/"); + var request = new Uri("https://0.0.0.0//images/logo.png"); + + var result = Microsoft.Maui.WebUtils.ResolveRelativePath(origin, request); + + // Either null (rejected) or a valid non-rooted path — never a rooted path + if (result is not null) + { + Assert.False(System.IO.Path.IsPathRooted(result), + $"ResolveRelativePath should not return a rooted path, got: '{result}'"); + } + } + + // ============================================================ + // ResolveRelativePath — encoded paths + // ============================================================ + + [Fact] + public void ResolveRelativePath_EncodedDotDot_HandledCorrectly() + { + var origin = new Uri("https://0.0.0.0/"); + // %2e%2e is URL-encoded ".." — Uri class may decode this + var request = new Uri("https://0.0.0.0/%2e%2e/secret.txt"); + + var result = Microsoft.Maui.WebUtils.ResolveRelativePath(origin, request); + + // Should be null (invalid) or if Uri decoded it, the result should be valid + if (result is not null) + { + Assert.DoesNotContain("..", result, StringComparison.Ordinal); + } + } + + // ============================================================ + // RemovePossibleQueryString + // ============================================================ + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData("https://example.com", "https://example.com")] + [InlineData("https://example.com?foo=bar", "https://example.com")] + [InlineData("https://example.com/path?query=1&other=2", "https://example.com/path")] + public void RemovePossibleQueryString_ReturnsExpected(string? input, string expected) + { + var result = Microsoft.Maui.WebUtils.RemovePossibleQueryString(input); + Assert.Equal(expected, result); + } + } +} From 98cbf91cbf7c3e333456b7e2e0d389895ab7856d Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 26 Mar 2026 22:42:47 +0200 Subject: [PATCH 3/5] Fix CI: revert Tizen changes, use OrdinalIgnoreCase for netstandard compat - Revert Tizen file provider changes (Tizen is no longer supported) - Replace OperatingSystem.IsWindows() with OrdinalIgnoreCase comparison to fix CS0117 on netstandard2.0 builds - Add null-forgiving operators for netstandard2.0 nullable analysis Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Maui/Tizen/TizenMauiAssetFileProvider.cs | 15 ++------------- .../src/FileSystem/FileSystemUtils.shared.cs | 17 ++++++----------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/BlazorWebView/src/Maui/Tizen/TizenMauiAssetFileProvider.cs b/src/BlazorWebView/src/Maui/Tizen/TizenMauiAssetFileProvider.cs index 2cc5419b79f8..1902bac976ff 100644 --- a/src/BlazorWebView/src/Maui/Tizen/TizenMauiAssetFileProvider.cs +++ b/src/BlazorWebView/src/Maui/Tizen/TizenMauiAssetFileProvider.cs @@ -4,7 +4,6 @@ using System.IO; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; -using Microsoft.Maui.Storage; using Tizen.Applications; namespace Microsoft.AspNetCore.Components.WebView.Maui @@ -22,20 +21,10 @@ public TizenMauiAssetFileProvider(string contentRootDir) } public IDirectoryContents GetDirectoryContents(string subpath) - { - var resolvedPath = FileSystemUtils.Combine(_resDir, subpath); - if (resolvedPath is null) - return NotFoundDirectoryContents.Singleton; - return new TizenMauiAssetDirectoryContents(resolvedPath); - } + => new TizenMauiAssetDirectoryContents(Path.Combine(_resDir, subpath)); public IFileInfo GetFileInfo(string subpath) - { - var resolvedPath = FileSystemUtils.Combine(_resDir, subpath); - if (resolvedPath is null) - return new NotFoundFileInfo(subpath); - return new TizenMauiAssetFileInfo(resolvedPath); - } + => new TizenMauiAssetFileInfo(Path.Combine(_resDir, subpath)); public IChangeToken Watch(string filter) => NullChangeToken.Singleton; diff --git a/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs b/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs index 73f699dc06d3..efaf1bb1737c 100644 --- a/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs +++ b/src/Essentials/src/FileSystem/FileSystemUtils.shared.cs @@ -29,12 +29,12 @@ internal static bool IsValidRelativePath(string? relativePath) if (string.IsNullOrEmpty(relativePath)) return true; - if (Path.IsPathRooted(relativePath)) + if (Path.IsPathRooted(relativePath!)) return false; // Check for ".." as a path segment, not as a substring, // so that filenames like "foo..bar.js" are not rejected. - var segments = relativePath.Split(new[] { '\\', '/' }, StringSplitOptions.None); + var segments = relativePath!.Split(new[] { '\\', '/' }, StringSplitOptions.None); foreach (var segment in segments) { if (string.Equals(segment, "..", StringComparison.Ordinal)) @@ -67,15 +67,10 @@ internal static bool IsValidRelativePath(string? relativePath) if (!normalizedRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) normalizedRoot += Path.DirectorySeparatorChar; - // Use case-insensitive comparison on Windows where the file system is case-insensitive. - var comparison = OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - - // The full path must either be exactly the root (for empty relative paths) - // or start with the root + separator (for paths within the root). - if (!fullPath.StartsWith(normalizedRoot, comparison) && - !string.Equals(fullPath + Path.DirectorySeparatorChar, normalizedRoot, comparison)) + // Use case-insensitive comparison to handle platforms with case-insensitive + // file systems (e.g., Windows, macOS) without requiring platform detection. + if (!fullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase) && + !string.Equals(fullPath + Path.DirectorySeparatorChar, normalizedRoot, StringComparison.OrdinalIgnoreCase)) return null; return fullPath; From 7251b4e9d59eed66ed3bb1917521bd9f4c164807 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 30 Mar 2026 19:01:40 +0200 Subject: [PATCH 4/5] Remove HostPage_LoadsSuccessfully test that cannot work in BlazorWebView The Blazor host page is only served for navigation requests (ResourceContext.Document), not for JavaScript fetch() requests. Calling fetch('') from within a BlazorWebView either throws (unpackaged Windows) or hangs indefinitely (packaged Windows), causing CI failures. The host page loading is already verified by the RunTest infrastructure which waits for the Blazor component to render before running any test code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BlazorWebViewTests.ContentRootResolution.cs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs index 4ca0b9dd5616..cacca40e5e21 100644 --- a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs @@ -75,17 +75,10 @@ static bool IsSpaFallback(UrlResolutionResult result) => result.bodyPreview.Contains("testhtmlloaded", StringComparison.Ordinal) || result.bodyPreview.Contains("There is no content at", StringComparison.Ordinal); - // ============================================================ - // Host page — should load correctly - // ============================================================ - - [Fact] - public Task HostPage_LoadsSuccessfully() => - RunUrlResolutionTest("", "relative", result => - { - Assert.Equal(200, result.status); - Assert.True(result.bodyLength > 0, "Host page should return content"); - }); + // NOTE: No HostPage_LoadsSuccessfully test here because the Blazor host page + // is only served for navigation requests (ResourceContext.Document), not for + // fetch() requests. RunTest already verifies the host page loads by waiting + // for the Blazor component to render before running the test lambda. // ============================================================ // Rooted paths — Path.Combine drops the root when the second From 656782a3023cd6f8f4b7bf1cb6b00fa57d514aa6 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Mon, 30 Mar 2026 19:36:23 +0200 Subject: [PATCH 5/5] Add positive test for known-good framework asset in BlazorWebView Fetches _framework/blazor.webview.js (always available in a BlazorWebView) and asserts status 200 with content. This exercises ResolveRelativePath with a valid multi-segment relative path and ensures the path hardening doesn't accidentally block legitimate requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BlazorWebViewTests.ContentRootResolution.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs index cacca40e5e21..c5e768e769d7 100644 --- a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.ContentRootResolution.cs @@ -80,6 +80,20 @@ static bool IsSpaFallback(UrlResolutionResult result) => // fetch() requests. RunTest already verifies the host page loads by waiting // for the Blazor component to render before running the test lambda. + // ============================================================ + // Positive test — a known-good asset must still load after the + // path-hardening changes so we don't accidentally block legit + // requests + // ============================================================ + + [Fact] + public Task Blazor_KnownFrameworkAsset_LoadsSuccessfully() => + RunUrlResolutionTest("_framework/blazor.webview.js", "relative", result => + { + Assert.Equal(200, result.status); + Assert.True(result.bodyLength > 0, "Framework script should return content"); + }); + // ============================================================ // Rooted paths — Path.Combine drops the root when the second // argument starts with a separator, so these should not resolve