to navigate Enter to open "…" exact phrase ANDOR to combine

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), and AllowCredentials() (:553) is what lets cookie and bearer traffic cross. The x-signalr-user-agent entry 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. UseCommonMiddlewarePipeline calls app.UseCors(...) with CorsPolicyAllowAll when app.Environment.IsDevelopment() and CorsPolicyAllowSpecificOrigins otherwise (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:93-95), positioned after UseRouting() (:92) and before UseAuthentication() (: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 calls AddDefaultPolicy (:37, :48) so a gateway pairs it with a bare app.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 _allowAll policy is AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod() under #pragma warning disable S5122 whose 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 by environment .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 as Cors__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 as Cors--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 runs UseCommonMiddlewarePipeline() 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 call AddCommonGatewayCors and the bare app.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. AddCommonCors takes no IHostEnvironment and no host passes one: the single decision point lives beside the other ordering decisions in UseCommonMiddlewarePipeline, 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:AllowedOrigins and only the Development branches use AllowAnyOrigin() (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:AllowedOrigins on 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 with ASPNETCORE_ENVIRONMENT=Development on 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's ProductionHostApplicationFactory, which pins UseEnvironment("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:AllowedOrigins is read with a raw configuration.GetSection(...).Get<string[]>() ?? [] in both registrations (WebApplicationBuilderExtensions.cs:549, GatewayCorsExtensions.cs:45-47); there is no options class, no ValidateOnStart, 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 moved UseCors out of place would not be caught by a CORS test.

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).