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