Software Securityhome
Fix a findingSearch
Fix card · A02:2025 Security Misconfiguration

Fix CORS misconfiguration in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Add a named CORS policy with WithOrigins(allowedOrigins) from configuration and AllowCredentials(), call app.UseCors(), and require the policy on the API endpoints. The middleware echoes only listed origins and sends other origins, null included, no CORS headers; add Vary: Origin to every API response yourself. Never use SetIsOriginAllowed(_ => true) or a Contains or EndsWith predicate, which bypass the list.

Stack
  • Language C# 14
  • Runtime .NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12)
  • Framework ASP.NET Core 10 minimal API with the CORS middleware
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks3 cards
Download the project ZIP, 31 KB
On this page

Steps

  1. Find every CORS policy: AddCors, AddPolicy and AddDefaultPolicy calls, [EnableCors] attributes, RequireCors on endpoints, and middleware that sets Access-Control-Allow-Origin by hand.
  2. Put the front ends' origins in configuration (Cors:AllowedOrigins:0, Cors:AllowedOrigins:1 and so on) and stop at startup when the section is missing, rather than falling back to a wildcard.
  3. Build the policy with WithOrigins(allowedOrigins), WithMethods and WithHeaders listing only what the front ends send, AllowCredentials() and SetPreflightMaxAge.
  4. Call app.UseCors() after routing and before UseAuthorization, and attach the policy to the API with RequireCors("api") on the endpoint group or [EnableCors("api")] on controllers.
  5. Remove SetIsOriginAllowed predicates, AllowAnyOrigin() and null, http:// or localhost origins from production policies. If every subdomain really is trusted, use WithOrigins("https://*.example.test") with SetIsOriginAllowedToAllowWildcardSubdomains() rather than a hand-written predicate.
  6. Send Cache-Control: no-store (or private) on per-user API responses, keep CSRF protection on cookie-authenticated writes, and require a JSON body so that cross-origin writes need a preflight.
  7. Add regression tests that send credentialed requests from a foreign origin, Origin: null, look-alike hosts, the http:// version of a front end and a sibling subdomain, and assert that a browser would not share the response.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerablesrc/Invoices/impl/VulnerableCors.csC#
public void AddCors(IServiceCollection services, string[] allowedOrigins) =>
    services.AddCors(options => options.AddPolicy(ICorsSetup.PolicyName, policy => policy
        // AllowAnyOrigin().AllowCredentials() threw "The CORS protocol does not allow specifying
        // a wildcard (any) origin and credentials at the same time."
        .SetIsOriginAllowed(_ => true)
        .AllowAnyMethod()
        .AllowAnyHeader()
        .AllowCredentials()));

With IMPL=vulnerable, 9 of 13 tests fail (8 exploit tests).

The fix

Fixedsrc/Invoices/impl/FixedCors.csC#
public void AddCors(IServiceCollection services, string[] allowedOrigins) =>
    services.AddCors(options => options.AddPolicy(ICorsSetup.PolicyName, policy => policy
        // https://portal.example.test and https://admin.example.test, from configuration.
        // Exact, ordinal match on scheme, host and port: no "*", no "null" and no SetIsOriginAllowed.
        .WithOrigins(allowedOrigins)
        .WithMethods("GET", "PUT")
        .WithHeaders("Content-Type")
        .AllowCredentials()
        .SetPreflightMaxAge(TimeSpan.FromMinutes(10))));

public void UseCors(IApplicationBuilder app)
{
    // The CORS middleware adds Vary: Origin only when it grants a policy with several origins.
    // Refusals and requests without Origin vary on it too, so a shared cache never replays a
    // refusal to a listed origin, or a grant to another. This callback is registered before
    // the CORS middleware's, so it runs after it and adds Vary only where it is missing.
    app.Use((context, next) =>
    {
        context.Response.OnStarting(() =>
        {
            var headers = context.Response.Headers;
            if (!headers.GetCommaSeparatedValues("Vary").Contains("Origin", StringComparer.OrdinalIgnoreCase))
            {
                headers.Append("Vary", "Origin");
            }
            return Task.CompletedTask;
        });
        return next(context);
    });
    app.UseCors();
}

With IMPL=fixed, 13 of 13 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Invoices/impl/WrongCors.csC#
public void AddCors(IServiceCollection services, string[] allowedOrigins) =>
    services.AddCors(options => options.AddPolicy(ICorsSetup.PolicyName, policy => policy
        .SetIsOriginAllowed(origin => origin.Contains("example.test")) // portal, admin and whatever comes next
        .WithMethods("GET", "PUT")
        .WithHeaders("Content-Type")
        .AllowCredentials()
        .SetPreflightMaxAge(TimeSpan.FromMinutes(10))));

