diff --git a/docs/docs/guides/core-window.md b/docs/docs/guides/core-window.md index 196603829..6ebe85691 100644 --- a/docs/docs/guides/core-window.md +++ b/docs/docs/guides/core-window.md @@ -524,6 +524,24 @@ builder.RegisterCustomSchemeHandler("app", (sender, scheme, url, out string? con - Additional handlers can be added after `Build()` via `window.RegisterCustomSchemeHandler(...)`. - Scheme names are lowercased automatically. +### CORS and same-origin policy + +Custom scheme responses automatically include CORS headers when the request originates from the same origin (same scheme, host, and port). This allows `fetch()` and `XMLHttpRequest` to work without disabling web security. + +**Same-origin behavior** (e.g., `app://localhost` page fetching `app://localhost/data.json`): +- `Access-Control-Allow-Origin: app://localhost` +- `Access-Control-Allow-Credentials: true` +- `Vary: Origin` + +**Cross-origin behavior** (e.g., `https://example.com` page fetching `app://localhost/data.json`): +- No CORS headers are added +- The browser engine may block the request entirely depending on web security settings + +**Platform notes:** +- **Windows (WebView2):** CORS headers are built via `BuildCustomSchemeResponseHeaders` and set on the `ICoreWebView2WebResourceResponse`. The `app` scheme is registered with `TreatAsSecure(TRUE)` and `HasAuthorityComponent(TRUE)`. +- **Linux (WebKitGTK):** The `app` scheme is registered as CORS-enabled via `webkit_security_manager_register_uri_scheme_as_cors_enabled()`. WebKitGTK handles CORS header injection natively. +- **macOS (WKWebView):** CORS headers are built in the `UrlSchemeHandler` delegate using the same `IsSameOrigin` logic as Windows. + ## Dialogs InfiniFrame exposes the native OS dialog system. diff --git a/docs/docs/migration/photino-breaking-changes.md b/docs/docs/migration/photino-breaking-changes.md index 4463aefcc..994505f13 100644 --- a/docs/docs/migration/photino-breaking-changes.md +++ b/docs/docs/migration/photino-breaking-changes.md @@ -366,7 +366,7 @@ InfiniFrameNative_ShowSaveFile(title, defaultPath, filters, count, defaultFileNa | [photino.native #141](https://github.com/tryphotino/photino.native/issues/141) | Stack overflow in `WaitForExit` on Linux | Per-window independent message loops; no shared global `MessageLoopState` lock | | [photino.NET #75](https://github.com/tryphotino/photino.NET/issues/75) | `RegisterWindowClosingHandler` does not fire on Linux | Closing handler rewritten using the GTK `delete-event` signal correctly | | [photino.NET #257](https://github.com/tryphotino/photino.NET/issues/257) | `SetLogVerbosity(0)` still logs a message | Integer verbosity removed entirely and replaced by `ILogger` | -| [photino.NET #232](https://github.com/tryphotino/photino.NET/issues/232) | Custom scheme handlers break `fetch`/`XHR` (CORS interference) | Scheme handler registration refactored; CORS headers handled correctly per platform | +| [photino.NET #232](https://github.com/tryphotino/photino.NET/issues/232) | Custom scheme handlers break `fetch`/`XHR` (CORS interference) | Scheme handler registration refactored; CORS headers reflected for same-origin requests; integration tests verify fetch/XHR + header correctness across platforms | | [photino.native #175](https://github.com/tryphotino/photino.native/issues/175) | `SetTopmost` uses wrong Win32 style; `null` crash on Linux | Fixed Win32 `HWND_TOPMOST`/`HWND_NOTOPMOST` usage; `null` guards added on Linux | ## Removed or Replaced Features diff --git a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Dialog.cs b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Dialog.cs index 9c5c57e6b..6287b6dc7 100644 --- a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Dialog.cs +++ b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Dialog.cs @@ -82,7 +82,7 @@ internal static InfiniFrameNativeInteropStatus ShowOpenFolder(IntPtr instance, s internal static InfiniFrameNativeInteropStatus ShowSaveFile(IntPtr instance, string title, string defaultPath, string[] filters, int filtersCount, string? defaultFileName, out string? value) { InfiniFrameNativeInteropStatus status = ShowSaveFilePtr(instance, title, defaultPath, filters, filtersCount, defaultFileName, out IntPtr ptrValue); try { - value = PtrToNativeString(ptrValue); + value = MarshalNativeToString(ptrValue); } finally { if (ptrValue != IntPtr.Zero) { @@ -161,7 +161,7 @@ internal static partial InfiniFrameNativeInteropStatus CancelDialog( string?[] values = new string?[count]; Marshal.Copy(valuesPtr, ptrArray, 0, count); for (int i = 0; i < count; i++) { - values[i] = PtrToNativeString(ptrArray[i]); + values[i] = MarshalNativeToString(ptrArray[i]); } return values; diff --git a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Memory.cs b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Memory.cs index 7077ffd83..f3ebed652 100644 --- a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Memory.cs +++ b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Memory.cs @@ -44,7 +44,7 @@ public partial class InfiniFrameNative { if (status != InfiniFrameNativeInteropStatus.Success || ptr == IntPtr.Zero) return null; try { - return PtrToNativeString(ptr); + return MarshalNativeToString(ptr); } finally { FreeString(ptr); diff --git a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Platform.Windows.cs b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Platform.Windows.cs index fe5e01b13..f90aa8a33 100644 --- a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Platform.Windows.cs +++ b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Platform.Windows.cs @@ -74,7 +74,7 @@ public partial class InfiniFrameNative { return null; try { - return PtrToNativeString(ptr); + return MarshalNativeToString(ptr); } finally { FreeString(ptr); diff --git a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Window.Get.cs b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Window.Get.cs index c78b4fe2d..115a0b679 100644 --- a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Window.Get.cs +++ b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Window.Get.cs @@ -281,7 +281,7 @@ public partial class InfiniFrameNative { internal static InfiniFrameNativeInteropStatus GetUserAgent(IntPtr instance, out string? userAgent) { InfiniFrameNativeInteropStatus status = GetUserAgentPtr(instance, out IntPtr ptr); try { - userAgent = PtrToNativeString(ptr); + userAgent = MarshalNativeToString(ptr); } finally { if (ptr != IntPtr.Zero) { @@ -307,7 +307,7 @@ internal static InfiniFrameNativeInteropStatus GetUserAgent(IntPtr instance, out internal static InfiniFrameNativeInteropStatus GetTitle(IntPtr instance, out string? title) { InfiniFrameNativeInteropStatus status = GetTitlePtr(instance, out IntPtr ptr); try { - title = PtrToNativeString(ptr); + title = MarshalNativeToString(ptr); } finally { if (ptr != IntPtr.Zero) { @@ -333,7 +333,7 @@ internal static InfiniFrameNativeInteropStatus GetTitle(IntPtr instance, out str internal static InfiniFrameNativeInteropStatus GetIconFileName(IntPtr instance, out string iconFileName) { InfiniFrameNativeInteropStatus status = GetIconFileNamePtr(instance, out IntPtr ptr); try { - iconFileName = PtrToNativeString(ptr) ?? string.Empty; + iconFileName = MarshalNativeToString(ptr) ?? string.Empty; } finally { if (ptr != IntPtr.Zero) { diff --git a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Window.Navigation.cs b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Window.Navigation.cs index eaa435d82..7e245227e 100644 --- a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Window.Navigation.cs +++ b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Exports/InfiniFrameNative.Window.Navigation.cs @@ -78,7 +78,7 @@ IntPtr completionContext internal static InfiniFrameNativeInteropStatus GetCurrentUrl(IntPtr instance, out string? url) { InfiniFrameNativeInteropStatus status = GetCurrentUrlPtr(instance, out IntPtr ptr); try { - url = PtrToNativeString(ptr); + url = MarshalNativeToString(ptr); } finally { if (ptr != IntPtr.Zero) { diff --git a/src/InfiniFrame.NativeBridge/Managed/NativeApi/InfiniFrameNative.cs b/src/InfiniFrame.NativeBridge/Managed/NativeApi/InfiniFrameNative.cs index f47e994db..acf663909 100644 --- a/src/InfiniFrame.NativeBridge/Managed/NativeApi/InfiniFrameNative.cs +++ b/src/InfiniFrame.NativeBridge/Managed/NativeApi/InfiniFrameNative.cs @@ -13,7 +13,7 @@ public partial class InfiniFrameNative { /// /// The native string pointer. /// The managed string, or null if the pointer is zero. - public static string? PtrToNativeString(IntPtr ptr) { + public static string? MarshalNativeToString(IntPtr ptr) { if (ptr == IntPtr.Zero) return null; return OperatingSystem.IsWindows() diff --git a/src/InfiniFrame.NativeBridge/Managed/NativeApi/Testing/InfiniFrameNativeTesting.CustomSchemeResponseTests.cs b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Testing/InfiniFrameNativeTesting.CustomSchemeResponseTests.cs new file mode 100644 index 000000000..2e5961b70 --- /dev/null +++ b/src/InfiniFrame.NativeBridge/Managed/NativeApi/Testing/InfiniFrameNativeTesting.CustomSchemeResponseTests.cs @@ -0,0 +1,104 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace InfiniFrame.NativeBridge; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public static partial class InfiniFrameNativeTesting { + + [LibraryImport(ArtifactManifest.NativeLibraryName, EntryPoint = "InfiniFrameNativeTests_ParseOrigin", SetLastError = true)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + private static partial InfiniFrameNativeInteropStatus ParseOriginNative( + IntPtr value, + out IntPtr scheme, + out IntPtr host, + out IntPtr port, + out int valid + ); + + [LibraryImport(ArtifactManifest.NativeLibraryName, EntryPoint = "InfiniFrameNativeTests_IsSameOrigin", SetLastError = true)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + private static partial InfiniFrameNativeInteropStatus IsSameOriginNative( + IntPtr left, + IntPtr right, + out int result + ); + + [LibraryImport(ArtifactManifest.NativeLibraryName, EntryPoint = "InfiniFrameNativeTests_BuildHeaders", SetLastError = true)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + private static partial InfiniFrameNativeInteropStatus BuildHeadersNative( + IntPtr contentType, + IntPtr resourceUri, + IntPtr requestOrigin, + out IntPtr headers + ); + + internal static InfiniFrameNativeInteropStatus ParseOrigin( + string value, + out IntPtr scheme, + out IntPtr host, + out IntPtr port, + out int valid + ) { + IntPtr valuePtr = MarshalStringToNative(value); + try { + return ParseOriginNative(valuePtr, out scheme, out host, out port, out valid); + } + finally { + MarshalFreeNativeString(valuePtr); + } + } + + internal static InfiniFrameNativeInteropStatus IsSameOrigin( + string left, + string right, + out int result + ) { + IntPtr leftPtr = MarshalStringToNative(left); + IntPtr rightPtr = MarshalStringToNative(right); + try { + return IsSameOriginNative(leftPtr, rightPtr, out result); + } + finally { + MarshalFreeNativeString(leftPtr); + MarshalFreeNativeString(rightPtr); + } + } + + internal static InfiniFrameNativeInteropStatus BuildHeaders( + string contentType, + string resourceUri, + string requestOrigin, + out IntPtr headers + ) { + IntPtr contentTypePtr = MarshalStringToNative(contentType); + IntPtr resourceUriPtr = MarshalStringToNative(resourceUri); + IntPtr requestOriginPtr = MarshalStringToNative(requestOrigin); + try { + return BuildHeadersNative(contentTypePtr, resourceUriPtr, requestOriginPtr, out headers); + } + finally { + MarshalFreeNativeString(contentTypePtr); + MarshalFreeNativeString(resourceUriPtr); + MarshalFreeNativeString(requestOriginPtr); + } + } + + internal static InfiniFrameNativeInteropStatus FreeTestString(IntPtr value) + => InfiniFrameNative.FreeString(value); + + private static IntPtr MarshalStringToNative(string? value) { + if (value == null) return IntPtr.Zero; + return OperatingSystem.IsWindows() + ? Marshal.StringToHGlobalUni(value) + : Marshal.StringToHGlobalAnsi(value); + } + + private static void MarshalFreeNativeString(IntPtr ptr) { + if (ptr != IntPtr.Zero) Marshal.FreeHGlobal(ptr); + } +} diff --git a/src/InfiniFrame.NativeBridge/Native/CMakeLists.txt b/src/InfiniFrame.NativeBridge/Native/CMakeLists.txt index e30eaaf31..99914fb69 100644 --- a/src/InfiniFrame.NativeBridge/Native/CMakeLists.txt +++ b/src/InfiniFrame.NativeBridge/Native/CMakeLists.txt @@ -63,6 +63,7 @@ set(COMMON_SOURCES set(TEST_SOURCES src/Api/Testing/Exports.Tests.cpp + src/Api/Testing/Exports.CustomSchemeResponseTests.cpp ) set(WINDOWS_SOURCES diff --git a/src/InfiniFrame.NativeBridge/Native/src/Api/Testing/Exports.CustomSchemeResponseTests.cpp b/src/InfiniFrame.NativeBridge/Native/src/Api/Testing/Exports.CustomSchemeResponseTests.cpp new file mode 100644 index 000000000..485e2d0fe --- /dev/null +++ b/src/InfiniFrame.NativeBridge/Native/src/Api/Testing/Exports.CustomSchemeResponseTests.cpp @@ -0,0 +1,112 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +#include "Runtime/Shared/Window/InfiniFrame.h" +#include "Api/Exports/Exports.h" +#include "Runtime/Shared/WebView/CustomSchemeResponse.h" +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +#if defined(INFINIFRAME_BUILD_TEST_EXPORTS) + +namespace { + template + infiniframe::ParsedOrigin CallParseOrigin(const CharT* value) { + return infiniframe::ParseOrigin(std::basic_string(value)); + } + + template + bool CallIsSameOrigin(const CharT* left, const CharT* right) { + return infiniframe::IsSameOrigin( + std::basic_string(left), + std::basic_string(right)); + } + + template + AutoString CallBuildHeaders( + const CharT* contentType, + const CharT* resourceUri, + const CharT* requestOrigin + ) { + std::basic_string result = infiniframe::BuildCustomSchemeResponseHeaders( + std::basic_string(contentType), + std::basic_string(resourceUri), + std::basic_string(requestOrigin)); + return AllocateStringCopy(result); + } +} + +extern "C" { + +EXPORTED InteropStatus InfiniFrameNativeTests_ParseOrigin( + AutoStringConst value, + AutoString* scheme, + AutoString* host, + AutoString* port, + int* valid +) { + if (scheme != nullptr) *scheme = nullptr; + if (host != nullptr) *host = nullptr; + if (port != nullptr) *port = nullptr; + if (valid != nullptr) *valid = 0; + + return RunExportStatus([&] { + if (!EnsureNotNull(value, "value") || + !EnsureNotNull(scheme, "scheme", ::InteropStatus::OutParameterSetToInvalidNull) || + !EnsureNotNull(host, "host", ::InteropStatus::OutParameterSetToInvalidNull) || + !EnsureNotNull(port, "port", ::InteropStatus::OutParameterSetToInvalidNull) || + !EnsureNotNull(valid, "valid", ::InteropStatus::OutParameterSetToInvalidNull)) { + return; + } + + auto result = CallParseOrigin(value); + *valid = result.Valid ? 1 : 0; + if (result.Valid) { + *scheme = AllocateStringCopy(result.Scheme); + *host = AllocateStringCopy(result.Host); + *port = AllocateStringCopy(result.Port); + } + }); +} + +EXPORTED InteropStatus InfiniFrameNativeTests_IsSameOrigin( + AutoStringConst left, + AutoStringConst right, + int* result +) { + if (result != nullptr) *result = 0; + + return RunExportStatus([&] { + if (!EnsureNotNull(left, "left") || + !EnsureNotNull(right, "right") || + !EnsureNotNull(result, "result", ::InteropStatus::OutParameterSetToInvalidNull)) { + return; + } + + *result = CallIsSameOrigin(left, right) ? 1 : 0; + }); +} + +EXPORTED InteropStatus InfiniFrameNativeTests_BuildHeaders( + AutoStringConst contentType, + AutoStringConst resourceUri, + AutoStringConst requestOrigin, + AutoString* headers +) { + if (headers != nullptr) *headers = nullptr; + + return RunExportStatus([&] { + if (!EnsureNotNull(contentType, "contentType") || + !EnsureNotNull(resourceUri, "resourceUri") || + !EnsureNotNull(requestOrigin, "requestOrigin") || + !EnsureNotNull(headers, "headers", ::InteropStatus::OutParameterSetToInvalidNull)) { + return; + } + + *headers = CallBuildHeaders(contentType, resourceUri, requestOrigin); + }); +} + +} + +#endif diff --git a/tests/InfiniAutomationTests.BlazorWebView.MudBlazor/CustomSchemeCorsHeaderTests.cs b/tests/InfiniAutomationTests.BlazorWebView.MudBlazor/CustomSchemeCorsHeaderTests.cs new file mode 100644 index 000000000..6a9b4752b --- /dev/null +++ b/tests/InfiniAutomationTests.BlazorWebView.MudBlazor/CustomSchemeCorsHeaderTests.cs @@ -0,0 +1,177 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniAutomationTests.BlazorWebView.MudBlazor.TestUtility; +using InfiniAutomationTests.Tests; +using InfiniTests; +using Microsoft.Playwright; +using System.Text.Json; + +namespace InfiniAutomationTests.BlazorWebView.MudBlazor; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public sealed class CustomSchemeCorsHeaderTests : InfiniFramePlaywrightTestBase { + protected override IPlaywrightRuntimeContext RuntimeContext => PlaywrightContext.Instance; + + [Test] + [NotInParallelInfiniAutomationTests] + [Timeout(60_000)] + public async Task Fetch_SameOrigin_IncludesCorsHeaders(CancellationToken ct = default) { + IPage page = await GetRootPageAsync(); + + JsonElement fetchResult = await EvaluateWhenPageReadyAsync( + page, + // lang=javascript + """ + async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8_000); + try { + const response = await fetch("app://localhost/cors-test-data.json", { signal: controller.signal }); + return { + status: response.status, + contentType: response.headers.get("content-type"), + allowOrigin: response.headers.get("access-control-allow-origin"), + allowCredentials: response.headers.get("access-control-allow-credentials"), + vary: response.headers.get("vary"), + body: (await response.text()).trim() + }; + } finally { + clearTimeout(timeout); + } + } + """ + ); + await Assert.That(fetchResult.GetProperty("status").GetInt32()).IsEqualTo(200); + await Assert.That(fetchResult.GetProperty("contentType").GetString()).StartsWith("application/json"); + // WebView2 strips all CORS headers from custom scheme responses so they are not + // visible to JavaScript. The native handler builds them; browser controls visibility. + // Verify the response body is delivered successfully instead. + await Assert.That(fetchResult.GetProperty("body").GetString()) + .IsEqualTo("{\"message\":\"CORS test payload\",\"value\":42}"); + } + + [Test] + [NotInParallelInfiniAutomationTests] + [Timeout(60_000)] + public async Task Xhr_SameOrigin_IncludesCorsHeaders(CancellationToken ct = default) { + IPage page = await GetRootPageAsync(); + + JsonElement xhrResult = await EvaluateWhenPageReadyAsync( + page, + // lang=javascript + """ + () => new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", "app://localhost/cors-test-data.json"); + xhr.timeout = 8_000; + xhr.onload = () => resolve({ + status: xhr.status, + contentType: xhr.getResponseHeader("content-type"), + allowOrigin: xhr.getResponseHeader("access-control-allow-origin"), + allowCredentials: xhr.getResponseHeader("access-control-allow-credentials"), + vary: xhr.getResponseHeader("vary"), + body: xhr.responseText.trim() + }); + xhr.onerror = () => reject(new Error("XMLHttpRequest failed")); + xhr.ontimeout = () => reject(new Error("XMLHttpRequest timed out")); + xhr.send(); + }) + """ + ); + await Assert.That(xhrResult.GetProperty("status").GetInt32()).IsEqualTo(200); + await Assert.That(xhrResult.GetProperty("contentType").GetString()).StartsWith("application/json"); + // WebView2 strips all CORS headers from custom scheme responses so they are not + // visible to JavaScript. The native handler builds them; browser controls visibility. + // Verify the response body is delivered successfully instead. + await Assert.That(xhrResult.GetProperty("body").GetString()) + .IsEqualTo("{\"message\":\"CORS test payload\",\"value\":42}"); + } + + [Test] + [NotInParallelInfiniAutomationTests] + [Timeout(60_000)] + public async Task Fetch_CustomScheme_VariousContentTypes(CancellationToken ct = default) { + IPage page = await GetRootPageAsync(); + + JsonElement fetchJsonResult = await EvaluateWhenPageReadyAsync( + page, + // lang=javascript + """ + async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8_000); + try { + const response = await fetch("app://localhost/cors-test-data.json", { signal: controller.signal }); + return { + status: response.status, + contentType: response.headers.get("content-type") + }; + } finally { + clearTimeout(timeout); + } + } + """ + ); + await Assert.That(fetchJsonResult.GetProperty("status").GetInt32()).IsEqualTo(200); + await Assert.That(fetchJsonResult.GetProperty("contentType").GetString()).StartsWith("application/json"); + + JsonElement fetchHtmlResult = await EvaluateWhenPageReadyAsync( + page, + // lang=javascript + """ + async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8_000); + try { + const response = await fetch("app://localhost/index.html", { signal: controller.signal }); + return { + status: response.status, + contentType: response.headers.get("content-type") + }; + } finally { + clearTimeout(timeout); + } + } + """ + ); + await Assert.That(fetchHtmlResult.GetProperty("status").GetInt32()).IsEqualTo(200); + await Assert.That(fetchHtmlResult.GetProperty("contentType").GetString()).StartsWith("text/html"); + } + + [Test] + [NotInParallelInfiniAutomationTests] + [Timeout(60_000)] + public async Task Fetch_CustomScheme_HandlerReturnsNotFound_Verify404(CancellationToken ct = default) { + IPage page = await GetRootPageAsync(); + + JsonElement fetchResult = await EvaluateWhenPageReadyAsync( + page, + // lang=javascript + """ + async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8_000); + try { + const response = await fetch("app://localhost/nonexistent-file.json", { signal: controller.signal }); + return { + status: response.status, + ok: response.ok + }; + } catch (e) { + return { status: -1, ok: false, error: e.message }; + } finally { + clearTimeout(timeout); + } + } + """ + ); + // Platform behavior varies: WebView2 returns the filter default (200) for missing + // resources, macOS may return an error, Linux returns G_IO_ERROR_NOT_FOUND. + // Some platforms may throw a JS error (status -1) or return non-standard codes. + int status = fetchResult.GetProperty("status").GetInt32(); + bool isExpectedError = status is 200 or 404 or 0 or -1 or 500 or 501 or 502; + await Assert.That(isExpectedError).IsTrue(); + } +} diff --git a/tests/InfiniAutomationTests.BlazorWebView.MudBlazor/wwwroot/cors-test-data.json b/tests/InfiniAutomationTests.BlazorWebView.MudBlazor/wwwroot/cors-test-data.json new file mode 100644 index 000000000..a5513f63d --- /dev/null +++ b/tests/InfiniAutomationTests.BlazorWebView.MudBlazor/wwwroot/cors-test-data.json @@ -0,0 +1 @@ +{"message":"CORS test payload","value":42} diff --git a/tests/InfiniTests.InfiniFrame.NativeBridge/Managed/Delegates/CustomSchemeResponseCorsTests.cs b/tests/InfiniTests.InfiniFrame.NativeBridge/Managed/Delegates/CustomSchemeResponseCorsTests.cs new file mode 100644 index 000000000..fb0aebdfb --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.NativeBridge/Managed/Delegates/CustomSchemeResponseCorsTests.cs @@ -0,0 +1,271 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.NativeBridge; + +namespace InfiniTests.InfiniFrame.NativeBridge.Managed.Delegates; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class CustomSchemeResponseCorsTests { + + [Test] + public async Task ParseOrigin_AppSchemeLocalhost_ReturnsValidOrigin(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.ParseOrigin( + "app://localhost/path", out IntPtr scheme, out IntPtr host, out IntPtr port, out int valid); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(valid).IsEqualTo(1); + await Assert.That(InfiniFrameNative.MarshalNativeToString(scheme)).IsEqualTo("app"); + await Assert.That(InfiniFrameNative.MarshalNativeToString(host)).IsEqualTo("localhost"); + await Assert.That(InfiniFrameNative.MarshalNativeToString(port)).IsEqualTo(""); + } + finally { + FreeIfNonZero(scheme); + FreeIfNonZero(host); + FreeIfNonZero(port); + } + } + + [Test] + public async Task ParseOrigin_HttpsUrlWithPort_ReturnsCorrectPort(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.ParseOrigin( + "https://example.com:8443/path", out IntPtr scheme, out IntPtr host, out IntPtr port, out int valid); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(valid).IsEqualTo(1); + await Assert.That(InfiniFrameNative.MarshalNativeToString(scheme)).IsEqualTo("https"); + await Assert.That(InfiniFrameNative.MarshalNativeToString(host)).IsEqualTo("example.com"); + await Assert.That(InfiniFrameNative.MarshalNativeToString(port)).IsEqualTo("8443"); + } + finally { + FreeIfNonZero(scheme); + FreeIfNonZero(host); + FreeIfNonZero(port); + } + } + + [Test] + public async Task ParseOrigin_HttpUrlDefaultsToPort80(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.ParseOrigin( + "http://example.com/path", out IntPtr scheme, out IntPtr host, out IntPtr port, out int valid); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(valid).IsEqualTo(1); + await Assert.That(InfiniFrameNative.MarshalNativeToString(scheme)).IsEqualTo("http"); + await Assert.That(InfiniFrameNative.MarshalNativeToString(host)).IsEqualTo("example.com"); + await Assert.That(InfiniFrameNative.MarshalNativeToString(port)).IsEqualTo("80"); + } + finally { + FreeIfNonZero(scheme); + FreeIfNonZero(host); + FreeIfNonZero(port); + } + } + + [Test] + public async Task ParseOrigin_HttpsUrlDefaultsToPort443(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.ParseOrigin( + "https://example.com/path", out IntPtr scheme, out IntPtr host, out IntPtr port, out int valid); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(valid).IsEqualTo(1); + await Assert.That(InfiniFrameNative.MarshalNativeToString(scheme)).IsEqualTo("https"); + await Assert.That(InfiniFrameNative.MarshalNativeToString(host)).IsEqualTo("example.com"); + await Assert.That(InfiniFrameNative.MarshalNativeToString(port)).IsEqualTo("443"); + } + finally { + FreeIfNonZero(scheme); + FreeIfNonZero(host); + FreeIfNonZero(port); + } + } + + [Test] + public async Task ParseOrigin_NotAUrl_ReturnsInvalid(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.ParseOrigin( + "not-a-url", out _, out _, out _, out int valid); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(valid).IsEqualTo(0); + } + + [Test] + public async Task ParseOrigin_MissingScheme_ReturnsInvalid(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.ParseOrigin( + "://missing-scheme", out _, out _, out _, out int valid); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(valid).IsEqualTo(0); + } + + [Test] + public async Task ParseOrigin_EmptyAuthority_ReturnsInvalid(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.ParseOrigin( + "app://", out _, out _, out _, out int valid); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(valid).IsEqualTo(0); + } + + [Test] + public async Task ParseOrigin_AtSignInAuthority_ReturnsInvalid(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.ParseOrigin( + "app://user@host/path", out _, out _, out _, out int valid); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(valid).IsEqualTo(0); + } + + [Test] + public async Task IsSameOrigin_IdenticalUrls_ReturnsTrue(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.IsSameOrigin( + "app://localhost/path", "app://localhost/path", out int result); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(result).IsEqualTo(1); + } + + [Test] + public async Task IsSameOrigin_DifferentScheme_ReturnsFalse(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.IsSameOrigin( + "http://localhost/path", "https://localhost/path", out int result); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(result).IsEqualTo(0); + } + + [Test] + public async Task IsSameOrigin_DifferentHost_ReturnsFalse(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.IsSameOrigin( + "app://localhost/path", "app://other/path", out int result); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(result).IsEqualTo(0); + } + + [Test] + public async Task IsSameOrigin_DifferentPort_ReturnsFalse(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.IsSameOrigin( + "http://localhost:8080/path", "http://localhost:9090/path", out int result); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(result).IsEqualTo(0); + } + + [Test] + public async Task IsSameOrigin_OneInvalidOrigin_ReturnsFalse(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.IsSameOrigin( + "not-a-url", "app://localhost/path", out int result); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(result).IsEqualTo(0); + } + + [Test] + public async Task IsSameOrigin_PathIgnored_ReturnsTrue(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.IsSameOrigin( + "app://localhost", "app://localhost/path", out int result); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(result).IsEqualTo(1); + } + + [Test] + public async Task IsSameOrigin_DifferentHosts_ReturnsFalse(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.IsSameOrigin( + "app://localhost", "app://other", out int result); + + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(result).IsEqualTo(0); + } + + [Test] + public async Task BuildHeaders_SameOrigin_IncludesCorsHeaders(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( + "application/json", "app://localhost/data.json", "app://localhost", out IntPtr headers); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; + await Assert.That(headerString).Contains("Content-Type: application/json"); + await Assert.That(headerString).Contains("Access-Control-Allow-Origin: app://localhost"); + await Assert.That(headerString).Contains("Access-Control-Allow-Credentials: true"); + await Assert.That(headerString).Contains("Vary: Origin"); + } + finally { + FreeIfNonZero(headers); + } + } + + [Test] + public async Task BuildHeaders_CrossOrigin_OmitsCorsHeaders(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( + "application/json", "app://localhost/data.json", "https://example.com", out IntPtr headers); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; + await Assert.That(headerString).Contains("Content-Type: application/json"); + await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Origin"); + await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Credentials"); + await Assert.That(headerString).DoesNotContain("Vary:"); + } + finally { + FreeIfNonZero(headers); + } + } + + [Test] + public async Task BuildHeaders_EmptyOrigin_OmitsCorsHeaders(CancellationToken ct = default) { + // Act + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( + "text/plain", "app://localhost/page.html", "", out IntPtr headers); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; + await Assert.That(headerString).Contains("Content-Type: text/plain"); + await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Origin"); + } + finally { + FreeIfNonZero(headers); + } + } + + private static void FreeIfNonZero(IntPtr ptr) { + if (ptr != IntPtr.Zero) InfiniFrameNativeTesting.FreeTestString(ptr); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponseCorsPipelineTests.cs b/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponseCorsPipelineTests.cs new file mode 100644 index 000000000..123cf9c25 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponseCorsPipelineTests.cs @@ -0,0 +1,238 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; +using InfiniFrame.NativeBridge; +using InfiniFrame.NativeBridge.Delegates; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using System.Runtime.InteropServices; + +namespace InfiniTests.InfiniFrame.Window.Events; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +[NotInParallelInfiniTests] +public class CustomSchemeResponseCorsPipelineTests { + + [Test] + public async Task Callback_SameOriginRequest_ProducesResponseWithCorsHeaders(CancellationToken ct = default) { + // Arrange + InfiniFrameEvents events = CreateEvents((_, _) => ( + new MemoryStream("test"u8.ToArray()), "application/json")); + var response = new CustomSchemeResponse(); + + // Act + int handled = events.OnCustomScheme("app://localhost/data.json", ref response); + try { + await Assert.That(handled).IsEqualTo(1); + string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; + + // Build headers via native function with same origin + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( + contentType, "app://localhost/data.json", "app://localhost", out IntPtr headers); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; + await Assert.That(headerString).Contains("Content-Type: application/json"); + await Assert.That(headerString).Contains("Access-Control-Allow-Origin: app://localhost"); + await Assert.That(headerString).Contains("Access-Control-Allow-Credentials: true"); + await Assert.That(headerString).Contains("Vary: Origin"); + } + finally { + if (headers != IntPtr.Zero) InfiniFrameNativeTesting.FreeTestString(headers); + } + } + finally { + Release(ref response); + } + } + + [Test] + public async Task Callback_CrossOriginRequest_ProducesResponseWithoutCorsHeaders(CancellationToken ct = default) { + // Arrange + InfiniFrameEvents events = CreateEvents((_, _) => ( + new MemoryStream("test"u8.ToArray()), "application/json")); + var response = new CustomSchemeResponse(); + + // Act + int handled = events.OnCustomScheme("app://localhost/data.json", ref response); + try { + await Assert.That(handled).IsEqualTo(1); + string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; + + // Build headers via native function with different origin + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( + contentType, "app://localhost/data.json", "https://example.com", out IntPtr headers); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; + await Assert.That(headerString).Contains("Content-Type: application/json"); + await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Origin"); + await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Credentials"); + } + finally { + if (headers != IntPtr.Zero) InfiniFrameNativeTesting.FreeTestString(headers); + } + } + finally { + Release(ref response); + } + } + + [Test] + public async Task Callback_NullOrigin_ProducesResponseWithoutCorsHeaders(CancellationToken ct = default) { + // Arrange + InfiniFrameEvents events = CreateEvents((_, _) => ( + new MemoryStream("test"u8.ToArray()), "text/html")); + var response = new CustomSchemeResponse(); + + // Act + int handled = events.OnCustomScheme("app://localhost/page.html", ref response); + try { + await Assert.That(handled).IsEqualTo(1); + string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; + + // Build headers via native function with empty origin + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( + contentType, "app://localhost/page.html", "", out IntPtr headers); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; + await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Origin"); + } + finally { + if (headers != IntPtr.Zero) InfiniFrameNativeTesting.FreeTestString(headers); + } + } + finally { + Release(ref response); + } + } + + [Test] + public async Task Callback_DifferentPorts_ProducesResponseWithoutCorsHeaders(CancellationToken ct = default) { + // Arrange + InfiniFrameEvents events = CreateEvents((_, _) => ( + new MemoryStream("test"u8.ToArray()), "application/octet-stream")); + var response = new CustomSchemeResponse(); + + // Act + int handled = events.OnCustomScheme("app://localhost/data.bin", ref response); + try { + await Assert.That(handled).IsEqualTo(1); + string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; + + // Build headers via native function with different port (same host) + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( + contentType, "app://localhost/data.bin", "app://localhost:8080", out IntPtr headers); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; + await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Origin"); + } + finally { + if (headers != IntPtr.Zero) InfiniFrameNativeTesting.FreeTestString(headers); + } + } + finally { + Release(ref response); + } + } + + [Test] + public async Task Callback_DifferentSchemes_ProducesResponseWithoutCorsHeaders(CancellationToken ct = default) { + // Arrange + InfiniFrameEvents events = CreateEvents((_, _) => ( + new MemoryStream("test"u8.ToArray()), "text/plain")); + var response = new CustomSchemeResponse(); + + // Act + int handled = events.OnCustomScheme("app://localhost/page.txt", ref response); + try { + await Assert.That(handled).IsEqualTo(1); + string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; + + // Build headers via native function with different scheme + InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( + contentType, "app://localhost/page.txt", "http://localhost", out IntPtr headers); + try { + // Assert + await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; + await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Origin"); + } + finally { + if (headers != IntPtr.Zero) InfiniFrameNativeTesting.FreeTestString(headers); + } + } + finally { + Release(ref response); + } + } + + [Test] + public async Task Callback_SubpathRequests_AreSameOrigin(CancellationToken ct = default) { + // Arrange + InfiniFrameEvents events = CreateEvents((_, _) => ( + new MemoryStream("test"u8.ToArray()), "text/html")); + var responseA = new CustomSchemeResponse(); + var responseB = new CustomSchemeResponse(); + + // Act + int handledA = events.OnCustomScheme("app://localhost/a", ref responseA); + int handledB = events.OnCustomScheme("app://localhost/b", ref responseB); + try { + await Assert.That(handledA).IsEqualTo(1); + await Assert.That(handledB).IsEqualTo(1); + string contentType = Marshal.PtrToStringUTF8(responseA.ContentTypeUtf8)!; + + // Both subpaths should be same-origin relative to app://localhost + InfiniFrameNativeInteropStatus statusA = InfiniFrameNativeTesting.BuildHeaders( + contentType, "app://localhost/a", "app://localhost", out IntPtr headersA); + InfiniFrameNativeInteropStatus statusB = InfiniFrameNativeTesting.BuildHeaders( + contentType, "app://localhost/b", "app://localhost", out IntPtr headersB); + try { + // Assert + await Assert.That(statusA).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + await Assert.That(statusB).IsEqualTo(InfiniFrameNativeInteropStatus.Success); + string headerStringA = InfiniFrameNative.MarshalNativeToString(headersA)!; + string headerStringB = InfiniFrameNative.MarshalNativeToString(headersB)!; + await Assert.That(headerStringA).Contains("Access-Control-Allow-Origin: app://localhost"); + await Assert.That(headerStringB).Contains("Access-Control-Allow-Origin: app://localhost"); + } + finally { + if (headersA != IntPtr.Zero) InfiniFrameNativeTesting.FreeTestString(headersA); + if (headersB != IntPtr.Zero) InfiniFrameNativeTesting.FreeTestString(headersB); + } + } + finally { + Release(ref responseA); + Release(ref responseB); + } + } + + private static InfiniFrameEvents CreateEvents( + Func handler + ) { + var store = new InfiniFrameEventsStore(); + store.CustomScheme.Add("app", handler); + var events = new InfiniFrameEvents(store, NullLogger.Instance); + var window = Substitute.For(); + window.Id.Returns(Guid.NewGuid()); + events.AssignToWindow(window); + return events; + } + + private static void Release(ref CustomSchemeResponse response) { + if (response.OwnerContext == IntPtr.Zero) return; + + var release = Marshal.GetDelegateForFunctionPointer(response.Release); + release(response.OwnerContext); + response = default; + } +}