Skip to content

CORS ​

Every application that imports [HardenedWebModule] runs CorsFilter on every request. The filter answers CORS preflights. In an application that declares no [Cors], it also adds the CORS headers to every response for an allowed origin. [Cors] on a handler, its class or its module limits CORS to the routes it covers, as CORS on some routes describes.

No origin is allowed until the application allows one. The environment variable CORS_ALLOWED_ORIGINS lists the allowed origins, separated by commas. From the solution directory, this command starts the application with one allowed origin:

bash
CORS_ALLOWED_ORIGINS=https://app.example.com dotnet run --project src/Todos.Host

A request from that origin gets the CORS headers:

http
GET /todos/1
Origin: https://app.example.com

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: X-Correlation-Id, WWW-Authenticate
Vary: Origin

{"id":1,"title":"Read the generated code","done":true}

The startup notice ​

An application that declares no [Cors] and allows no origin logs a notice at startup. The notice is at Information, in the category Hardened.Web.Runtime.Cors.CorsStartupService. The template allows no origin, so a new application logs the notice on its first run:

console
$ dotnet run --project src/Todos.Host
info: Hardened.Web.Runtime.Cors.CorsStartupService[0] CORS is registered with no allowed origins, so every cross-origin request will be refused. Set CORS_ALLOWED_ORIGINS or call AllowOrigin to configure it.
info: Todos.Host[0] Listening on http://localhost:5080

With no origin allowed, the filter is still installed. It refuses every cross-origin request.

The applicationThe notice
Allows an origin through CORS_ALLOWED_ORIGINSNot logged
Allows an origin in a configuration registered in ConfigureServicesNot logged
Allows an origin from a startup service that calls AllowOrigin on the registered configurationLogged. The origin is still allowed for requests
Declares [Cors] on a route or a moduleNot logged, whether or not an origin is allowed

Allowing origins in code shows a configuration registered in ConfigureServices.

A logging filter at Warning for the category hides the notice. The filter goes where src/Todos.Host/Program.cs configures logging, and that differs by host. Under the Kestrel and Cloud Run hosts, this excerpt replaces the template's services.AddLogging(...) line:

csharp
using Microsoft.Extensions.Logging;

services.AddLogging(logging => logging
    .AddSimpleConsole(options => options.SingleLine = true)
    .AddFilter("Hardened.Web.Runtime.Cors.CorsStartupService", LogLevel.Warning));

The Lambda host's services.AddLogging(...) line keeps its AddLambdaLogger call and takes the same AddFilter after it.

The ASP.NET Core host's Program.cs has no services.AddLogging(...) line. The filter goes on builder.Logging, after the template's WebApplication.CreateBuilder(args) line:

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Logging.AddFilter("Hardened.Web.Runtime.Cors.CorsStartupService", LogLevel.Warning);

Allowing origins with CORS_ALLOWED_ORIGINS ​

An entry in CORS_ALLOWED_ORIGINS takes one of three forms:

EntryAllowsRefuses
https://partner.testThat origin, in any letter case, with or without a trailing /http://partner.test and https://partner.test:8443
*.example.comEvery subdomain of example.com, at any depth, with any scheme and any port: https://app.example.com, https://eu.app.example.com, http://app.example.com, https://app.example.com:8443https://example.com, https://notexample.com
*Every originNothing

Spaces around an entry are ignored. Empty entries are skipped. A variable that is unset, blank or holds only commas allows nothing. The variable is read once, when the configuration is built at startup.

Access-Control-Allow-Origin repeats the origin as the request sent it. With CORS_ALLOWED_ORIGINS='*', the responses carry Access-Control-Allow-Origin: *:

http
GET /todos/1
Origin: https://anything.test

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: X-Correlation-Id, WWW-Authenticate
Vary: Origin

{"id":1,"title":"Read the generated code","done":true}

Allowing origins in code ​

CorsConfiguration holds the settings. It is in the Hardened.Web.Runtime.Cors namespace. A configuration that the application registers in ConfigureServices replaces the one that the web module builds, whether the library module or the host's application module registers it. A library's registration also reaches its tests, whose entry point is the library. The file src/Todos.Host/ApplicationCors.cs registers one on the host project's application module:

csharp
using DependencyModules.Runtime.Interfaces;
using Hardened.Web.Runtime.Cors;
using Microsoft.Extensions.DependencyInjection;

namespace Todos.Host;

public partial class Application : IServiceCollectionConfiguration
{
    public void ConfigureServices(IServiceCollection services)
    {
        var cors = new CorsConfiguration { AllowCredentials = true };

        cors.LoadFromEnvironment();
        cors.AllowOrigin("https://app.example.com");
        cors.AllowOriginSuffix("example.org");
        cors.AllowHeader("X-Request-Id");
        cors.ExposeHeader("Location");

        services.AddSingleton(cors);
    }
}

