diff --git a/src/Host/FSH.Starter.Api/appsettings.Production.json b/src/Host/FSH.Starter.Api/appsettings.Production.json index 8cf660327d..db3cfc6d1e 100644 --- a/src/Host/FSH.Starter.Api/appsettings.Production.json +++ b/src/Host/FSH.Starter.Api/appsettings.Production.json @@ -59,8 +59,8 @@ "CorsOptions": { "AllowAll": false, "AllowedOrigins": [], - "AllowedHeaders": [ "content-type", "authorization" ], - "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] + "AllowedHeaders": [ "content-type", "authorization", "tenant", "x-fsh-app", "idempotency-key", "x-requested-with", "x-signalr-user-agent" ], + "AllowedMethods": [ "GET", "POST", "PUT", "PATCH", "DELETE" ] }, "JwtOptions": { "Issuer": "fsh.local", diff --git a/src/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json index 6a0a968ba2..17a3bccc11 100644 --- a/src/Host/FSH.Starter.Api/appsettings.json +++ b/src/Host/FSH.Starter.Api/appsettings.json @@ -100,8 +100,8 @@ "http://localhost:5173", "http://localhost:5174" ], - "AllowedHeaders": [ "content-type", "authorization" ], - "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] + "AllowedHeaders": [ "content-type", "authorization", "tenant", "x-fsh-app", "idempotency-key", "x-requested-with", "x-signalr-user-agent" ], + "AllowedMethods": [ "GET", "POST", "PUT", "PATCH", "DELETE" ] }, "JwtOptions": { "Issuer": "fsh.local", diff --git a/src/Tests/Framework.Tests/Framework.Tests.csproj b/src/Tests/Framework.Tests/Framework.Tests.csproj index 89ec11ee87..8bc68c1bd3 100644 --- a/src/Tests/Framework.Tests/Framework.Tests.csproj +++ b/src/Tests/Framework.Tests/Framework.Tests.csproj @@ -32,4 +32,13 @@ + + + + + diff --git a/src/Tests/Framework.Tests/Web/CorsConfigurationTests.cs b/src/Tests/Framework.Tests/Web/CorsConfigurationTests.cs new file mode 100644 index 0000000000..930b28eb84 --- /dev/null +++ b/src/Tests/Framework.Tests/Web/CorsConfigurationTests.cs @@ -0,0 +1,62 @@ +using System.Text.Json; + +namespace Framework.Tests.Web; + +public sealed class CorsConfigurationTests +{ + [Theory] + [InlineData("appsettings.json")] + [InlineData("appsettings.Production.json")] + public void AllowedMethods_Should_IncludePatch_When_RestrictedCorsIsConfigured(string fileName) + { + // Arrange + string path = Path.Combine(AppContext.BaseDirectory, "HostConfiguration", fileName); + + // Act + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + JsonElement corsOptions = document.RootElement.GetProperty("CorsOptions"); + string[] allowedMethods = corsOptions + .GetProperty("AllowedMethods") + .EnumerateArray() + .Select(method => method.GetString()) + .OfType() + .ToArray(); + + // Assert + corsOptions.GetProperty("AllowAll").GetBoolean().ShouldBeFalse(); + allowedMethods.ShouldContain("PATCH"); + } + + // Every non-safelisted header the React clients (and the SignalR client) send must be allowed, + // or the browser rejects the preflight: tenant on every call, X-FSH-App on login, + // Idempotency-Key on chat sends, X-Requested-With / X-SignalR-User-Agent on hub negotiate. + [Theory] + [InlineData("appsettings.json", "tenant")] + [InlineData("appsettings.json", "x-fsh-app")] + [InlineData("appsettings.json", "idempotency-key")] + [InlineData("appsettings.json", "x-requested-with")] + [InlineData("appsettings.json", "x-signalr-user-agent")] + [InlineData("appsettings.Production.json", "tenant")] + [InlineData("appsettings.Production.json", "x-fsh-app")] + [InlineData("appsettings.Production.json", "idempotency-key")] + [InlineData("appsettings.Production.json", "x-requested-with")] + [InlineData("appsettings.Production.json", "x-signalr-user-agent")] + public void AllowedHeaders_Should_IncludeClientHeader_When_RestrictedCorsIsConfigured(string fileName, string header) + { + // Arrange + string path = Path.Combine(AppContext.BaseDirectory, "HostConfiguration", fileName); + + // Act + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + string[] allowedHeaders = document.RootElement + .GetProperty("CorsOptions") + .GetProperty("AllowedHeaders") + .EnumerateArray() + .Select(h => h.GetString()) + .OfType() + .ToArray(); + + // Assert + allowedHeaders.ShouldContain(h => string.Equals(h, header, StringComparison.OrdinalIgnoreCase)); + } +}