Settles how the headers and observability units get built
NFR Design for U3 and U4. Two decisions the earlier stages had deliberately left open, plus four risks the functional design did not name. OPEN-01 closed: the correlation ID is the W3C trace ID from the ambient Activity, with TraceIdentifier as the fallback. It propagates across the master/slave boundary via traceparent, which TraceIdentifier cannot do at all, and it is the same value ProblemDetails already returns to the client. REF-U3-01 raised: BR-U3-22's Umami-origin startup warning cannot work. The backend never sees VITE_UMAMI_WEBSITE_ID, so the check would either always warn or never warn. Withdrawn from U3 and replaced by a blocking U5 CI gate that compares the frontend build variable against that environment's CSP origins, where both values are visible. Four additions beyond the functional design: - Set-Cookie added to the scrub list; the login response issues the refreshToken there, so scrubbing only the request cookie protects nothing - SetBeforeSendTransaction alongside SetBeforeSend; transactions carry request data too - OnRejected on the rate limiter; today a 429 leaves no trace anywhere - FlushAsync before the migration-failure rethrow, or the one Critical event in the system dies with the process Two traps recorded with tests attached: Sentry groups log events by message template, so interpolated messages make FR-19's rate-based alert rules unimplementable while appearing to work; and DefaultHttpContext.Response .OnStarting is a no-op, so the obvious middleware test asserts nothing. Three values chosen rather than escalated, each one line to change and all three listed for review at the end of U4's pattern document: JSON console outside Development, TracesSampleRate 0.1, tunnel cap 200 KB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
# Logical Components — U3 HTTP Security Headers & CSP
|
||||
|
||||
All new types live in `SlpModularCms.Core/Hosting/Security/`, alongside the existing `AdminTokenValidator` from U1. Nothing is added to `SlpModularCms.Api`, so everything here is reachable by `SlpModularCms.Core.Tests` — deliberately, after U1's Step 11 deviation showed what happens when host-only code needs testing.
|
||||
|
||||
---
|
||||
|
||||
## Component Wiring
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Configuration["Configuration"]
|
||||
Section["SecurityHeaders section<br/>appsettings per environment"]
|
||||
Options["SecurityHeadersOptions"]
|
||||
Validator["IValidateOptions<br/>ValidateOnStart"]
|
||||
end
|
||||
|
||||
subgraph Policy["Policy Composition — startup only"]
|
||||
Catalog["CspPolicyCatalog<br/>static, in code"]
|
||||
Provider["ICspPolicyProvider<br/>CspPolicyProvider"]
|
||||
Frozen["FrozenDictionary<string, SecurityHeaderSet>"]
|
||||
end
|
||||
|
||||
subgraph Request["Request Path — per response"]
|
||||
Middleware["SecurityHeadersMiddleware"]
|
||||
Resolver["PathPolicyResolver"]
|
||||
State["HeaderWriteState<br/>struct-like state object"]
|
||||
Writer["SecurityHeaderWriter<br/>static, pure"]
|
||||
end
|
||||
|
||||
subgraph Host["Host Wiring"]
|
||||
AddExt["AddCmsSecurityHeaders"]
|
||||
UseExt["UseCmsSecurityHeaders"]
|
||||
Logger["ILogger — startup origins,<br/>disabled warning"]
|
||||
end
|
||||
|
||||
Response["HTTP response headers"]
|
||||
|
||||
Section --> Options
|
||||
Options --> Validator
|
||||
Options --> Provider
|
||||
Options --> Resolver
|
||||
Catalog --> Provider
|
||||
Provider --> Frozen
|
||||
AddExt --> Options
|
||||
AddExt --> Provider
|
||||
AddExt --> Resolver
|
||||
UseExt --> Middleware
|
||||
UseExt --> Logger
|
||||
Middleware --> Resolver
|
||||
Middleware --> Provider
|
||||
Middleware --> State
|
||||
State --> Writer
|
||||
Writer --> Response
|
||||
|
||||
classDef cfg fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef code fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef runtime fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef host fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||
classDef out fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
|
||||
class Section,Options,Validator cfg;
|
||||
class Catalog,Provider,Frozen,Writer code;
|
||||
class Middleware,Resolver,State runtime;
|
||||
class AddExt,UseExt,Logger host;
|
||||
class Response out;
|
||||
```
|
||||
|
||||
Text alternative: configuration binds to validated options that feed both the policy provider and the path resolver at startup; the provider composes the in-code catalog into a frozen dictionary of header sets; per response the middleware resolves a policy name, registers a response-start callback carrying a state object, and a static writer applies the headers.
|
||||
|
||||
---
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
| Component | Lifetime | Responsibility | Pattern |
|
||||
|---|---|---|---|
|
||||
| `SecurityHeadersOptions` | Options singleton | Binds `Enabled`, `DefaultPolicy`, `PathPolicies`, `AllowedScriptOrigins`, `AllowedConnectOrigins` | 1, 11 |
|
||||
| `PathPolicyRule` | Record | One `PathPrefix` → `Policy` pair | 3 |
|
||||
| `CspPolicyCatalog` | Static | The two policy definitions. Pure function of origin lists → directive list | 2 |
|
||||
| `SecurityHeaderSet` | Record | The three HTML-only header values for one policy | 2 |
|
||||
| `ICspPolicyProvider` / `CspPolicyProvider` | Singleton | Composes and caches header sets once; one lookup per response | 2 |
|
||||
| `PathPolicyResolver` | Singleton | Ordered, segment-aware prefix match → policy name | 3 |
|
||||
| `SecurityHeadersMiddleware` | Per-request instance, singleton delegate | Gate on `Enabled`, resolve the policy, register the response-start callback | 4 |
|
||||
| `SecurityHeaderWriter` | Static | Per-header scoping, HSTS gating, never-overwrite. **All decision logic** | 5, 6 |
|
||||
| `SecurityHeadersExtensions` | Static | `AddCmsSecurityHeaders` / `UseCmsSecurityHeaders`, startup logging | 9, 10 |
|
||||
|
||||
**Why `SecurityHeaderWriter` is static and separate from the middleware**: it is the only component whose behaviour is worth exhaustive testing, and `DefaultHttpContext` cannot drive it through the middleware (Pattern 5). Splitting it makes the interesting part testable with `new HeaderDictionary()` and leaves the middleware as glue with nothing to get wrong except ordering — which is verified at Build and Test.
|
||||
|
||||
**Why `PathPolicyResolver` is a class rather than a method on the provider**: it answers a different question (which policy) from the provider (what the policy contains), and the `/administrator` versus `/admin` trap deserves its own test class rather than being buried in provider tests.
|
||||
|
||||
---
|
||||
|
||||
## DI Registration Order
|
||||
|
||||
Inside `AddCmsSecurityHeaders(configuration)`:
|
||||
|
||||
```
|
||||
1. services.AddOptions<SecurityHeadersOptions>()
|
||||
.Bind(configuration.GetSection("SecurityHeaders"))
|
||||
.Validate(...) // unknown policy name, origin format
|
||||
.ValidateOnStart()
|
||||
2. services.AddSingleton<ICspPolicyProvider, CspPolicyProvider>()
|
||||
3. services.AddSingleton<PathPolicyResolver>()
|
||||
```
|
||||
|
||||
Called from `Program.cs` next to the other `AddCms*` calls, before module registration. Order within the file does not matter — nothing here is overridable by a module, unlike Data Protection in U2.
|
||||
|
||||
`UseCmsSecurityHeaders()` is called **first inside `UseExceptionHandler()`**; see Pattern 10. That position *does* matter, in both directions.
|
||||
|
||||
---
|
||||
|
||||
## Both Hosts
|
||||
|
||||
`SlpModularCms.Api` and `SlpModularCms.Api.Slave` both call `AddCmsSecurityHeaders` and `UseCmsSecurityHeaders`. The slave host serves no admin SPA and no public website today, but it does serve `/api/v1` and `/health`, and it will be reached directly during diagnosis. There is no reason for it to be the one host without `nosniff` and HSTS.
|
||||
|
||||
The slave's `PathPolicies` defaults are identical; the paths that do not exist there simply never match.
|
||||
|
||||
---
|
||||
|
||||
## NFR Coverage Traceability
|
||||
|
||||
| NFR / Rule | Pattern | Component |
|
||||
|---|---|---|
|
||||
| NFR-01 — no server configuration | Headers emitted in-process | `SecurityHeadersMiddleware`, `SecurityHeaderWriter` |
|
||||
| NFR-03 — startup cost | Compose twice at startup, never per request | `CspPolicyProvider` + `FrozenDictionary` |
|
||||
| NFR-06 — testability | Decision logic in pure static functions | `SecurityHeaderWriter`, `CspPolicyCatalog`, `PathPolicyResolver` |
|
||||
| SECURITY-04 | All five headers; CSP on every HTML path | `SecurityHeaderWriter`, `CspPolicyCatalog` |
|
||||
| SECURITY-11 | CSP as a second layer behind output escaping | `CspPolicyCatalog` |
|
||||
| SECURITY-15 — fail closed | `ValidateOnStart`; never throws per request | Pattern 1, Pattern 7 |
|
||||
| BR-U3-01…03 — per-header scope | Explicit `sendHsts` + content-type parse | `SecurityHeaderWriter` |
|
||||
| BR-U3-04 — never overwrite | `TryAdd` rather than the indexer | `SecurityHeaderWriter` |
|
||||
| BR-U3-05 — write at response start | `OnStarting(callback, state)` with a static delegate | `SecurityHeadersMiddleware` |
|
||||
| BR-U3-06, BR-U3-09 — position | Inside the exception handler, before static files | `UseCmsSecurityHeaders` call site |
|
||||
| BR-U3-07 — never throw | Catch-and-log inside the callback | `SecurityHeadersMiddleware` |
|
||||
| BR-U3-10…19 — policy content | Two policies in code, origins from configuration | `CspPolicyCatalog` |
|
||||
| BR-U3-20 — unknown name fatal | `ValidateOnStart` | Pattern 1 |
|
||||
| BR-U3-22 | **Refined — REF-U3-01**, moved to the U5 CI gate | Carried to U5 |
|
||||
| BR-U3-23, BR-U3-24 — startup logging | Logged from the `Use` extension | `SecurityHeadersExtensions` |
|
||||
|
||||
---
|
||||
|
||||
## Carried Forward
|
||||
|
||||
| Item | To | Why |
|
||||
|---|---|---|
|
||||
| **REF-U3-01** — Umami origin drift gate | **U5 CI Workflow** | The backend cannot see `VITE_UMAMI_WEBSITE_ID`; the CI job can see both sides and must fail on drift |
|
||||
| Pipeline-order verification | **Build and Test** | A misordered registration passes every unit test and serves the whole website without a CSP |
|
||||
| Header presence on a real `503` and a real static asset | **Build and Test** | Needs a running host with a real response feature |
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
# NFR Design Patterns — U3 HTTP Security Headers & CSP
|
||||
|
||||
**Unit**: U3 HTTP Security Headers & CSP
|
||||
**NFRs addressed**: NFR-01 (no server configuration), NFR-03 (startup cost), NFR-06 (testability), SECURITY-04, SECURITY-11, SECURITY-15
|
||||
|
||||
These patterns exist because U3 moves work that normally lives in nginx into the request pipeline. That changes the failure modes: a bad nginx config fails loudly at reload, whereas a bad middleware silently emits nothing. Every pattern below is chosen so that "wrong" is visible rather than quiet.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1 — Fail Closed at Startup: `ValidateOnStart`
|
||||
|
||||
**Rule**: BR-U3-20 — an unknown policy name must stop the process.
|
||||
|
||||
**Pattern**: Bind `SecurityHeadersOptions` through the options builder with a validator and `ValidateOnStart()`, so the failure happens during host start rather than on the first request.
|
||||
|
||||
```csharp
|
||||
services.AddOptions<SecurityHeadersOptions>()
|
||||
.Bind(configuration.GetSection(SecurityHeadersOptions.SectionName))
|
||||
.Validate(o => o.PathPolicies.All(r => CspPolicyCatalog.IsKnownPolicy(r.Policy)),
|
||||
"SecurityHeaders:PathPolicies contains an unknown policy name.")
|
||||
.Validate(o => CspPolicyCatalog.IsKnownPolicy(o.DefaultPolicy),
|
||||
"SecurityHeaders:DefaultPolicy is not a known policy.")
|
||||
.Validate(o => o.AllowedScriptOrigins.All(IsOrigin) &&
|
||||
o.AllowedConnectOrigins.All(IsOrigin),
|
||||
"SecurityHeaders origins must be scheme-and-host only, without a path.")
|
||||
.ValidateOnStart();
|
||||
```
|
||||
|
||||
**Why `ValidateOnStart` and not a constructor check**: options are resolved lazily. Without `ValidateOnStart`, a typo in `PathPolicies` is discovered when the first request arrives — by which time the deployment has already been reported as successful and the health check is green. `ValidateOnStart` registers an `IStartupValidator` that runs inside `IHost.StartAsync`, so `dotnet SlpModularCms.Api.dll` exits non-zero and the release switch is visibly broken.
|
||||
|
||||
**Consequence for the deploy workflow (U6)**: this makes a smoke request unnecessary to catch configuration typos — the process simply does not come up. Worth stating, because it is the difference between "deployment failed" and "deployment succeeded and the site is broken".
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2 — Precomposed Immutable Policies: `ICspPolicyProvider`
|
||||
|
||||
**Rule**: BR-U3-18 — policy strings are composed once and reused.
|
||||
|
||||
```csharp
|
||||
public interface ICspPolicyProvider
|
||||
{
|
||||
SecurityHeaderSet Get(string policyName);
|
||||
IReadOnlyCollection<string> PolicyNames { get; }
|
||||
}
|
||||
|
||||
public sealed record SecurityHeaderSet(
|
||||
string ContentSecurityPolicy,
|
||||
string FrameOptions,
|
||||
string ReferrerPolicy);
|
||||
```
|
||||
|
||||
**Implementation shape**: a singleton whose constructor builds a `FrozenDictionary<string, SecurityHeaderSet>` from `CspPolicyCatalog` (the in-code policy definitions) plus the configured origin lists. Nothing is composed per request; the middleware performs one dictionary lookup and three header writes.
|
||||
|
||||
**Why `FrozenDictionary`**: read-only after startup and read on every response. This is the exact workload it exists for, and the build cost is paid once against a two-entry dictionary.
|
||||
|
||||
**Why the split into catalog and provider**: `CspPolicyCatalog` is a pure static function of `(policy name, origin lists) → directive list` with no dependencies, so the directive content is unit-testable without a service provider or configuration. The provider only caches it.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3 — Path Matching: `StartsWithSegments`, First Match Wins
|
||||
|
||||
**Rule**: BR-U3-12.
|
||||
|
||||
```csharp
|
||||
foreach (var rule in _rules) // ordered, from configuration
|
||||
{
|
||||
if (path.StartsWithSegments(rule.Prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return rule.Policy;
|
||||
}
|
||||
}
|
||||
return _defaultPolicy;
|
||||
```
|
||||
|
||||
**`StartsWithSegments`, never `string.StartsWith`.** `"/administrator".StartsWith("/admin")` is `true`. A future route named `/admin-tools` or a public page at `/administration` would silently inherit the strict policy and lose its inline scripts — a failure that looks like a broken page with a console error and no server-side trace at all. `StartsWithSegments` compares whole path segments and does not match.
|
||||
|
||||
**First match wins, not longest prefix.** The rule list is ordered, and order is meaningful. Longest-prefix matching would be more forgiving but would make the effective policy non-obvious from reading the configuration. Order is documented in the configuration comment and asserted in a test that puts a broader prefix before a narrower one and expects the broader one to win.
|
||||
|
||||
**Prefixes are normalised at startup** — a configured `admin` or `/admin/` both become `/admin` — because `StartsWithSegments` requires a leading slash and treats a trailing slash inconsistently across overloads.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4 — Writing at Response Start Without a Per-Request Closure
|
||||
|
||||
**Rule**: BR-U3-05 — the content type is unknown before the pipeline continues.
|
||||
|
||||
```csharp
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var headerSet = _policyProvider.Get(_resolver.Resolve(context.Request.Path));
|
||||
context.Response.OnStarting(WriteHeadersCallback,
|
||||
new HeaderWriteState(context.Response, headerSet));
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static readonly Func<object, Task> WriteHeadersCallback = static state =>
|
||||
{
|
||||
var s = (HeaderWriteState)state;
|
||||
SecurityHeaderWriter.Apply(s.Response.Headers, s.Response.ContentType, s.HeaderSet, s.SendHsts);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
```
|
||||
|
||||
**Why the `(callback, state)` overload with a `static` delegate**: the lambda overload allocates a closure and a delegate on **every response**, including every static asset of the public website. Static assets are the bulk of the traffic in this hosting model. A cached static delegate plus one small state object is the difference between two allocations per response and none beyond the state.
|
||||
|
||||
**Why not `IHttpResponseFeature` interception or a `Stream` wrapper**: both are heavier, both risk interfering with `SendFileAsync` fast paths used by static-file middleware, and neither buys anything — headers are still mutable at response start.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5 — The Header Decision as a Pure Function (the testability pattern)
|
||||
|
||||
**NFR-06**, and a trap worth naming.
|
||||
|
||||
`DefaultHttpContext.Response.OnStarting(...)` **does nothing** — there is no response feature to trigger it. A unit test that builds a `DefaultHttpContext`, invokes the middleware and asserts on `context.Response.Headers` will find them empty, and the natural reaction is to assume the middleware is broken. The opposite mistake is worse: a test that asserts *nothing was set* passes for the wrong reason and keeps passing after the middleware is deleted.
|
||||
|
||||
**Pattern**: all decision logic lives in a static, dependency-free writer. The middleware is glue.
|
||||
|
||||
```csharp
|
||||
internal static class SecurityHeaderWriter
|
||||
{
|
||||
public static void Apply(
|
||||
IHeaderDictionary headers,
|
||||
string? contentType,
|
||||
SecurityHeaderSet set,
|
||||
bool sendHsts)
|
||||
{
|
||||
headers.TryAdd("X-Content-Type-Options", "nosniff");
|
||||
if (sendHsts)
|
||||
{
|
||||
headers.TryAdd("Strict-Transport-Security", HstsValue);
|
||||
}
|
||||
|
||||
if (!IsHtml(contentType))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
headers.TryAdd("Content-Security-Policy", set.ContentSecurityPolicy);
|
||||
headers.TryAdd("X-Frame-Options", set.FrameOptions);
|
||||
headers.TryAdd("Referrer-Policy", set.ReferrerPolicy);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Component | Test style | Why |
|
||||
|---|---|---|
|
||||
| `CspPolicyCatalog` | Pure function over origin lists | Directive content, `'unsafe-inline'` placement, BR-U3-13 |
|
||||
| `SecurityHeaderWriter` | `new HeaderDictionary()` directly | Per-header scoping, HSTS gating, never-overwrite |
|
||||
| `PathPolicyResolver` | Pure function over `PathString` | `/administrator` vs `/admin`, ordering |
|
||||
| Options validation | `ValidateOnStart` via a real `ServiceProvider` | Startup failure on an unknown name |
|
||||
| Composed middleware | Carried to phase-level Build and Test | Needs a real response feature; `DefaultHttpContext` cannot express it |
|
||||
|
||||
**`TryAdd`, not the indexer** (BR-U3-04): `headers["X-Frame-Options"] = value` overwrites. `TryAdd` is a no-op when the key exists, which is exactly the rule, and it states the intent in the call rather than in a surrounding `if`.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6 — Content-Type Detection
|
||||
|
||||
```csharp
|
||||
private static bool IsHtml(string? contentType) =>
|
||||
contentType is not null &&
|
||||
MediaTypeHeaderValue.TryParse(contentType, out var parsed) &&
|
||||
parsed.MediaType.Equals("text/html", StringComparison.OrdinalIgnoreCase);
|
||||
```
|
||||
|
||||
**Why parse rather than `Contains("text/html")`**: the real header is `text/html; charset=utf-8`, so a substring check happens to work — until something serves `application/xhtml+xml` or a type whose *parameter* contains the string. Parsing states the intent and costs nothing at this volume.
|
||||
|
||||
**`null` is not HTML**, which is what makes the `304`, redirect and no-body cases in BR-U3's edge-case table fall out automatically rather than needing their own branches.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 7 — Never Throw Into the Response Path
|
||||
|
||||
**Rule**: BR-U3-07.
|
||||
|
||||
An exception thrown from an `OnStarting` callback surfaces after the response has begun — too late for the exception handler, and it produces a truncated or malformed response instead of a diagnosable error. The callback therefore wraps the writer:
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
SecurityHeaderWriter.Apply(...);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
s.Logger.LogError(ex, "Failed to apply security headers to the response.");
|
||||
}
|
||||
```
|
||||
|
||||
**This is deliberately the one place that swallows.** The alternative — letting it propagate — converts a header-writing bug into a corrupted response for the user. The startup validation in Pattern 1 is what keeps this catch from hiding configuration errors: by the time a request arrives, the configuration is already known good, so anything reaching this catch is a code defect and belongs in the log (and, via U4, in Sentry at `Error`).
|
||||
|
||||
---
|
||||
|
||||
## Pattern 8 — HSTS Gating Resolved Once
|
||||
|
||||
`sendHsts` is `!IsDevelopment()`, evaluated **once at startup** and stored on the middleware, not per request. `IWebHostEnvironment.IsDevelopment()` is a string comparison against `EnvironmentName` that cannot change while the process runs; evaluating it per response is pure waste on a static-file workload.
|
||||
|
||||
The value is passed explicitly into the writer rather than read from ambient state, so the Development and non-Development cases are both directly testable (BR-U3-02, BR-U3-08).
|
||||
|
||||
---
|
||||
|
||||
## Pattern 9 — Startup Logging and the Umami Drift Check
|
||||
|
||||
**Rules**: BR-U3-22, BR-U3-23, BR-U3-24.
|
||||
|
||||
BR-U3-23 (log permitted origins) and BR-U3-24 (warn when disabled) are logged from `UseCmsSecurityHeaders()`, which runs once during pipeline construction and has `app.Services` available. No hosted service is needed.
|
||||
|
||||
### Refinement REF-U3-01 — BR-U3-22 cannot work as specified
|
||||
|
||||
BR-U3-22 asks for a startup warning when "a Umami website ID is configured but its script origin is absent from the allowed origins". **The backend cannot perform that check.** The Umami website ID is a `VITE_` variable baked into the frontend bundle at build time; the running host never sees it. Comparing `AllowedScriptOrigins` against a value the process cannot read would either always warn or never warn.
|
||||
|
||||
The check is therefore **moved to where both values are visible — the CI workflow (U5)**:
|
||||
|
||||
| Where | What is checked | On failure |
|
||||
|---|---|---|
|
||||
| ~~Backend startup~~ | ~~Umami ID configured, origin missing from CSP~~ | Withdrawn — not implementable |
|
||||
| **U5 CI workflow** | If `VITE_UMAMI_SCRIPT_URL` is set for an environment's frontend build, its origin must appear in that environment's `SecurityHeaders:AllowedScriptOrigins` | **Pipeline fails** |
|
||||
| Backend startup (retained) | Permitted script and connect origins | Logged at `Information` (BR-U3-23) |
|
||||
|
||||
This is stronger than the rule it replaces: a drift between the frontend build and the backend CSP now blocks the deployment instead of writing a warning into a log file on a Raspberry Pi that nobody reads until something is already broken.
|
||||
|
||||
**Carried to U5** as a required gate. Recorded here rather than silently dropped, because "we warn about this" appearing in a design and not in the code is exactly the kind of gap that survives to production.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 10 — Pipeline Position
|
||||
|
||||
```
|
||||
UseExceptionHandler() ← already present
|
||||
UseCmsSecurityHeaders() ← NEW: first thing inside the exception handler
|
||||
UseRateLimiter()
|
||||
UseHttpsRedirection()
|
||||
UseCmsStaticContent() ← short-circuits; must be after us (BR-U3-06)
|
||||
UseCors()
|
||||
UseModules() ← availability gate; its 503 still gets headers
|
||||
UseAuthentication() / UseAuthorization()
|
||||
MapControllers() / MapCmsHealthChecks() / MapCmsSpaFallbacks()
|
||||
```
|
||||
|
||||
Two ordering constraints, both load-bearing:
|
||||
|
||||
- **Inside `UseExceptionHandler`** (BR-U3-09): the exception handler re-executes the pipeline from inside itself, so middleware registered outside it never observes the `ProblemDetails` response. Registering there means error responses carry the headers too.
|
||||
- **Before `UseCmsStaticContent`** (BR-U3-06): static-file middleware terminates the request. Everything registered after it is invisible to the public website — which is almost all of the HTML this host serves.
|
||||
|
||||
**Ordering is verified by test, not by comment.** A phase-level Build and Test assertion requests a static website asset and a `503` from the availability gate and asserts on the headers of both. A misordered registration compiles, starts, passes every unit test, and serves the entire website without a CSP.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 11 — Configuration Shape
|
||||
|
||||
```jsonc
|
||||
"SecurityHeaders": {
|
||||
"Enabled": true,
|
||||
"DefaultPolicy": "Relaxed",
|
||||
"PathPolicies": [
|
||||
{ "PathPrefix": "/admin", "Policy": "Strict" },
|
||||
{ "PathPrefix": "/api/v1", "Policy": "Strict" },
|
||||
{ "PathPrefix": "/health", "Policy": "Strict" }
|
||||
],
|
||||
"AllowedScriptOrigins": [],
|
||||
"AllowedConnectOrigins": []
|
||||
}
|
||||
```
|
||||
|
||||
Follows the existing `IOptions` convention used by `JwtSettings`, `Availability`, `MasterModule`, `MasterPolling` and `RateLimiting`. No secret appears in this section — origins are public hostnames — so it is committed with real values per environment rather than supplied through environment variables (contrast D-16, which governs credentials).
|
||||
|
||||
**The defaults are the production values.** An environment that supplies no `SecurityHeaders` section at all gets the strict policy on `/admin`, `/api/v1` and `/health` and the relaxed policy elsewhere. Omission cannot produce an unprotected host.
|
||||
Reference in New Issue
Block a user