The web module's configuration is the one that reads CORS_ALLOWED_ORIGINS. A configuration that the application registers reads the variable only when the application calls LoadFromEnvironment(), as the file above does.

WARNING

A configuration registered in code without LoadFromEnvironment() refuses the origins in CORS_ALLOWED_ORIGINS. Nothing is logged when the code allows an origin of its own.

With that file, a preflight from https://shop.example.org that names x-request-id is allowed:

http
OPTIONS /todos
Origin: https://shop.example.org
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, x-request-id

HTTP/1.1 204 No Content
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: content-type, x-request-id
Access-Control-Allow-Methods: POST
Access-Control-Allow-Origin: https://shop.example.org
Access-Control-Max-Age: 86400
Vary: Origin

The response to the POST carries Access-Control-Allow-Credentials: true and exposes Location:

http
POST /todos
Origin: https://shop.example.org
Content-Type: application/json
X-Request-Id: 7

{"title":"Write the docs"}

HTTP/1.1 201 Created
Content-Type: application/json
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://shop.example.org
Access-Control-Expose-Headers: X-Correlation-Id, WWW-Authenticate, Location
Location: /todos/3
Vary: Origin

{"id":3,"title":"Write the docs","done":false}

The configuration has these members:

MemberDefaultDoes
AllowOrigin(origin)Allows one origin, as an exact entry in CORS_ALLOWED_ORIGINS does
AllowOriginSuffix(domain)Allows every subdomain of domain, as *.domain does
AllowAnyOriginfalseAllows every origin, as * does
AllowCredentialsfalseSends Access-Control-Allow-Credentials: true
AllowHeader(header)Adds a request header a preflight may name
ExposeHeader(header)Adds a response header a script may read
ClearExposedHeaders()Removes every exposed header, X-Correlation-Id and WWW-Authenticate included
MaxAgeSec86400How long a browser may keep a preflight's answer, in seconds. Sent as Access-Control-Max-Age
FallbackMethodsGET, POST, PUT, DELETE, OPTIONSThe methods a preflight for a path with no route is told, in an application that declares no [Cors]
EnvironmentVariableCORS_ALLOWED_ORIGINSThe variable LoadFromEnvironment() reads origins from
HeadersEnvironmentVariableCORS_ALLOWED_HEADERSThe variable LoadFromEnvironment() reads allowed request headers from, separated by commas
LoadFromEnvironment()Adds the entries of both variables, the origins in the three forms of the previous section

Cross-origin requests ​

CORS on some routes covers the routes of an application that declares [Cors]. In an application that declares no [Cors], a request gets one of these answers:

The requestAnswer
With an Origin header from an allowed originAccess-Control-Allow-Origin and Access-Control-Expose-Headers. The handler runs
From a refused originNo CORS headers. The handler still runs
Without an Origin header, once an origin is allowedNo CORS headers. Vary: Origin
Without an Origin header, with no origin allowedNo CORS headers and no Vary: Origin

With CORS_ALLOWED_ORIGINS=https://app.example.com, a request from another origin still adds a todo:

http
POST /todos
Origin: https://evil.example.net
Content-Type: application/json

{"title":"Posted from elsewhere"}

HTTP/1.1 201 Created
Content-Type: application/json
Location: /todos/3
Vary: Origin

{"id":3,"title":"Posted from elsewhere","done":false}

The filter runs for every request, before routing. A 404 and a 401 to an allowed origin carry the CORS headers.

An OPTIONS request without Access-Control-Request-Method is not a preflight. It is routed like any other request, as Routing describes.

Preflights ​

An OPTIONS request that carries Access-Control-Request-Method is a preflight. The filter answers it with 204 and no body. A preflight never reaches routing, authorization or a handler. A preflight for a route that requires a caller is answered like any other.

In an application that declares no [Cors], a preflight gets one of these answers:

The preflightAnswer
From an allowed origin, for a method the path has a route for, naming allowed headers204 with the CORS headers
For a path with no route204 with the CORS headers. Access-Control-Allow-Methods is FallbackMethods
From a refused origin204 with no CORS headers
For a method the path has no route for204 with no CORS headers
Naming a header that is not allowed204 with no CORS headers

An allowed preflight gets Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Max-Age. It also gets Access-Control-Allow-Headers when it named headers. Access-Control-Allow-Methods holds the requested method alone, when the path has a route for it. Access-Control-Allow-Headers repeats the requested headers as the request wrote them. With CORS_ALLOWED_ORIGINS=https://app.example.com, a preflight from that origin gets these headers:

