Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions StrictDocOslcRm.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="StrictDocOslcRm/StrictDocOslcRm.csproj" />
<Project Path="StrictDocOslcRm.Tests/StrictDocOslcRm.Tests.csproj" />
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System.Net;

Check warning on line 1 in src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/SecurityHeadersTests.cs

View workflow job for this annotation

GitHub Actions / Format (dotnet format)

Using directive is unnecessary.
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Playwright;

Check warning on line 3 in src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/SecurityHeadersTests.cs

View workflow job for this annotation

GitHub Actions / Format (dotnet format)

Using directive is unnecessary.

namespace StrictDocOslcRm.Tests;

[ClassDataSource<WebApplicationFactory<Program>>(Shared = SharedType.PerAssembly)]
public class SecurityHeadersTests(WebApplicationFactory<Program> factory)
{
private readonly WebApplicationFactory<Program> _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'");
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>

<ItemGroup>
Expand All @@ -12,6 +13,8 @@

<ItemGroup>
<PackageReference Include="dotNetRdf" Version="3.4.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="Microsoft.Playwright" Version="1.57.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="OSLC4Net.Core" Version="0.6.3" />
<PackageReference Include="OSLC4Net.Core.DotNetRdfProvider" Version="0.6.3" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Http;

Check warning on line 1 in src/StrictDocOslcRmServer/StrictDocOslcRm/Middleware/SecurityHeadersMiddleware.cs

View workflow job for this annotation

GitHub Actions / Format (dotnet format)

Using directive is unnecessary.
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;

Check warning on line 44 in src/StrictDocOslcRmServer/StrictDocOslcRm/Middleware/SecurityHeadersMiddleware.cs

View workflow job for this annotation

GitHub Actions / Format (dotnet format)

Add braces to 'if' statement.

var pathValue = path.Value!;

// RequirementController.GetRequirementResource -> /
if (pathValue == "/") return true;

Check warning on line 49 in src/StrictDocOslcRmServer/StrictDocOslcRm/Middleware/SecurityHeadersMiddleware.cs

View workflow job for this annotation

GitHub Actions / Format (dotnet format)

Add braces to 'if' statement.

// 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;";
}
}
14 changes: 14 additions & 0 deletions src/StrictDocOslcRmServer/StrictDocOslcRm/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,21 @@
// Register StrictDoc service
builder.Services.AddSingleton<IStrictDocService, StrictDocService>();

// 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
Expand All @@ -47,6 +56,9 @@
//
// app.UseHttpsRedirection();

// Add security headers middleware
app.UseMiddleware<SecurityHeadersMiddleware>();

// Enable CORS
app.UseCors();

Expand All @@ -61,3 +73,5 @@
app.MapControllers();

app.Run();

public partial class Program { }
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
Expand Down
Loading