Architecture Decision Record
ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy
Status
Accepted (2026-08-14).
Context
Both deployed applications put a YARP gateway in front of per-module service hosts (ADR-008), and the browser and MAUI clients talk to the gateway origin while the services answer on their own internal origins. That makes CORS an edge decision taken in two different kinds of host: a service host that owns controllers and therefore knows exactly which headers and methods its API accepts, and a reverse proxy that owns no API surface at all and has to relay whatever a client sends to the service behind it.
Those two hosts cannot run the same policy. A proxy that allow-lists headers would have to enumerate every header any fronted service will ever accept, and would break the next endpoint that introduces one. A service host that allowed any header would be giving away precision it actually has.
CORS already appears in the record twice, but only in passing: ADR-008 lists it as gateway
middleware and as a reason the gateway exists
(Website/docs-src/adr/008-service-extraction-topology.md:34, :49), and ADR-058 pins its test
hosts to Production so the restrictive branch is the one under test
(Website/docs-src/adr/058-runtime-conformance-suites-as-a-package.md:58-60). Its edge siblings
each have their own record: ADR-023 for the security-response headers and ADR-019 for rate limiting
and the forwarded-header trust that feeds it. This ADR records the posture itself.
Decision
Ship two cross-origin policies from the framework: an allow-listed one for service hosts and a deliberately broader one for gateways.
- Service hosts register two named policies from one call.
AddCommonCors(IConfiguration)(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:543) adds_allowSpecificOrigins(:32,:547) and_allowAll(:35,:556). Neither is the default policy, so nothing applies until the pipeline names one. - The service policy allow-lists origins, headers and methods, and allows credentials. Origins
come from
Cors:AllowedOrigins(:549), headers are the four the APIs actually use (Content-Type,Authorization,x-signalr-user-agent,x-requested-with,:551), methods are five explicit verbs (GET,POST,PUT,DELETE,PATCH,:552), andAllowCredentials()(:553) is what lets cookie and bearer traffic cross. Thex-signalr-user-agententry is load-bearing rather than decorative: it plus credentials is what allows the SignalR hub to negotiate cross-origin with a bearer token (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:150-154). - The environment picks between the two policies in the shared middleware pipeline, not at
registration.
UseCommonMiddlewarePipelinecallsapp.UseCors(...)withCorsPolicyAllowAllwhenapp.Environment.IsDevelopment()andCorsPolicyAllowSpecificOriginsotherwise (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:93-95), positioned afterUseRouting()(:92) and beforeUseAuthentication()(:96). Registration stays environment-agnostic; one line decides the posture. - The gateway gets a different policy, and it is the default policy.
AddCommonGatewayCors(IConfiguration, IHostEnvironment)(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/GatewayCorsExtensions.cs:24) lives in the Aspire hosting package, not in the API package, and callsAddDefaultPolicy(:37,:48) so a gateway pairs it with a bareapp.UseCors(). - The gateway policy restricts origins only. Outside Development it is
WithOrigins(Cors:AllowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials()(GatewayCorsExtensions.cs:45-52). A reverse proxy must pass arbitrary client headers through to the services it fronts, so origin is the one axis it can still constrain while keeping credentials flowing. - Both tiers carry a Development-only allow-any-origin branch behind an S5122 suppression. The
service
_allowAllpolicy isAllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()under#pragma warning disable S5122whose comment names the pipeline line that gates it (WebApplicationBuilderExtensions.cs:555-560); the gateway's Development branch is the same shape under the same suppression (GatewayCorsExtensions.cs:34-41), selected byenvironment .IsDevelopment()at registration time because the gateway has only one policy slot. - Allowed origins are configuration, empty by default, filled at deploy time. Every host ships
"Cors": { "AllowedOrigins": [] }(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:10-11,MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:11,MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/appsettings.json:11,MMCA.Store/Source/Services/MMCA.Store.Sales.Service/appsettings.json:22), and Bicep injects the real value into the gateway container of both applications asCors__AllowedOrigins__0, pointing at the UI container app's FQDN (MMCA.ADC/infra/main.bicep:1665,MMCA.Store/infra/main.bicep:1363). On Store the same key can also arrive from Key Vault asCors--AllowedOrigins--0, which is why the vault provider is registered before anything reads configuration: the allow-list binds eagerly (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:41-49,:50). - Adoption is complete on both tiers. All seven ADC and Store service hosts call
AddCommonCors(MMCA.ADC.Identity.Service/Program.cs:167,MMCA.ADC.Conference.Service/Program.cs:187,MMCA.ADC.Engagement.Service/Program.cs:165,MMCA.ADC.Notification.Service/Program.cs:154,MMCA.Store.Catalog.Service/Program.cs:140,MMCA.Store.Identity.Service/Program.cs:136,MMCA.Store.Sales.Service/Program.cs:147), as does the Helpdesk reference host (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:32), and every one of the eight then runsUseCommonMiddlewarePipeline()so the selection above applies (MMCA.ADC.Identity.Service/Program.cs:322,MMCA.ADC.Conference.Service/Program.cs:392,MMCA.ADC.Engagement.Service/Program.cs:328,MMCA.ADC.Notification.Service/Program.cs:258,MMCA.Store.Catalog.Service/Program.cs:266,MMCA.Store.Identity.Service/Program.cs:257,MMCA.Store.Sales.Service/Program.cs:275,MMCA.Helpdesk.Web/Program.cs:111). Both gateways callAddCommonGatewayCorsand the bareapp.UseCors()(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:82,:120;MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:64,:143).
The Blazor UI hosts register neither call: they serve their own origin and have no cross-origin API surface, so there is no third tier.
Rationale
- A proxy cannot allow-list what it does not own. The gateway has no controllers and no
knowledge of which headers the fronted services accept, so a header allow-list there would be a
guess that silently breaks the next endpoint. Origin is the axis the proxy genuinely knows, and
restricting it is what keeps
AllowCredentials()meaningful. - A service host can be precise, so it is. Four headers and five methods is a real reduction of the preflight-accepted surface, and it costs nothing where the API surface is known.
- Named versus default policy follows from the count. The service tier needs two policies, so
they must be named and selected explicitly; the gateway needs one, so the default policy plus a
bare
UseCors()is the smaller thing to get wrong. - Deciding in the pipeline keeps registration environment-free.
AddCommonCorstakes noIHostEnvironmentand no host passes one: the single decision point lives beside the other ordering decisions inUseCommonMiddlewarePipeline, where ADR-019's rate limiter and the auth middleware are also placed. - Credentials forbid a wildcard origin. Any policy that allows credentials has to enumerate
origins, which is exactly why both production branches read
Cors:AllowedOriginsand only the Development branches useAllowAnyOrigin()(which drops credentials with it).
Trade-offs
- The gateway policy is broad on two of three axes. Any header and any method are accepted for
an allow-listed origin. The origin list is the only lever there, so a mistake in
Cors:AllowedOriginson a gateway is a bigger mistake than the same error on a service host. - The allow-any-origin branch ships in production binaries. Both tiers keep a policy that
allows every origin and gate it solely on
IHostEnvironment.IsDevelopment(). Anything that boots one of these hosts withASPNETCORE_ENVIRONMENT=Developmenton a reachable network gets the open policy, and the S5122 suppressions (WebApplicationBuilderExtensions.cs:555,GatewayCorsExtensions.cs:36) mean the analyzer will not say so again. The compensating control is ADR-058'sProductionHostApplicationFactory, which pinsUseEnvironment("Production")so conformance runs exercise the restrictive branch. - The service allow-list is a framework edit, not a host setting. Headers and methods are
hardcoded in
AddCommonCors(:551-552), so a service that needs a sixth verb or a fifth header needs an MMCA.Common change and a lockstep version bump (ADR-016), not an appsettings entry. That is the deliberate direction of the trade (precision over per-host configurability), but it does make the cheap change the expensive one. - Origins are a deploy-time responsibility with no startup validation.
Cors:AllowedOriginsis read with a rawconfiguration.GetSection(...).Get<string[]>() ?? []in both registrations (WebApplicationBuilderExtensions.cs:549,GatewayCorsExtensions.cs:45-47); there is no options class, noValidateOnStart, and no entry in the fail-fast configuration contract (ADR-070). A host deployed without the value starts happily and fails closed at the first cross-origin request, which is the safe direction but shows up as a browser console error rather than a boot failure. - Nothing asserts the emitted
Access-Control-*headers. The tests assert the registered policy objects (origins, credentials, and the allow-all shape:MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/WebApplicationBuilderExtensionsTests.cs:197-245), not a real preflight response, so a pipeline-ordering regression that movedUseCorsout of place would not be caught by a CORS test.
Related
ADR-079 (the shared middleware pipeline whose fixed order
places the environment-selected CORS policy between routing and authentication), ADR-008 (the
gateway plus per-module service topology this posture splits along, and the record that
first named CORS as a gateway responsibility), ADR-023 (the security-response headers registered
next to CORS in the same gateway and service pipelines), ADR-019 (rate limiting and the
forwarded-header trust that share the same edge and the same middleware ordering), ADR-058 (the
conformance fixtures that pin Production so the restrictive branch is what runs under test),
ADR-070 (the fail-fast configuration contract that Cors:AllowedOrigins is deliberately not part
of), ADR-016 (why widening the service allow-list is a lockstep framework release).