File: Internal\DefaultCsrfProtection.cs
Web Access
Project: src\aspnetcore\src\DefaultBuilder\src\Microsoft.AspNetCore.csproj (Microsoft.AspNetCore)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Antiforgery;

internal sealed class DefaultCsrfProtection : ICsrfProtection
{
    // Safe HTTP methods that do not require cross-origin validation (RFC 7231).
    private static readonly HashSet<string> SafeMethods = new(StringComparer.OrdinalIgnoreCase)
    {
        HttpMethods.Get,
        HttpMethods.Head,
        HttpMethods.Options,
        HttpMethods.Trace,
    };

    /// <inheritdoc />
    public async ValueTask<CsrfProtectionResult> ValidateAsync(HttpContext context)
    {
        ArgumentNullException.ThrowIfNull(context);

        var request = context.Request;

        // Step 1: Safe methods are always allowed.
        if (SafeMethods.Contains(request.Method))
        {
            return CsrfProtectionResult.Allowed();
        }

        // Step 2: Sec-Fetch-Site accept path. The vast majority of legitimate browser
        // traffic to a same-origin endpoint hits this exit without any DI lookup or string parsing.
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site#same-site
        var secFetchSite = request.Headers["Sec-Fetch-Site"].ToString();
        if (secFetchSite is "same-origin" or "none")
        {
            return CsrfProtectionResult.Allowed();
        }

        // Step 3: Check trusted origins from the CORS policy that applies to this request
        // (per-endpoint policy first, falling back to the default policy).
        var origin = request.Headers.Origin.ToString();
        if (!string.IsNullOrEmpty(origin))
        {
            var policy = await ResolveCorsPolicyAsync(context);

            // AllowAnyOrigin is intentionally NOT honored as a CSRF trust signal: it means "any browser
            // can read this resource", which is a different concern than "any origin can mutate it on the
            // user's behalf". Treating AllowAnyOrigin as trusted would turn this middleware into a no-op
            // for cross-origin writes. Users who legitimately need public-read CORS + CSRF-protected writes
            // should rely on Sec-Fetch-Site (modern browsers) / Origin-vs-Host (legacy) — which still apply
            // here — or opt the endpoint out via DisableAntiforgery() if it has no cookie-based auth.
            if (policy is not null && !policy.AllowAnyOrigin && policy.IsOriginAllowed(origin))
            {
                return CsrfProtectionResult.Allowed();
            }
        }

        // Step 4: Sec-Fetch-Site deny path. Any value other than "same-origin"/"none"
        // (e.g. "cross-site", "same-site") is treated as untrusted.
        if (!string.IsNullOrEmpty(secFetchSite))
        {
            return CsrfProtectionResult.Denied();
        }

        // Step 5: No Sec-Fetch-Site header. Fall back to Origin vs Host comparison.
        if (!string.IsNullOrEmpty(origin))
        {
            return OriginMatchesRequestHost(origin, request)
                ? CsrfProtectionResult.Allowed()
                : CsrfProtectionResult.Denied();
        }

        // Step 6: No Sec-Fetch-Site AND no Origin header.
        // This is a non-browser client (curl, Postman, server-to-server).
        // Allow the request — CSRF is a browser-based attack vector.
        return CsrfProtectionResult.Allowed();
    }

    private static async ValueTask<CorsPolicy?> ResolveCorsPolicyAsync(HttpContext context)
    {
        var corsMetadata = context.GetEndpoint()?.Metadata.GetMetadata<ICorsMetadata>();

        // [DisableCors] on the endpoint → no CORS-derived trust applies. Fall through to Sec-Fetch logic.
        if (corsMetadata is IDisableCorsAttribute)
        {
            return null;
        }

        // Inline policy attached to the endpoint (rare, but supported by the CORS metadata model).
        if (corsMetadata is ICorsPolicyMetadata inlinePolicyMetadata)
        {
            return inlinePolicyMetadata.Policy;
        }

        // [EnableCors("name")] selects a named policy; otherwise null → ICorsPolicyProvider falls back
        // to the default policy registered via AddCors(o => o.AddDefaultPolicy(...)).
        var policyName = (corsMetadata as IEnableCorsAttribute)?.PolicyName;
        var provider = context.RequestServices.GetService<ICorsPolicyProvider>();
        if (provider is null)
        {
            return null;
        }

        return await provider.GetPolicyAsync(context, policyName);
    }

    /// <summary>
    /// Compares the Origin header to "scheme://host[:port]" built from the request.
    /// </summary>
    /// <param name="origin">value of Origin header</param>
    /// <param name="request">request being processed</param>
    /// <remarks>
    /// This path only runs for pre-Fetch-Metadata browsers (~2020 and earlier) and
    /// non-browser clients, so the current allocation cost is negligible.
    /// </remarks>
    /// <returns>true if match</returns>
    private static bool OriginMatchesRequestHost(string origin, HttpRequest request)
    {
        var host = request.Host;
        if (!host.HasValue)
        {
            return false;
        }

        // host.Value preserves the raw "host[:port]" form as parsed from the Host header,
        // matching how browsers serialize the Origin header (default ports stripped on both sides).
        return MemoryExtensions.Equals(origin, $"{request.Scheme}://{host.Value}", StringComparison.OrdinalIgnoreCase);
    }
}