Architecture Decision Record
ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP
Status
Accepted (2026-07-02). Revised 2026-09-01 (the static default is now a complete hardened baseline: it
ships script-src 'self' 'wasm-unsafe-eval' and style-src 'self' 'unsafe-inline' instead of omitting
both directives; BlazorCspPolicyProvider fails closed on an API/Gateway origin it cannot resolve,
narrowing connect-src to 'self' while staying enforced, rather than degrading to a permissive
Report-Only policy; the middleware also substitutes a per-request nonce for a {nonce} token in the
resolved policy).
Revised 2026-09-07 (HSTS and forwarded headers are applied on the UI hosts and not only at the
gateway, and credential-carrying paths additionally answer Referrer-Policy: no-referrer and
Cache-Control: no-store).
Revised 2026-09-19 (the Blazor provider's emitted policy is recorded in full: it also carries
img-src and font-src, an opt-in startup-validated frame-src, and a Development-only
script-src 'unsafe-inline'; and the UI host's forwarded-headers options clear KnownProxies and
KnownIPNetworks on purpose).
Revised 2026-09-10 (both UI origins are pinned by a host-level conformance test in the no-database
CI filter).
Revised 2026-10-01 (a custom provider registered after AddCommonSecurityHeaders with plain
AddSingleton still wins; only a late TryAdd registration loses to the static default).
Revised 2026-10-06 (credential paths also answer Pragma: no-cache, re-apply their cache headers at
response start over a weaker downstream value, and have their prefixes validated at startup).
Revised 2026-10-07: anchors refreshed after the v1.233.0 release.
Context
Every client-facing host (the YARP Gateway and the Blazor UI web host in each app) must stamp the same
hardened HTTP response headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Permissions-Policy, HSTS, and a Content-Security-Policy. These were previously hand-rolled per host, so
the values drifted between Gateway and UI and between apps, and a new edge host could ship with weaker
headers (or none) by omission. A Content-Security-Policy is the hard part: an API or Gateway host that
serves JSON, WebSockets, and a static privacy page wants a strict, fixed policy, but a Blazor/MudBlazor
host needs script-src 'wasm-unsafe-eval' and style-src 'unsafe-inline' and must pin connect-src to
its own API/Gateway origin (which it only knows at runtime from configuration). One static policy cannot
serve both, and a wrong CSP hard-breaks the app, so the policy cannot simply be a constant in the
framework.
Decision
Ship one security-headers middleware in MMCA.Common.Aspire (MMCA.Common.Aspire.Security), registered
with AddCommonSecurityHeaders(configuration?, configure?) and inserted early with
UseCommonSecurityHeaders().
SecurityHeadersMiddlewarestamps every response withX-Content-Type-Options: nosniff,X-Frame-Options(defaultDENY),Referrer-Policy(defaultstrict-origin-when-cross-origin),Permissions-Policy(defaultgeolocation=(), microphone=(), camera=(), payment=()), and HSTS (max-age=31536000; includeSubDomains, emitted only outside Development and only whenEnableHsts). All values are overridable via the"SecurityHeaders"configuration section or theconfiguredelegate (SecurityHeadersSettings).- The CSP is resolved through an
ICspPolicyProviderextension point, not stamped as a constant. The provider returns aCspPolicy(string Value, bool Enforce): whenEnforceis true the middleware writesContent-Security-Policy, otherwiseContent-Security-Policy-Report-Only. Returningnullemits no CSP. A resolved policy that carries the literal token{nonce}gets a fresh 128-bit value per request, substituted into the header as'nonce-<value>'and stashed inHttpContext.Items(read withCspNonce.Get) before the rest of the pipeline runs, so a layout can stamp it onto its own tags. - The default provider (
StaticCspPolicyProvider) returns a complete hardened baseline fromSecurityHeadersSettings.ContentSecurityPolicy:default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'. It shipsscript-srcandstyle-srcat exactly the strength Blazor ('wasm-unsafe-eval') and MudBlazor ('unsafe-inline'styles) need, so an HTML host that never registers a fuller provider gets a functional policy rather than one silently missing both directives, while the JSON, WebSocket and static responses of API and Gateway hosts are unaffected. A host wanting a stricter or looser policy configures the"SecurityHeaders"section or registers its own provider. - HTML hosts register their own
ICspPolicyProviderbefore callingAddCommonSecurityHeaders(the default registration usesTryAddSingleton, so it is skipped when a provider is already registered). Both apps register one sharedBlazorCspPolicyProvider(a singleinternal sealedclass living inMMCA.Common.UI.Web) viaAddCommonBlazorCspahead ofAddCommonSecurityHeaders. It pinsconnect-srcto'self'plus the configured API/Gateway origin (https + wss, from the sharedApiSettings), addsscript-src 'self' 'wasm-unsafe-eval',style-src 'self' 'unsafe-inline',img-src 'self' data: https:andfont-src 'self', emits an opt-inframe-srcwhen the host configures one, and fails closed when that origin cannot be resolved or parsed:connect-srcnarrows to'self'and the policy is still enforced, so a misconfiguration shows up immediately as blocked cross-origin calls in the browser console rather than as a header that is emitted but inert. Development loosens two directives, not one:connect-srcgainshttp://localhost:* ws://localhost:*andscript-srcadditionally gains'unsafe-inline'(Visual Studio Browser Link / Hot Reload injects an inline bootstrap script as well as opening a localhost WebSocket), and the second of those is not localhost-scoped. The 2026-09-19 revision below records the emitted policy directive by directive. - Adopted at both edges of both apps: Store and ADC each wire
AddCommonSecurityHeaders+UseCommonSecurityHeadersin their Gateway host and their UI web host, with the UI host also registeringBlazorCspPolicyProvider. The middleware carries a unit test (SecurityHeadersMiddlewareTestsinMMCA.Common.Aspire.Tests).
Rationale
- One hardened default, defined once. Centralizing the header set removes per-host drift and makes a new edge host secure by default rather than by remembering to copy headers.
- An extension point, because one CSP cannot fit all hosts. The
ICspPolicyProviderindirection is the minimum needed to let a Blazor host inject a runtime, origin-pinned policy while API/Gateway hosts keep the strict static one, without the framework guessing either app's origins. - A default that is complete and still Blazor-compatible. The baseline carries
script-srcandstyle-srcat the weakest strength a Blazor/MudBlazor host actually needs, so every directive is covered even for a host that never registers a provider, and the shared middleware is still never the thing that blanks out such a host. Tightening past that is an explicit act: configure the section or register a provider, both visible and testable. - Fail closed when a dynamic policy cannot be built. A
connect-srcorigin that cannot be resolved is a misconfiguration, so the policy keeps enforcing on the strictest value it can be sure of ('self'). The mistake surfaces as blocked cross-origin calls, which someone notices, instead of a Report-Only header that protects nothing.
Trade-offs
- The baseline is complete, not maximal. Shipping a policy that works for a Blazor/MudBlazor host
means the default carries
style-src 'unsafe-inline'(MudBlazor injects styles at runtime), so inline styles are not blocked out of the box, and an API or Gateway host that serves no HTML inherits a script/style allowance it does not need. Either host tightens the string in the"SecurityHeaders"section or registers its own provider; the{nonce}placeholder is the supported path off'unsafe-inline'. The default is documented onSecurityHeadersSettings.ContentSecurityPolicy. - Registration order matters only for
TryAdd. The static default is registered withTryAddSingleton(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:288), so a customICspPolicyProviderregistered beforeAddCommonSecurityHeaderssuppresses it. A custom provider added afterwards with plainAddSingleton(asAddCommonBlazorCspdoes,MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:77) still wins, because a single-service resolve returns the last registration; the static default wins silently only when the late custom provider is itself added withTryAdd. Registering first remains the documented convention (SecurityHeaders.cs:265-266and the interface summary at:83-84,DependencyInjection.cs:56-58). - A shared Blazor CSP provider constrains per-host divergence.
BlazorCspPolicyProvidernow lives once inMMCA.Common.UI.Web, over the sharedApiSettingstype, and both apps register it viaAddCommonBlazorCsp, so the connect-src/origin logic is no longer copied per app. The remaining trade-off is that a host needing genuinely different CSP logic cannot edit an app-local class: it must supply its ownICspPolicyProvider(registered beforeAddCommonSecurityHeaders) instead. - Failing closed moves the pain onto a running app. A Blazor host whose
ApiSettingsendpoint is wrong still serves pages, but the enforcedconnect-src 'self'blocks every cross-origin API call and the SignalR notification hub. That loud signal is the point (a security header that quietly stops being enforced is the worse failure mode), and the cost is that the configuration mistake lands on the users of that deployment rather than in a passive report.
Revision (2026-09-07)
Two changes from the 2026-09-07 security review.
- Credential-carrying paths get a stricter referrer and cache posture.
SecurityHeadersSettings.CredentialPathPrefixes(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:46) defaults to["/reset-password", "/auth/oauth-complete"].SecurityHeadersMiddlewarematches the request path against it (:175) and, on a hit, replaces the site-widestrict-origin-when-cross-originwithno-referrer(:188) and writesCache-Control: no-store, no-cache, must-revalidate, max-age=0(:194). Both pages carry a single-use secret in the URL: the reset token and the OAuth authorization code.no-referrerkeeps that URL out of theRefererheader of every asset and outbound link the page loads, andno-storekeeps the rendered page out of the browser's back/forward cache and out of any shared proxy. The list is a settings property rather than a constant so an app that mounts these flows on its own routes can name them. - The UI origin emits its own HSTS. These headers used to be a gateway responsibility, which
left a server-rendered HTML origin behind a different ingress with none (SEC-ADC-18 /
SEC-Store-27). ADC's Blazor host registers the middleware
(
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:111) and runsUseForwardedHeaderswithXForwardedFor | XForwardedProto | XForwardedHost(:128-134) ahead of it, which is what makesRequest.IsHttpstrue behind a container ingress that terminates TLS: without that step the host sees plain HTTP on port 8080,UseHttpsRedirection(:153) is inert and noStrict-Transport-Securityis emitted (:137). The forwarded-headers options mirror the gateway and the service pipeline exactly, so all three hosts agree on what they trust (:121-122). Store's storefront host carries the same posture with a conformance test beside it (MMCA.Store/Tests/Hosts/MMCA.Store.UI.Web.Tests/SecurityHeadersTests.cs), closing the gap where only the JSON gateway was pinned by a test.
Revision (2026-09-10)
Both UI origins are now pinned by a test, not just Store's. ADC has
MMCA.ADC/Tests/Hosts/MMCA.ADC.UI.Web.Tests/SecurityHeadersTests.cs:17-18, a one-line subclass of
the framework's SecurityHeadersTestsBase
(MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/SecurityHeadersTestsBase.cs:16) over a
ConferenceUiHostApplicationFactory that boots the real Blazor host Production-pinned
(MMCA.ADC/Tests/Hosts/MMCA.ADC.UI.Web.Tests/ConferenceUiHostApplicationFactory.cs:17, deriving
ProductionHostApplicationFactory<Program>). Production-pinned is what makes the assertion mean
anything here, because Strict-Transport-Security is emitted on that environment and not in
Development. The shape matches Store's exactly
(MMCA.Store/Tests/Hosts/MMCA.Store.UI.Web.Tests/SecurityHeadersTests.cs:17-18).
The test project is in the no-database CI filter (MMCA.ADC/MMCA.ADC.CI.slnf:61, the same place
Store's sits at MMCA.Store/MMCA.Store.CI.slnf:55), so the guard runs on every pull request rather
than existing and never being executed. That is the whole difference between a conformance suite that
is adopted and one that is merely present: item 2 above records the UI origin emitting its own HSTS,
and a host-level test in the gating tier is what keeps a later pipeline edit from quietly undoing it.
Revision (2026-09-19)
The Blazor policy is wider than the Decision above recorded, and the UI host's forwarded-headers
options clear their allow-lists on purpose. The provider is no longer the copy that was hoisted out
of the two app hosts: it now takes IOptions<BlazorCspSettings> alongside ApiSettings
(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:33-40), so
"identical to the app-local copies" is history and the emitted string is what follows.
- The full policy it emits.
BuildPolicy(.../Security/BlazorCspPolicyProvider.cs:103-113) concatenates, in order:default-src 'self'(:104),script-src 'self' 'wasm-unsafe-eval'(:105),style-src 'self' 'unsafe-inline'(:106),img-src 'self' data: https:(:107),font-src 'self'(:108), the computedconnect-src(:109), an optionalframe-src(:110), thenbase-uri 'self',form-action 'self'andframe-ancestors 'none'(:111-113).img-srcis open to any https source on purpose, because profile pictures and content images come from arbitrary external hosts, while the directives that matter for exfiltration (script-srcandconnect-src) stay pinned (:99-102). Two differences from the static baseline in the Decision above are worth stating: this policy addsimg-srcandfont-src, and it carries noobject-srcdirective at all, so objects fall back todefault-src 'self'rather than the baseline's explicitobject-src 'none'(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:65-67). - Development loosens
script-src, not just localhostconnect-src. OnIsDevelopmentthe provider appendshttp://localhost:* ws://localhost:*toconnect-src(:74-77) and'unsafe-inline'toscript-src(:105). Only the first is localhost-scoped; the second is a blanket inline-script allowance for that environment. Both exist for Visual Studio's Browser Link and Hot Reload, which inject an inline bootstrap script and open a WebSocket on a port that changes every run (:70-73). Neither reaches a non-Development host, because the flag isIWebHostEnvironment.IsDevelopment()read once at construction (:41). frame-srcis opt-in and startup-validated. The directive is emitted only whenBlazorCspSettings.FrameSources(.../Security/BlazorCspSettings.cs:35, bound from the"BlazorCsp"configuration section,:21) lists at least one origin, asframe-src 'self' <origins>(:85-97); with the default empty list there is noframe-srcat all and frames fall back todefault-src 'self', leaving the policy unchanged. Each entry must be a plain absolute https origin:BlazorCspSettingsValidator(.../Security/BlazorCspSettingsValidator.cs:40-56) refuses a wildcard, quote, semicolon, comma, whitespace, user info, query, fragment or non-root path (:19), andAddCommonBlazorCspregisters it withValidateOnStart(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:52-60), so a bad entry fails the boot with a message naming it instead of being spliced verbatim into a security response header. Validated entries are canonicalized and de-duplicated before the splice (:91-96). This governs only what the host may frame:frame-ancestors 'none'is never relaxed by this path.- The forwarded-headers allow-lists are cleared deliberately. Item 2 of the 2026-09-07 revision
records that the UI host's options mirror the gateway and the service pipeline
(
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:173-176). What it leaves out is load-bearing:KnownProxiesandKnownIPNetworksare both cleared before the middleware is added (:184-185), because a cloud reverse proxy fronts the app from internal addresses that are in neither default list, and leaving the defaults in place makesUseForwardedHeadersignore every forwarded header it receives (:177-179). Populated allow-lists therefore make the whole 2026-09-07 fix inert:Request.IsHttpsstays false behind the ingress and noStrict-Transport-Securityis emitted.
Revision (2026-10-01)
No decision or rationale changed; one Trade-offs statement was corrected and the current homes of the forwarded-headers posture are recorded.
- The registration-order trade-off overstated the risk. It said a custom provider registered
after
AddCommonSecurityHeadersloses silently to the static default. Only the default usesTryAddSingleton(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:263);AddCommonBlazorCspuses plainAddSingleton(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:62), and a single-service resolve returns the last registration, so a lateAddSingletonprovider still wins. The Decision and Trade-offs bullets now say so. - The forwarded-headers posture now lives in the framework. The ADC Blazor host no longer
builds its own
ForwardedHeadersOptions, so theProgram.csanchors in the 2026-09-07 and 2026-09-19 revisions point at other code today. The host registersAddCommonBlazorCspandAddCommonSecurityHeaders(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:177-178), callsapp.UseCommonUiForwardedHeaders()first (:192), thenUseCommonSecurityHeaders()(:198) and laterUseHttpsRedirection()(:216); the HSTS andIsHttpsrationale is in the comments at:171-176,:182-186and:194-197. The helper wrapsUseForwardedHeaders(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/CommonForwardedHeadersExtensions.cs:24-25) overCommonForwardedHeaders.Create(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/CommonForwardedHeaders.cs:42-53), which honorsXForwardedFor | XForwardedProto | XForwardedHost(:33-34) and clearsKnownProxiesandKnownIPNetworks(:49-50, rationale:14-20). Behavior is unchanged. The Gateway carries the same values in its own dependency-free copy (:23-24), so the three hosts agree by value, not through one shared object. - Two frame-src anchors in the 2026-09-19 revision resolve against the wrong file. The
frame-srcemission isBlazorCspPolicyProvider.cs:85-97and the canonicalize-and-dedupe step isBlazorCspPolicyProvider.cs:92-94, not lines ofBlazorCspSettings.csorDependencyInjection.cs.
Revision (2026-10-06)
- The credential-path cache posture is stronger than the 2026-09-07 revision recorded. The
value is the constant
CredentialCacheControl(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:156), written byApplyCredentialCacheHeaders(:246-250), which also setsPragma: no-cache(:249). The middleware applies it on a matching path (:197-199) and re-applies it in aResponse.OnStartingcallback (:205-211), because the Razor Components endpoint writes its own weakerCache-Controlwhile rendering and the callback settles the value last.CredentialPathPrefixes(:48, defaults unchanged) carries[LeadingSlashPathPrefixes](:47) andAddCommonSecurityHeadersregisters the settings withValidateDataAnnotations().ValidateOnStart()(:276-277), so a malformed prefix fails the boot instead of every request. The match is the segment-basedIsCredentialPath(:179-182, used at:192and:197), andno-referreris at:193. - Current homes of facts recorded in earlier revisions. The static baseline with its explicit
object-src 'none'isSecurityHeaders.cs:67-69.AddCommonBlazorCspisMMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:67-78(ValidateOnStartat:69-71, validator at:74-75). In the ADC Blazor host (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs),AddCommonBlazorCspandAddCommonSecurityHeadersare at:192-193,UseCommonUiForwardedHeaders()at:207,UseCommonSecurityHeaders()at:213andUseHttpsRedirection()at:231, with the rationale comments at:181-191,:197-206and:209-212;UseUiRateLimiting()(:218, ADR-019) now sits between the security headers and the HTTPS redirect. The ordering is unchanged. - Anchors in the live sections were re-verified against current source; the Trade-offs anchors moved.
Revision (2026-10-07)
Re-verified against current source. No decision, rationale or trade-off changed, and the live-section
anchors (SecurityHeaders.cs:288, :265-266, :83-84; DependencyInjection.cs:77, :56-58) still
hold. What moved is the ADC Blazor host: its Program.cs lines shifted after the 2026-10-06
revision, so the anchors that revision recorded for it are stale, and one validator anchor from the
2026-09-19 revision is stated more precisely here.
- The
frame-srcvalidator checks are spread over three places, not one.ForbiddenCharacters(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspSettingsValidator.cs:19) refuses only*,',",;,,,@,?and#(the last three cover user info, query and fragment). Whitespace is refused separately (:43), the https scheme is required at:52and the root-only path at:54, all insideIsValidOrigin(:40-55). The refused set the 2026-09-19 revision lists is unchanged. - Anchors re-verified against current source: in the ADC Blazor host
(
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs),AddCommonBlazorCspis at:194andAddCommonSecurityHeadersat:195,UseCommonUiForwardedHeaders()at:210,UseCommonSecurityHeaders()at:216,UseUiRateLimiting()at:221andUseHttpsRedirection()at:234, with the rationale comments at:183-193(CSP and HSTS),:199-209(forwarded headers),:212-215(security headers, noUseHsts) and:218-220(rate limiter). The ordering is unchanged: forwarded headers, then security headers, then rate limiting, then the HTTPS redirect.
Related
ADR-019 (rate limiting, the other always-on edge protection living in the same Aspire layer), ADR-022 (browser session-cookie auth, the other browser-edge security control), ADR-008 (the gateway topology whose Gateway and UI hosts are where these headers are stamped).