With IMPL=wrong, 6 of 13 tests fail (5 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Invoices.Tests/ExploitTests.csC#
/// <summary>
/// Exploit: each request is what a page on the attacker's origin gets from
/// fetch("https://api.example.test/api/invoices", {credentials: "include"}): the browser attaches
/// Alice's cookie, and the API's CORS headers decide whether the page may read the reply.
/// </summary>
public sealed class ExploitTests(InvoicesApiFactory factory) : CorsTests(factory)
{
    [Fact]
    public async Task AnArbitraryOriginCannotReadAlicesInvoices() =>
        AssertNotShared(await ReadInvoices(Evil), Evil);

    [Fact]
    public async Task OriginNullFromASandboxedIframeOrDataUrlCannotReadThem() =>
        AssertNotShared(await ReadInvoices("null"), "null");

    [Fact]
    public async Task ADomainThatMerelyEndsWithExampleTestCannotReadThem() =>
        AssertNotShared(await ReadInvoices(SuffixLookalike), SuffixLookalike);

    [Fact]
    public async Task ADomainThatStartsWithThePortalsOriginCannotReadThem() =>
        AssertNotShared(await ReadInvoices(PrefixLookalike), PrefixLookalike);

    [Fact]
    public async Task AHostThatMatchesOnlyBecauseARegexDotMatchesAnyCharacterCannotReadThem() =>
        AssertNotShared(await ReadInvoices(DotLookalike), DotLookalike);

    [Fact]
    public async Task ThePortalsHostOverPlainHttpCannotReadThem() =>
        // Anyone on the network path can inject script into an http:// page.
        AssertNotShared(await ReadInvoices(PlainHttpPortal), PlainHttpPortal);

    [Fact]
    public async Task ASiblingSubdomainThatIsNotOnTheListCannotReadThem() =>
        // One XSS or subdomain takeover on forum.example.test would otherwise expose the API.
        AssertNotShared(await ReadInvoices(Sibling), Sibling);

    [Fact]
    public async Task AnArbitraryOriginsPreflightIsRefusedSoItsCredentialedUpdateIsNeverSent()
    {
        var result = await ChangeEmail(Evil, "mallory@evil.example");
        Assert.True(Accounts.EmailOf("alice") == "alice@example.test",
            $"the cross-origin PUT changed Alice's email ({Browser.DescribeCors(result.Response)})");
        Assert.False(result.Sent, $"the browser would send the PUT after this preflight ({Browser.DescribeCors(result.Response)})");
    }
}

Pitfalls

  • AllowAnyOrigin() with AllowCredentials() throws InvalidOperationException when the app starts ("The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time"). SetIsOriginAllowed(_ => true) gets past that check and echoes every origin with credentials, Origin: null included; Microsoft's CORS documentation calls it an insecure configuration, and it is this card's vulnerable implementation.
  • SetIsOriginAllowed replaces the exact comparison: once it is set, the predicate alone decides, so origin.Contains("example.test") or new Uri(origin).Host.EndsWith("example.test") accept look-alike domains and http:// origins whatever WithOrigins lists.
  • WithOrigins lowercases the configured values and compares the request's Origin with them ordinally, so list origins exactly as browsers send them: scheme and host, a port only when it is not the default, and no trailing slash, which never matches.
  • The middleware adds Vary: Origin to responses it allows only when the policy lists more than one origin or uses a predicate; with a single origin it adds none, and refused origins and requests without Origin never get it. A cache that stores such a response can replay it to a listed front end, so the fixed code registers a Response.OnStarting callback before UseCors that adds Vary: Origin where the CORS middleware did not. Mark per-user responses Cache-Control: no-store or private.
  • A request from a refused origin still runs the endpoint and gets its normal response without CORS headers, and a refused preflight gets 204 without CORS headers; the browser enforces the refusal. Authorization stays in the endpoint, because non-browser clients ignore CORS.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs cors-misconfiguration/csharp-aspnetcore does this for you).
  2. Against your own API, replay a credentialed request with a foreign origin, for example curl -i -H 'Origin: https://evil.example' -b '.AspNetCore.Cookies=...' https://your.api/api/invoices, and expect neither Access-Control-Allow-Origin nor Access-Control-Allow-Credentials; repeat with Origin: null, a look-alike host, the http:// version of your front end and a preflight (-X OPTIONS -H 'Access-Control-Request-Method: PUT').
  3. Grep for SetIsOriginAllowed, AllowAnyOrigin and Access-Control-Allow-Origin: Sonar's S5122 catches AllowAnyOrigin() and "*" but not predicates, and neither Semgrep nor CodeQL has a C# CORS rule.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix CORS misconfiguration in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepn/aSemgrep CE has no C# CORS rule: no file under csharp/ in semgrep-rules mentions CORS, and p/security-audit and p/csharp reported nothing on src/.Checked: Semgrep 1.180.0 (CE), semgrep-rules develop at 9bbf021