http
OPTIONS /todos
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: POST
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Max-Age: 86400
Vary: Origin

Every requested header must be allowed, or the whole preflight is refused. The allowed request headers are Authorization, Content-Type, Accept, x-auth-token and x-amz-content-sha256. The filter compares them without regard to case. AllowHeader adds one, and so does an entry in CORS_ALLOWED_HEADERS, such as CORS_ALLOWED_HEADERS=X-Request-Id,X-Tenant.

A preflight also allows the headers the operation it asks about reads: a parameter bound from a header, such as [FromHeader("X-Tenant")] or a contract's @httpHeader, and a header a filter reads, such as the If-None-Match of [ConditionalGet]. A contract that requires a header on every call is therefore reachable from a browser without listing the header again.

A refused preflight is still a 204, with Vary: Origin and no CORS headers:

http
OPTIONS /todos
Origin: https://evil.example.net
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Vary: Origin

Exposed headers ​

Access-Control-Expose-Headers lists X-Correlation-Id and WWW-Authenticate by default. Every response carries an X-Correlation-Id header. A 401 carries a WWW-Authenticate challenge, and a script reads its error_description to tell an expired token from a missing one. ExposeHeader adds a header to the list. ClearExposedHeaders empties the list. Access-Control-Expose-Headers is then not sent. The list goes on responses to actual requests. A preflight does not carry it.

Credentials ​

AllowCredentials = true adds Access-Control-Allow-Credentials: true to the preflight and to the response to the actual request. CORS_ALLOWED_ORIGINS has no form for credentials. Only a configuration in code sets them.

When AllowAnyOrigin is also true, the filter sends the request's origin in place of *. It also leaves out Access-Control-Allow-Credentials.

The Vary header ​

In an application that declares no [Cors], every response to a request with an Origin header carries Vary: Origin, whether the origin is allowed or refused. A preflight's 204 carries it too. Once an origin is allowed, a response to a request without an Origin header carries it as well.

Once a route declares [Cors], every response of a declaring route carries Vary: Origin, with or without an Origin header, and so does every preflight. A route with no declaration does not.

A response that depends on Origin has to say so even when the request sent none. A shared cache could otherwise store the answer to such a request, which has no CORS headers, and serve it to a browser that sends one.

The filter adds Origin to the Vary values that other filters write. A compressed response carries Vary: Origin, Accept-Encoding. Compression covers when a response is compressed.

CORS on some routes ​

[Cors] is in Hardened.Web.Runtime.Cors. It goes on a handler method, a controller class or a [HardenedModule] class. It takes no arguments. An application that declares [Cors] nowhere keeps CORS on every request, as the sections above describe. Once a route or a module declares [Cors], CORS covers only the routes that a declaration reaches:

DeclarationCovers
On a handler methodThat handler
On a controller classEvery handler in the class
On a [HardenedModule] classEvery handler compiled in the module's project
A route registered through IRouteRegistryWhatever its handler declares: on the controller method and its class, or on the lambda

Registered routes covers routes registered through that interface.

The nearest declaration takes precedence: a method's over its class's, and either over the module's. This also holds between [Cors] and [Cors<TPolicy>], which A policy for some routes describes.

The template's src/Todos.Host project compiles no handler. [Cors] on its Application covers no route. With that declaration alone, every route answers with no CORS headers. Every preflight is refused. The build reports nothing, and nothing is logged.

A route that [Cors] covers answers cross-origin requests with the application's configuration. The web module builds that configuration from CORS_ALLOWED_ORIGINS, unless the application registers its own as Allowing origins in code shows.

This excerpt of src/Todos/TodoController.cs adds the using line and the two [Cors] lines to the template's file:

csharp
using Hardened.Requests.Abstract.Responses;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Cors;
using Hardened.Web.Runtime.Responses;
using ValidationModules.Constraints;

namespace Todos;

public class TodoController
{
    [Cors]
    [Operation("listTodos")]
    [Get("/")]
    public Task<IReadOnlyList<Todo>> All(ITodoStore store) => store.All();

    [Cors]
    [Operation("getTodo")]
    [Get("/{id}")]
    public async Task<Response<Todo, NotFound>> ById(ITodoStore store, [Range(Min = 1)] int id)
    {
        var todo = await store.Find(id);

        if (todo is null)
        {
            return new NotFound("todo", $"No todo has id {id}.");
        }

        return todo;
    }
}

With CORS_ALLOWED_ORIGINS=https://app.example.com, GET /todos/1 from https://app.example.com answers as in the first example on this page. Create answers POST /todos and declares nothing. Its preflight is refused:

http
OPTIONS /todos
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Vary: Origin

Its response carries no CORS headers and no Vary:

http
POST /todos
Origin: https://app.example.com
Content-Type: application/json

