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));
+ }
+}