CodeQLn/aCodeQL has no C# CORS query: the repository's CORS queries cover JavaScript, Python, Java and Go only.Checked: github/codeql main at b5b8165
SonarAnalyzer.CSharp (NuGet)S5122Cross-Origin Resource Sharing (CORS) policy should be restricted to trusted originsReported only the probe file's AllowAnyOrigin(); no findings on VulnerableCors.cs, WrongCors.cs or FixedCors.cs. The rule tracks AllowAnyOrigin(), WithOrigins("*"), new CorsPolicyBuilder("*"), [EnableCors("*")] and Access-Control-Allow-Origin headers set to "*"; it does not inspect SetIsOriginAllowed predicates.Checked: SonarAnalyzer.CSharp 10.34.0.3385 on a copy of src/ with one added probe file, dotnet build
.NET code analysisn/aNo built-in CA rule for CORSThe build raised only general design and globalization warnings (CA1515, CA1034, CA1307 and similar); none concerns CORS.Checked: .NET SDK 10.0.107 with -p:AnalysisMode=All -p:AnalysisModeSecurity=All

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC# 14
Runtime.NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12)
FrameworkASP.NET Core 10 minimal API with the CORS middleware
Microsoft.AspNetCore.App (shared framework: Microsoft.AspNetCore.Cors)10.0.12 at run time
Microsoft.AspNetCore.Mvc.Testing10.0.12
xunit.v34.0.1 (Microsoft Testing Platform 2.4.0)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the cors-misconfiguration/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Invoices.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/Invoices.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Invoices.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: a test fails
IMPL=wrong dotnet test --project tests/Invoices.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs cors-misconfiguration/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 31 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • dotnetSdk 10.0.112
  • aspNetCoreRuntime 10.0.12
  • mvcTesting 10.0.12
  • xunitV3 4.0.1
  • microsoftTestingPlatform 2.4.0
Matrix
fixed: 13 of 13 tests pass; vulnerable: 9 of 13 tests fail (8 exploit tests); wrong: 6 of 13 tests fail (5 exploit tests)
Test results
fixed 13/13 pass; vulnerable 9/13 fail (all 8 exploit tests and the Vary test); wrong 6/13 fail (5 exploit tests and the Vary test), offline after install
Dependency freshness
Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) is the current 10.0.x patch. xunit.v3 4.0.1 (2026-09-12) is used rather than 4.0.2 (2026-10-09), a bug-fix release under 14 days old. dotnet list package --vulnerable --include-transitive found nothing. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.
Notes
CORS behaviour checked with probe apps on ASP.NET Core 10.0.7 and again on 10.0.12: AllowAnyOrigin().AllowCredentials() threw InvalidOperationException at startup (the .NET 10 CORS article says the CORS service returns an invalid CORS response; the policy builder's check fires first); SetIsOriginAllowed(_ => true) echoed Origin: null with credentials and Vary: Origin; the fixed policy sent nothing to a refused origin (200 from the endpoint) or a refused preflight (204); a single WithOrigins origin got no Vary: Origin; WithOrigins("https://*.example.test") with SetIsOriginAllowedToAllowWildcardSubdomains() accepted https://forum.example.test and refused https://portal.example.test.evil.example. The test factory's UseSetting values for Cors:AllowedOrigins were visible to Program.cs before Build().

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. Enable Cross-Origin Requests (CORS) in ASP.NET Core (.NET 10)
  2. ASP.NET Core 10.0.12 source: CorsService (origin evaluation and Vary)
  3. ASP.NET Core 10.0.12 source: CorsPolicyBuilder (WithOrigins normalization, the wildcard and credentials check)
  4. WHATWG Fetch Standard: CORS protocol and the CORS check
  5. MDN: Cross-Origin Resource Sharing (CORS)
  6. Sonar rule S5122: CORS policy should be restricted to trusted origins (sonar-dotnet)
  7. PortSwigger Web Security Academy: CORS

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.