{"title":"Write the docs"}

HTTP/1.1 201 Created
Content-Type: application/json
Location: /todos/3

{"id":3,"title":"Write the docs","done":false}

Once a route declares [Cors], a request gets one of these answers:

The requestAnswer
To a declaring route, from an origin its configuration allowsThe CORS headers of Cross-origin requests, and Vary: Origin
To a declaring route, from an origin its configuration refusesVary: Origin alone
To a route with no declarationNo CORS headers and no Vary: Origin
To a path no route answers, or with a method the path has no route for404 or 405, with no CORS headers and no Vary: Origin
A preflight for a declaring route, from an allowed origin, naming allowed headers204 with the CORS headers
A preflight for a route with no declaration, or for a path no route answers204 with Vary: Origin alone

The filter still answers every preflight, before routing. It answers with the configuration of the route that the preflight asks about. It does not use FallbackMethods for a path that no route answers. The 405 for an OPTIONS request without Access-Control-Request-Method carries no CORS headers either.

The filter that [Cors] installs runs before rate limiting, authorization and validation. A 401, a 429 and a validation 400 on a declaring route carry the CORS headers. The execution pipeline gives the order of the filters.

A policy for some routes ​

[Cors<TPolicy>] is also in Hardened.Web.Runtime.Cors. It covers routes as [Cors] does. Its routes answer with the configuration that AddCorsPolicy<TPolicy> registered, in place of the application's. TPolicy can be any type. It only names the policy.

AddCorsPolicy<TPolicy>(configure) is in the same namespace. It is an extension method on IServiceCollection. It passes a new configuration to configure and registers it. A new configuration allows no origin. It allows the five request headers of Preflights. It exposes X-Correlation-Id and WWW-Authenticate. Its MaxAgeSec is 86400. A named policy does not read CORS_ALLOWED_ORIGINS.

In this example, src/Todos/Partners.cs declares the type that names the policy:

csharp
namespace Todos;

public sealed class Partners;

The library module registers the policy in its ConfigureServices. This excerpt of src/Todos/TodosLibrary.cs adds the using line and the AddCorsPolicy line to the template's code:

csharp
using Hardened.Web.Runtime.Cors;

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IJsonTypeInfoResolver>(TodosJsonContext.Default);
    services.AddCorsPolicy<Partners>(policy => policy.AllowOrigin("https://partner.example.com"));
}

In the same src/Todos/TodoController.cs, Create declares the policy:

csharp
[Cors<Partners>]
[Operation("createTodo")]
[Post("/")]
public async Task<Response<Created<Todo>, Conflict>> Create(ITodoStore store, NewTodo request)
{
    if (await store.TitleExists(request.Title))
    {
        return new Conflict($"A todo titled '{request.Title}' already exists.");
    }

    var todo = await store.Add(request.Title);

    return new Created<Todo>(todo, $"/todos/{todo.Id}");
}

With those three changes and CORS_ALLOWED_ORIGINS=https://app.example.com, a preflight from the partner is allowed:

http
OPTIONS /todos
Origin: https://partner.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: POST
Access-Control-Allow-Origin: https://partner.example.com
Access-Control-Max-Age: 86400
Vary: Origin

The partner's request gets the policy's CORS headers:

http
POST /todos
Origin: https://partner.example.com
Content-Type: application/json

{"title":"Write the docs"}

HTTP/1.1 201 Created
Content-Type: application/json
Access-Control-Allow-Origin: https://partner.example.com
Access-Control-Expose-Headers: X-Correlation-Id, WWW-Authenticate
Location: /todos/3
Vary: Origin

{"id":3,"title":"Write the docs","done":false}

A route whose [Cors<TPolicy>] names a policy that nothing registered answers every request with 500 and no body, with or without an Origin header. A preflight for it answers 500 with Vary: Origin. The application still starts. Its other routes answer. Each of those requests logs an InvalidOperationException with this message, where Unregistered is the type argument:

text
[Cors<Unregistered>] names a policy that nothing registered. Register it with AddCorsPolicy<Unregistered>.

Limits ​

No declaration in the application reaches the framework's routes: /health/live, /health/ready, /openapi.json and /docs. Once a route declares [Cors], they answer cross-origin requests with no CORS headers. A preflight for them is refused.

The build does not read a declaration on a lambda registered through IRouteRegistry when it decides whether routes declare CORS. An application whose only declarations are on such lambdas keeps CORS on every request. The filter then answers every preflight with the application's configuration, including a preflight for the lambda's route.

Next ​

PageCovers
AuthenticationThe credentials a cross-origin request carries
The execution pipelineThe middleware chain the filter runs in
ModulesConfigureServices and startup services

Released under the MIT License.