Skip to content

Commit 373cbcc

Browse files
Merge pull request #203 from daveharmswebdev/feature/14-2-security-headers-middleware
feat: Add security headers middleware (Story 14.2)
2 parents 9569191 + 8968157 commit 373cbcc

6 files changed

Lines changed: 349 additions & 4 deletions

File tree

_bmad-output/implementation-artifacts/14-1-cors-configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Story 14.1: CORS Configuration
22

3-
Status: dev-complete
3+
Status: done
44

55
## Story
66

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Story 14.2: Security Headers Middleware
2+
3+
Status: review
4+
5+
## Story
6+
7+
As a **developer**,
8+
I want **standard security headers added to every HTTP response**,
9+
So that **the application is protected against common browser-based attacks**.
10+
11+
## Acceptance Criteria
12+
13+
1. **Given** any API response is returned **When** I inspect the response headers **Then** the following headers are present:
14+
- `X-Frame-Options: DENY`
15+
- `X-Content-Type-Options: nosniff`
16+
- `Referrer-Policy: strict-origin-when-cross-origin`
17+
- `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()`
18+
19+
2. **Given** the application is running in production or staging **When** a response is returned **Then** the `Strict-Transport-Security` (HSTS) header is present **And** HSTS is NOT present in development (auto-handled by `app.UseHsts()`)
20+
21+
3. **Given** the Content Security Policy header **When** I inspect the CSP value **Then** it allows:
22+
- `'self'` for default sources
23+
- Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) for styles and fonts
24+
- `blob:` for PDF preview rendering
25+
- `*.ingest.sentry.io` for Sentry error reporting
26+
- `'unsafe-inline'` for styles (required by Angular Material)
27+
28+
4. **Given** the security headers middleware is registered **When** checking the middleware pipeline order **Then** it is placed after `UseHttpsRedirection()` and `UseHsts()`
29+
30+
## Tasks / Subtasks
31+
32+
- [x] Task 1: Create SecurityHeadersMiddleware class (AC: #1, #3)
33+
- [x] 1.1 Create `backend/src/PropertyManager.Api/Middleware/SecurityHeadersMiddleware.cs`
34+
- [x] 1.2 Implement `InvokeAsync` that adds all security headers before calling `_next(context)`
35+
- [x] 1.3 Add headers: `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()`
36+
- [x] 1.4 Add CSP header: `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://*.ingest.sentry.io; frame-ancestors 'none'; object-src 'none'`
37+
38+
- [x] Task 2: Create extension method for clean pipeline registration (AC: #4)
39+
- [x] 2.1 Add a static `UseSecurityHeaders()` extension method on `IApplicationBuilder` in the middleware file (or a separate `MiddlewareExtensions.cs` if preferred, but co-locating is fine)
40+
41+
- [x] Task 3: Register middleware in Program.cs pipeline (AC: #2, #4)
42+
- [x] 3.1 Add `app.UseHsts()` after `app.UseHttpsRedirection()` (built-in, auto-skips in Development)
43+
- [x] 3.2 Add `app.UseSecurityHeaders()` after `app.UseHsts()`
44+
- [x] 3.3 Resulting pipeline order:
45+
```
46+
app.UseMiddleware<GlobalExceptionHandlerMiddleware>();
47+
// Swagger (dev only)
48+
app.UseHttpsRedirection();
49+
app.UseHsts(); // NEW - Story 14.2
50+
app.UseSecurityHeaders(); // NEW - Story 14.2
51+
app.UseSerilogRequestLogging();
52+
app.UseCors("AllowedOrigins");
53+
app.UseAuthentication();
54+
app.UseAuthorization();
55+
app.MapControllers();
56+
app.MapHub<ReceiptHub>("/hubs/receipts");
57+
```
58+
59+
- [x] Task 4: Write unit tests for SecurityHeadersMiddleware (AC: #1, #3)
60+
- [x] 4.1 Create `backend/tests/PropertyManager.Api.Tests/Middleware/SecurityHeadersMiddlewareTests.cs`
61+
- [x] 4.2 Test: Response includes `X-Frame-Options: DENY`
62+
- [x] 4.3 Test: Response includes `X-Content-Type-Options: nosniff`
63+
- [x] 4.4 Test: Response includes `Referrer-Policy: strict-origin-when-cross-origin`
64+
- [x] 4.5 Test: Response includes `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()`
65+
- [x] 4.6 Test: Response includes `Content-Security-Policy` with correct value
66+
- [x] 4.7 Test: Next middleware is called (pass-through behavior)
67+
68+
## Dev Notes
69+
70+
### Middleware Pattern — Follow Existing Convention
71+
72+
Follow the `GlobalExceptionHandlerMiddleware` pattern at `backend/src/PropertyManager.Api/Middleware/GlobalExceptionHandlerMiddleware.cs`:
73+
- Same namespace: `PropertyManager.Api.Middleware`
74+
- Constructor takes `RequestDelegate next` (no logger/environment needed — this middleware just adds headers)
75+
- `InvokeAsync(HttpContext context)` method
76+
- Add headers to `context.Response.Headers` **before** calling `await _next(context)`
77+
78+
### CSP Header Value (Exact)
79+
80+
```
81+
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://*.ingest.sentry.io; frame-ancestors 'none'; object-src 'none'
82+
```
83+
84+
Rationale for each directive:
85+
- `style-src 'unsafe-inline'` — Angular Material injects inline styles
86+
- `fonts.googleapis.com` / `fonts.gstatic.com` — Google Fonts used in the app
87+
- `img-src blob:` — PDF preview rendering via blob URLs
88+
- `connect-src https://*.ingest.sentry.io` — Story 14.4 will add Sentry; pre-allow the domain now
89+
- `frame-ancestors 'none'` — equivalent to X-Frame-Options DENY, belt-and-suspenders
90+
91+
### HSTS — Use Built-in
92+
93+
Do **not** manually add the `Strict-Transport-Security` header. Use `app.UseHsts()` which:
94+
- Automatically adds `Strict-Transport-Security: max-age=2592000` (30 days)
95+
- Automatically **skips** in Development environment (no HSTS on localhost)
96+
- This satisfies AC #2 with zero custom code
97+
98+
### Pipeline Placement
99+
100+
Headers must be added **early** in the pipeline so every response (including error responses, CORS preflight responses, etc.) gets the security headers. Place after `UseHttpsRedirection()` and `UseHsts()` but before `UseCors()`.
101+
102+
Current pipeline in `Program.cs` (lines 225-249):
103+
```csharp
104+
app.UseMiddleware<GlobalExceptionHandlerMiddleware>();
105+
// Swagger (dev only)
106+
app.UseHttpsRedirection();
107+
app.UseSerilogRequestLogging();
108+
app.UseCors(corsPolicyName);
109+
app.UseAuthentication();
110+
app.UseAuthorization();
111+
```
112+
113+
Target after this story:
114+
```csharp
115+
app.UseMiddleware<GlobalExceptionHandlerMiddleware>();
116+
// Swagger (dev only)
117+
app.UseHttpsRedirection();
118+
app.UseHsts(); // NEW
119+
app.UseSecurityHeaders(); // NEW
120+
app.UseSerilogRequestLogging();
121+
app.UseCors(corsPolicyName);
122+
app.UseAuthentication();
123+
app.UseAuthorization();
124+
```
125+
126+
### Test Pattern — Follow GlobalExceptionHandlerMiddlewareTests
127+
128+
Test file: `backend/tests/PropertyManager.Api.Tests/Middleware/GlobalExceptionHandlerMiddlewareTests.cs`
129+
130+
Key patterns to reuse:
131+
- `DefaultHttpContext` with `MemoryStream` for response body
132+
- Simple `RequestDelegate` that returns `Task.CompletedTask` (no exception — just pass through)
133+
- Assert against `_httpContext.Response.Headers["HeaderName"]`
134+
- Use `FluentAssertions` (already referenced in test project)
135+
- Use `[Fact]` attributes (xUnit, already in use)
136+
137+
### Previous Story (14.1) Learnings
138+
139+
- CORS implementation in Story 14.1 was clean — followed the same Program.cs modification pattern
140+
- Code review feedback (commit `31d29c0`) addressed minor issues — keep code tight and focused
141+
- The test project already has the `Middleware/` folder structure ready
142+
143+
### Project Structure Notes
144+
145+
- New file: `backend/src/PropertyManager.Api/Middleware/SecurityHeadersMiddleware.cs`
146+
- Modified file: `backend/src/PropertyManager.Api/Program.cs`
147+
- New test file: `backend/tests/PropertyManager.Api.Tests/Middleware/SecurityHeadersMiddlewareTests.cs`
148+
- All paths align with existing project conventions — `Middleware/` folder already exists
149+
150+
### References
151+
152+
- [Source: _bmad-output/planning-artifacts/epic-14-security-hardening-observability.md#Story 14.2]
153+
- [Source: backend/src/PropertyManager.Api/Middleware/GlobalExceptionHandlerMiddleware.cs] — middleware pattern reference
154+
- [Source: backend/tests/PropertyManager.Api.Tests/Middleware/GlobalExceptionHandlerMiddlewareTests.cs] — test pattern reference
155+
- [Source: backend/src/PropertyManager.Api/Program.cs] — pipeline registration
156+
- [Source: _bmad-output/implementation-artifacts/14-1-cors-configuration.md] — previous story context
157+
158+
## Dev Agent Record
159+
160+
### Agent Model Used
161+
Claude Opus 4.6
162+
163+
### Debug Log References
164+
None — clean implementation with no issues.
165+
166+
### Completion Notes List
167+
- Created `SecurityHeadersMiddleware` with all 5 security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP) added before `_next(context)`
168+
- Co-located `UseSecurityHeaders()` extension method in same file for clean pipeline registration
169+
- Registered `app.UseHsts()` (built-in HSTS, auto-skips in Development) and `app.UseSecurityHeaders()` in Program.cs after `UseHttpsRedirection()` and before `UseSerilogRequestLogging()`
170+
- 6 unit tests: one per header + pass-through behavior — all passing
171+
- Full regression suite: 1,447 tests passing (913 Application + 85 Infrastructure + 449 Api)
172+
173+
### File List
174+
- `backend/src/PropertyManager.Api/Middleware/SecurityHeadersMiddleware.cs` (NEW)
175+
- `backend/src/PropertyManager.Api/Program.cs` (MODIFIED)
176+
- `backend/tests/PropertyManager.Api.Tests/Middleware/SecurityHeadersMiddlewareTests.cs` (NEW)
177+
- `_bmad-output/implementation-artifacts/14-2-security-headers-middleware.md` (MODIFIED)
178+
- `_bmad-output/implementation-artifacts/sprint-status.yaml` (MODIFIED)
179+
180+
## Change Log
181+
- 2026-02-14: Implemented security headers middleware (Story 14.2) — all 4 tasks complete, 6 tests added, 1,447 total tests passing

_bmad-output/implementation-artifacts/sprint-status.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,9 @@ development_status:
195195

196196
# Epic 14: Security Hardening & Observability
197197
# User Outcome: "The app is hardened and observable — ready for beta users"
198-
epic-14: pending
199-
14-1-cors-configuration: pending
200-
14-2-security-headers-middleware: pending
198+
epic-14: in-progress
199+
14-1-cors-configuration: done
200+
14-2-security-headers-middleware: review
201201
14-3-api-rate-limiting: pending
202202
14-4-backend-sentry-integration: pending
203203
14-5-frontend-sentry-integration: pending
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
namespace PropertyManager.Api.Middleware;
2+
3+
public class SecurityHeadersMiddleware
4+
{
5+
private const string XFrameOptions = "DENY";
6+
private const string XContentTypeOptions = "nosniff";
7+
private const string ReferrerPolicy = "strict-origin-when-cross-origin";
8+
private const string PermissionsPolicy = "camera=(), microphone=(), geolocation=(), payment=()";
9+
private const string ContentSecurityPolicy =
10+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
11+
"font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; " +
12+
"connect-src 'self' https://*.ingest.sentry.io; frame-ancestors 'none'; " +
13+
"object-src 'none'; base-uri 'self'; form-action 'self'";
14+
15+
private readonly RequestDelegate _next;
16+
17+
public SecurityHeadersMiddleware(RequestDelegate next)
18+
{
19+
_next = next;
20+
}
21+
22+
public async Task InvokeAsync(HttpContext context)
23+
{
24+
context.Response.Headers["X-Frame-Options"] = XFrameOptions;
25+
context.Response.Headers["X-Content-Type-Options"] = XContentTypeOptions;
26+
context.Response.Headers["Referrer-Policy"] = ReferrerPolicy;
27+
context.Response.Headers["Permissions-Policy"] = PermissionsPolicy;
28+
context.Response.Headers["Content-Security-Policy"] = ContentSecurityPolicy;
29+
30+
await _next(context);
31+
}
32+
}
33+
34+
public static class SecurityHeadersMiddlewareExtensions
35+
{
36+
public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app)
37+
{
38+
return app.UseMiddleware<SecurityHeadersMiddleware>();
39+
}
40+
}

backend/src/PropertyManager.Api/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@
236236
}
237237

238238
app.UseHttpsRedirection();
239+
app.UseHsts();
240+
app.UseSecurityHeaders();
239241
app.UseSerilogRequestLogging();
240242
app.UseCors(corsPolicyName);
241243

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using FluentAssertions;
2+
using Microsoft.AspNetCore.Http;
3+
using PropertyManager.Api.Middleware;
4+
5+
namespace PropertyManager.Api.Tests.Middleware;
6+
7+
public class SecurityHeadersMiddlewareTests
8+
{
9+
private readonly DefaultHttpContext _httpContext;
10+
private readonly SecurityHeadersMiddleware _middleware;
11+
private bool _nextCalled;
12+
13+
public SecurityHeadersMiddlewareTests()
14+
{
15+
_httpContext = new DefaultHttpContext();
16+
_httpContext.Response.Body = new MemoryStream();
17+
_nextCalled = false;
18+
19+
RequestDelegate next = _ =>
20+
{
21+
_nextCalled = true;
22+
return Task.CompletedTask;
23+
};
24+
25+
_middleware = new SecurityHeadersMiddleware(next);
26+
}
27+
28+
[Fact]
29+
public async Task InvokeAsync_AddsXFrameOptionsDeny()
30+
{
31+
await _middleware.InvokeAsync(_httpContext);
32+
33+
_httpContext.Response.Headers["X-Frame-Options"].ToString().Should().Be("DENY");
34+
}
35+
36+
[Fact]
37+
public async Task InvokeAsync_AddsXContentTypeOptionsNosniff()
38+
{
39+
await _middleware.InvokeAsync(_httpContext);
40+
41+
_httpContext.Response.Headers["X-Content-Type-Options"].ToString().Should().Be("nosniff");
42+
}
43+
44+
[Fact]
45+
public async Task InvokeAsync_AddsReferrerPolicy()
46+
{
47+
await _middleware.InvokeAsync(_httpContext);
48+
49+
_httpContext.Response.Headers["Referrer-Policy"].ToString().Should().Be("strict-origin-when-cross-origin");
50+
}
51+
52+
[Fact]
53+
public async Task InvokeAsync_AddsPermissionsPolicy()
54+
{
55+
await _middleware.InvokeAsync(_httpContext);
56+
57+
_httpContext.Response.Headers["Permissions-Policy"].ToString()
58+
.Should().Be("camera=(), microphone=(), geolocation=(), payment=()");
59+
}
60+
61+
[Fact]
62+
public async Task InvokeAsync_AddsContentSecurityPolicy()
63+
{
64+
await _middleware.InvokeAsync(_httpContext);
65+
66+
var csp = _httpContext.Response.Headers["Content-Security-Policy"].ToString();
67+
csp.Should().Contain("default-src 'self'");
68+
csp.Should().Contain("script-src 'self'");
69+
csp.Should().Contain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com");
70+
csp.Should().Contain("font-src 'self' https://fonts.gstatic.com");
71+
csp.Should().Contain("img-src 'self' data: blob:");
72+
csp.Should().Contain("connect-src 'self' https://*.ingest.sentry.io");
73+
csp.Should().Contain("frame-ancestors 'none'");
74+
csp.Should().Contain("object-src 'none'");
75+
csp.Should().Contain("base-uri 'self'");
76+
csp.Should().Contain("form-action 'self'");
77+
}
78+
79+
[Fact]
80+
public async Task InvokeAsync_ContentSecurityPolicy_HasExactExpectedDirectiveCount()
81+
{
82+
await _middleware.InvokeAsync(_httpContext);
83+
84+
var csp = _httpContext.Response.Headers["Content-Security-Policy"].ToString();
85+
var directives = csp.Split(';', StringSplitOptions.TrimEntries);
86+
directives.Should().HaveCount(10);
87+
}
88+
89+
[Fact]
90+
public async Task InvokeAsync_CallsNextMiddleware()
91+
{
92+
await _middleware.InvokeAsync(_httpContext);
93+
94+
_nextCalled.Should().BeTrue();
95+
}
96+
97+
[Fact]
98+
public async Task InvokeAsync_HeadersSetEvenWhenNextThrows()
99+
{
100+
var context = new DefaultHttpContext();
101+
context.Response.Body = new MemoryStream();
102+
103+
RequestDelegate throwingNext = _ => throw new InvalidOperationException("downstream failure");
104+
var middleware = new SecurityHeadersMiddleware(throwingNext);
105+
106+
var act = () => middleware.InvokeAsync(context);
107+
108+
await act.Should().ThrowAsync<InvalidOperationException>();
109+
context.Response.Headers["X-Frame-Options"].ToString().Should().Be("DENY");
110+
context.Response.Headers["Content-Security-Policy"].ToString().Should().NotBeEmpty();
111+
}
112+
113+
[Fact]
114+
public async Task InvokeAsync_OverwritesPreExistingHeaders()
115+
{
116+
_httpContext.Response.Headers["X-Frame-Options"] = "SAMEORIGIN";
117+
118+
await _middleware.InvokeAsync(_httpContext);
119+
120+
_httpContext.Response.Headers["X-Frame-Options"].ToString().Should().Be("DENY");
121+
}
122+
}

0 commit comments

Comments
 (0)