diff --git a/StrictDocOslcRm.sln b/StrictDocOslcRm.sln
new file mode 100644
index 0000000..a5dc498
--- /dev/null
+++ b/StrictDocOslcRm.sln
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/SecurityHeadersTests.cs b/src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/SecurityHeadersTests.cs
new file mode 100644
index 0000000..77d0aaa
--- /dev/null
+++ b/src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/SecurityHeadersTests.cs
@@ -0,0 +1,106 @@
+using System.Net;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Playwright;
+
+namespace StrictDocOslcRm.Tests;
+
+[ClassDataSource>(Shared = SharedType.PerAssembly)]
+public class SecurityHeadersTests(WebApplicationFactory factory)
+{
+ private readonly WebApplicationFactory _factory = factory;
+
+ [Test]
+ public async Task SecurityHeaders_ArePresent_OnEmbeddableResource()
+ {
+ // Use HttpClient for checking headers directly
+ var client = _factory.CreateClient();
+ // Since we don't have real data, root might 404 but middleware still runs.
+ // Wait, root "/" maps to RequirementController.GetRequirementResource but requires query param 'a'.
+ // Without 'a', it returns 400 Bad Request. Middleware runs on 400 too.
+ var response = await client.GetAsync("/?a=123");
+
+ var headers = response.Headers;
+
+ // Assertions
+ await Assert.That(headers.Contains("X-Content-Type-Options")).IsTrue();
+ await Assert.That(headers.GetValues("X-Content-Type-Options").FirstOrDefault()).IsEqualTo("nosniff");
+
+ await Assert.That(headers.Contains("Referrer-Policy")).IsTrue();
+ await Assert.That(headers.GetValues("Referrer-Policy").FirstOrDefault()).IsEqualTo("strict-origin-when-cross-origin");
+
+ await Assert.That(headers.Contains("Permissions-Policy")).IsTrue();
+
+ // X-Frame-Options should be absent for embeddable
+ await Assert.That(headers.Contains("X-Frame-Options")).IsFalse();
+
+ // CSP should allow framing (frame-ancestors *)
+ await Assert.That(headers.Contains("Content-Security-Policy")).IsTrue();
+ var csp = headers.GetValues("Content-Security-Policy").FirstOrDefault();
+ await Assert.That(csp).IsNotNull();
+ await Assert.That(csp).Contains("frame-ancestors *");
+ }
+
+ [Test]
+ public async Task HstsHeader_IsPresent_InProduction()
+ {
+ var client = _factory
+ .WithWebHostBuilder(builder =>
+ {
+ builder.UseEnvironment("Production");
+ })
+ .CreateClient(new WebApplicationFactoryClientOptions
+ {
+ BaseAddress = new Uri("https://localhost")
+ });
+
+ var response = await client.GetAsync("/?a=123");
+ var headers = response.Headers;
+
+ // Verify Strict-Transport-Security
+ await Assert.That(headers.Contains("Strict-Transport-Security")).IsTrue();
+ var hsts = headers.GetValues("Strict-Transport-Security").FirstOrDefault();
+ await Assert.That(hsts).IsNotNull();
+ await Assert.That(hsts).Contains("max-age=");
+ }
+
+ [Test]
+ public async Task SecurityHeaders_ArePresent_OnSelector()
+ {
+ var client = _factory.CreateClient();
+ var response = await client.GetAsync("/oslc/service_provider/123/requirements/selector");
+
+ var headers = response.Headers;
+ // Even if 404 (document not found), the middleware checks PATH.
+ // However, if the controller returns 404, the response is generated.
+ // The middleware runs AFTER the controller (on the way back)? No, it wraps 'next'.
+ // So it adds headers to the response object.
+ // The middleware logic:
+ // await next(context);
+ // headers["..."] = ...
+ // So it adds headers AFTER the inner pipeline.
+
+ await Assert.That(headers.Contains("X-Frame-Options")).IsFalse();
+
+ var csp = headers.GetValues("Content-Security-Policy").FirstOrDefault();
+ await Assert.That(csp).IsNotNull();
+ await Assert.That(csp).Contains("frame-ancestors *");
+ }
+
+ [Test]
+ public async Task SecurityHeaders_ArePresent_OnNonEmbeddableResource()
+ {
+ var client = _factory.CreateClient();
+ // A route that is not whitelisted
+ var response = await client.GetAsync("/oslc/catalog/123");
+
+ var headers = response.Headers;
+
+ await Assert.That(headers.Contains("X-Frame-Options")).IsTrue();
+ await Assert.That(headers.GetValues("X-Frame-Options").FirstOrDefault()).IsEqualTo("DENY");
+
+ await Assert.That(headers.Contains("Content-Security-Policy")).IsTrue();
+ var csp = headers.GetValues("Content-Security-Policy").FirstOrDefault();
+ await Assert.That(csp).IsNotNull();
+ await Assert.That(csp).Contains("frame-ancestors 'none'");
+ }
+}
diff --git a/src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/StrictDocOslcRm.Tests.csproj b/src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/StrictDocOslcRm.Tests.csproj
index 971f365..ef3e30d 100644
--- a/src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/StrictDocOslcRm.Tests.csproj
+++ b/src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/StrictDocOslcRm.Tests.csproj
@@ -1,9 +1,10 @@
- net10.0
+ net8.0
enable
enable
Exe
+ true
@@ -12,6 +13,8 @@
+
+
diff --git a/src/StrictDocOslcRmServer/StrictDocOslcRm/Middleware/SecurityHeadersMiddleware.cs b/src/StrictDocOslcRmServer/StrictDocOslcRm/Middleware/SecurityHeadersMiddleware.cs
new file mode 100644
index 0000000..1a6278c
--- /dev/null
+++ b/src/StrictDocOslcRmServer/StrictDocOslcRm/Middleware/SecurityHeadersMiddleware.cs
@@ -0,0 +1,76 @@
+using Microsoft.AspNetCore.Http;
+using System.Threading.Tasks;
+
+namespace StrictDocOslcRm.Middleware;
+
+public class SecurityHeadersMiddleware(RequestDelegate next)
+{
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var headers = context.Response.Headers;
+
+ // X-Content-Type-Options
+ headers["X-Content-Type-Options"] = "nosniff";
+
+ // Referrer-Policy
+ headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
+
+ // Permissions-Policy
+ headers["Permissions-Policy"] = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
+
+ // Determine if the endpoint should be embeddable
+ var path = context.Request.Path;
+ var isEmbeddable = IsEmbeddable(path);
+
+ if (isEmbeddable)
+ {
+ // Remove X-Frame-Options if present
+ headers.Remove("X-Frame-Options");
+ }
+ else
+ {
+ headers["X-Frame-Options"] = "DENY";
+ }
+
+ // Content-Security-Policy
+ var csp = BuildCsp(isEmbeddable);
+ headers["Content-Security-Policy"] = csp;
+
+ await next(context);
+ }
+
+ private static bool IsEmbeddable(PathString path)
+ {
+ if (!path.HasValue) return false;
+
+ var pathValue = path.Value!;
+
+ // RequirementController.GetRequirementResource -> /
+ if (pathValue == "/") return true;
+
+ // ServiceProviderController.RequirementSelector -> /oslc/service_provider/{documentMid}/requirements/selector
+ if (pathValue.StartsWith("/oslc/service_provider", StringComparison.OrdinalIgnoreCase) &&
+ pathValue.EndsWith("/requirements/selector", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private static string BuildCsp(bool isEmbeddable)
+ {
+ var frameAncestors = isEmbeddable ? "*" : "'none'";
+
+ return "default-src 'self'; " +
+ "script-src 'self' 'unsafe-inline' https://unpkg.com; " +
+ "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " +
+ "font-src 'self' https://cdn.jsdelivr.net; " +
+ "img-src 'self' data:; " +
+ $"frame-ancestors {frameAncestors}; " +
+ "object-src 'none'; " +
+ "base-uri 'self'; " +
+ "form-action 'self'; " +
+ "upgrade-insecure-requests;";
+ }
+}
diff --git a/src/StrictDocOslcRmServer/StrictDocOslcRm/Program.cs b/src/StrictDocOslcRmServer/StrictDocOslcRm/Program.cs
index ea8d3b6..a783def 100644
--- a/src/StrictDocOslcRmServer/StrictDocOslcRm/Program.cs
+++ b/src/StrictDocOslcRmServer/StrictDocOslcRm/Program.cs
@@ -31,12 +31,21 @@
// Register StrictDoc service
builder.Services.AddSingleton();
+// Configure HSTS options
+builder.Services.AddHsts(options =>
+{
+ options.Preload = true;
+ options.IncludeSubDomains = true;
+ options.MaxAge = TimeSpan.FromDays(365);
+});
+
var app = builder.Build();
// Configure the HTTP request pipeline
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error");
+ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
else
@@ -47,6 +56,9 @@
//
// app.UseHttpsRedirection();
+// Add security headers middleware
+app.UseMiddleware();
+
// Enable CORS
app.UseCors();
@@ -61,3 +73,5 @@
app.MapControllers();
app.Run();
+
+public partial class Program { }
diff --git a/src/StrictDocOslcRmServer/StrictDocOslcRm/StrictDocOslcRm.csproj b/src/StrictDocOslcRmServer/StrictDocOslcRm/StrictDocOslcRm.csproj
index c6b793b..ce46820 100644
--- a/src/StrictDocOslcRmServer/StrictDocOslcRm/StrictDocOslcRm.csproj
+++ b/src/StrictDocOslcRmServer/StrictDocOslcRm/StrictDocOslcRm.csproj
@@ -1,7 +1,7 @@
- net10.0
+ net8.0
enable
enable