Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/BlazorWebView/src/Maui/Android/AndroidMauiAssetFileProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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);
}
Comment thread
mattleibow marked this conversation as resolved.

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;
Expand Down
7 changes: 4 additions & 3 deletions src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -171,8 +172,8 @@ private async Task<bool> 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);
Expand Down
15 changes: 13 additions & 2 deletions src/BlazorWebView/src/Maui/iOS/iOSMauiAssetFileProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Foundation;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
using Microsoft.Maui.Storage;

namespace Microsoft.AspNetCore.Components.WebView.Maui
{
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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;

/// <summary>
/// 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.
/// </summary>
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<UrlResolutionResult> assertion)
{
await RunTest(async (blazorWebView, handler) =>
{
var jsPath = path.Replace("'", "\\'", StringComparison.Ordinal);
var result = await WebViewHelpers.ExecuteAsyncScriptAndWaitForResult<UrlResolutionResult>(
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);
});
}

/// <summary>
/// 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.
/// </summary>
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);

// 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.

// ============================================================
// 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
// ============================================================

[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 affect path resolution
// ============================================================

[Theory]
[InlineData("%2F%2Fimages%2Flogo.png")]
[InlineData("%2e%2e/readme.txt")]
public Task Blazor_EncodedSeparators_DoNotAffectResolution(string path) =>
RunUrlResolutionTest(path, "relative", result =>
{
Assert.True(
result.status != 200 || IsSpaFallback(result),
$"Path '{path}' unexpectedly returned content (status={result.status}, length={result.bodyLength})");
});
}
1 change: 1 addition & 0 deletions src/Controls/src/Core/Controls.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

<ItemGroup>
<Compile Include="..\..\..\Essentials\src\Types\Shared\WebUtils.shared.cs" />
<Compile Include="..\..\..\Essentials\src\FileSystem\FileSystemUtils.shared.cs" />
</ItemGroup>

<ItemGroup Condition=" '$(_MauiDesignDllBuild)' == 'True' and '$(TargetFramework)' == '$(_MauiDotNetTfm)'">
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<UrlResolutionResult> assertion) =>
RunTest("urlresolution.html", async (hybridWebView) =>
{
var result = await hybridWebView.InvokeJavaScriptAsync<UrlResolutionResult>(
"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 affect path resolution
// ============================================================

[Theory]
[InlineData("%2F%2Fimages%2Flogo.png")]
[InlineData("%2e%2e/readme.txt")]
public Task EncodedSeparators_DoNotAffectResolution(string path) =>
RunUrlResolutionTest(path, "relative", result =>
{
Assert.NotEqual(200, result.status);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file is inside the HybridRoot content directory.
Loading
Loading