Start Debugging

So deaktivieren Sie die Antiforgery-Validierung für einen einzelnen Minimal-API-Formularendpunkt in ASP.NET Core 11

Rufen Sie .DisableAntiforgery() auf dem einen Endpunkt oder auf einer MapGroup auf. In ASP.NET Core 11 nimmt das den Endpunkt sowohl aus der Token-Middleware als auch aus der neuen automatischen CSRF-Prüfung heraus. Gemessene Matrix, Fallen bei der Rangfolge und engere Alternativen.

Kurze Antwort: Hängen Sie .DisableAntiforgery() an den einen MapPost-Aufruf an (oder an eine MapGroup, die nur Machine-to-Machine-Endpunkte enthält). In ASP.NET Core 11 nimmt dieser eine Aufruf den Endpunkt aus beiden Schichten heraus, die einen Formular-Post ablehnen können: der tokenbasierten UseAntiforgery()-Middleware und der neuen automatischen Cross-Origin-CSRF-Prüfung, die WebApplication für Sie einfügt. [RequireAntiforgeryToken(false)] auf dem Handler bewirkt dasselbe. Greifen Sie nicht zum app-weiten Schalter DisableCsrfProtection, um einen einzelnen Endpunkt zu reparieren: In einer App, die nie UseAntiforgery() aufruft, macht er aus jedem anderen Formularendpunkt einen 500.

Alles Folgende wurde mit dem .NET 11 RC 1 SDK gemessen (11.0.100-rc.1.26425.128, ASP.NET Core 11.0.0-rc.1), mit einem Lauf auf .NET 10.0.10 zum Vergleich. Die Anfragematrix, die Fallen bei der Rangfolge und die engeren Alternativen sind der Teil, den die Dokumentation Sie selbst herausfinden lässt.

Warum ein Formularendpunkt unerwartet Anfragen ablehnt

Seit .NET 8 erhält jeder Minimal-API-Handler mit einem formulargebundenen Parameter ([FromForm], IFormFile, IFormCollection) automatisch Antiforgery-Metadaten. Sie sehen das in RequestDelegateFactory.InferAntiforgeryMetadata: Wenn die Factory einen Parameter aus dem Formular bindet, fügt sie dem Endpunkt ein IAntiforgeryMetadata mit RequiresValidation = true hinzu. Sie haben nie ein Attribut geschrieben; der Parametertyp hat es für Sie erledigt.

Was diese Metadaten liest, hat sich in .NET 11 geändert.

In .NET 11 kann ein Formular-Post also auf zwei Arten abgelehnt werden, und sie treffen unterschiedliche Clients. Die Token-Middleware lehnt alles ohne Token ab, einschließlich curl und der Server Ihres Zahlungsanbieters. Die CSRF-Prüfung lässt Nicht-Browser-Clients durch, lehnt aber Cross-Origin-Posts aus dem Browser ab. Der klassische Fall ist eine Drittanbieterseite, die ein HTML-Formular an Sie zurücksendet (eine gehostete Zahlungsseite, ein Callback im SAML-Stil, ein “Senden an”-Formular eines Partners).

Was jeder Client tatsächlich bekommt

Ich habe eine Test-App mit fünf Endpunkten gebaut und jeden mit vier Anfrageformen in vier Pipeline-Konfigurationen aufgerufen. “plain” ist curl ohne Browser-Header, “cross-site” sendet Sec-Fetch-Site: cross-site plus einen fremden Origin, “same-origin” sendet Sec-Fetch-Site: same-origin, und “nur fremder Origin” ahmt einen alten Browser nach, der Origin, aber keine Fetch Metadata sendet.

// .NET 11 RC 1, ASP.NET Core 11.0.0-rc.1
app.MapPost("/protected", ([FromForm] string name) => $"hello {name}");

app.MapPost("/disabled", ([FromForm] string name) => $"hello {name}")
   .DisableAntiforgery();

app.MapPost("/attr",
    [RequireAntiforgeryToken(false)] ([FromForm] string name) => $"hello {name}");

var hooks = app.MapGroup("/hooks").DisableAntiforgery();
hooks.MapPost("/payments", ([FromForm] string name) => $"hello {name}");

app.MapPost("/manual", async (HttpRequest req) =>
    $"hello {(await req.ReadFormAsync())["name"]}");

Standard-Pipeline von .NET 11 (kein AddAntiforgery, kein UseAntiforgery), Production-Umgebung:

Endpunktplaincross-sitesame-originnur fremder Origin
/protected200400200400
/disabled200200200200
/attr200200200200
/hooks/payments200200200200
/manual200200200200

Mit builder.Services.AddAntiforgery() und app.UseAntiforgery() (was Blazor- und MVC-Apps, die von .NET 8-10 aktualisiert wurden, meist haben):

Endpunktplaincross-sitesame-originnur fremder Origin
/protected400400400400
/disabled, /attr, /hooks/payments200200200200

Mit DisableCsrfProtection=true und ohne UseAntiforgery(): /protected ist für jede Anfrage ein 500, genau wie .NET 10.0.10 ohne UseAntiforgery(), was ich ebenfalls gemessen habe. Die ausgenommenen Endpunkte bleiben bei 200.

Die Server-Logs verraten, welche Schicht abgelehnt hat. Die CSRF-Schicht protokolliert Cross-origin CSRF protection marked request POST /protected from origin 'https://evil.example' as invalid. auf Debug unter Microsoft.AspNetCore.Antiforgery.CsrfProtectionMiddleware, und der Binder protokolliert danach Antiforgery validation failed when reading parameter "string name" from the request body as form. mit einer inneren CsrfValidationException. Die Token-Schicht erzeugt dieselbe Binder-Meldung mit einer inneren AntiforgeryValidationException: The required antiforgery cookie ".AspNetCore.Antiforgery.<suffix>" is not present. In Production ist der Antworttext leer. Schalten Sie also Debug für Microsoft.AspNetCore.Http.RequestDelegateFactory und Microsoft.AspNetCore.Antiforgery ein, wenn Sie einem rätselhaften 400 nachjagen.

Deaktivierung für einen Endpunkt, Schritt für Schritt

  1. Stellen Sie sicher, dass der Endpunkt wirklich kein Browser-Cookie-Endpunkt ist. Die einzigen sicheren Kandidaten sind Aufrufer, die sich auf andere Weise authentifizieren: ein per HMAC signierter Webhook, ein API-Schlüssel, ein Bearer-Token, mTLS. Wenn der Browser eines angemeldeten Benutzers dorthin posten kann und der Handler anhand seiner Cookie-Identität handelt, behalten Sie den Schutz bei.
  2. Hängen Sie .DisableAntiforgery() an diesen MapPost an. Es ist eine Erweiterung auf jedem IEndpointConventionBuilder in Microsoft.AspNetCore.Builder, in einem Webprojekt ist also kein zusätzliches using nötig.
  3. Ersetzen Sie den gerade entfernten Schutz durch den eigenen Nachweis des Aufrufers. Bei einem formularkodierten Webhook ist das eine Signaturprüfung über den rohen Body, erledigt in einer Middleware, damit sie läuft, bevor irgendetwas das Formular bindet.
  4. Testen Sie erneut mit der Cross-Site-Anfrageform von oben (-H 'Sec-Fetch-Site: cross-site' -H 'Origin: https://other.example') und mit einfachem curl, damit Sie wissen, dass beide Schichten aus dem Weg sind.

Hier das Ganze für einen Anbieter, der application/x-www-form-urlencoded postet und den rohen Body mit HMAC-SHA256 signiert:

// .NET 11 RC 1, ASP.NET Core 11.0.0-rc.1
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var secret = Encoding.UTF8.GetBytes(app.Configuration["Sms:WebhookSecret"]!);

// Verify the signature over the raw body before anything binds the form.
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/webhooks/sms"), branch =>
    branch.Use(async (ctx, next) =>
    {
        ctx.Request.EnableBuffering();
        using var ms = new MemoryStream();
        await ctx.Request.Body.CopyToAsync(ms);
        ctx.Request.Body.Position = 0;

        var expected = Convert.ToHexStringLower(HMACSHA256.HashData(secret, ms.ToArray()));
        var actual = ctx.Request.Headers["X-Signature"].ToString();

        if (!CryptographicOperations.FixedTimeEquals(
                Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(actual)))
        {
            ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return;
        }

        await next(ctx);
    }));

app.MapPost("/webhooks/sms", ([FromForm] SmsStatus status) =>
        Results.Ok($"{status.MessageId}: {status.Status}"))
   .DisableAntiforgery();

app.Run();

record SmsStatus(string MessageId, string Status);

Gemessen: Ein korrekt signierter Post lieferte 200, sowohl als einfaches curl als auch mit den Cross-Site-Browser-Headern, und eine falsche Signatur lieferte 401.

Mein erster Entwurf hat diese Prüfung stattdessen in einen Endpunktfilter gesteckt, und er ist bei jeder signierten Anfrage gescheitert. Wenn ein Endpunktfilter läuft, ist der [FromForm]-Parameter bereits gebunden, der Formular-Reader hat den Anfragestream geleert, und EnableBuffering hat an dieser Stelle nichts mehr zu puffern: Der Filter hat 0 Bytes gehasht. Signaturprüfungen über den rohen Body gehören in eine Middleware, einer der konkreten Fälle in Endpunktfilter vs. Middleware. Ein Endpunktfilter ist für reine Header-Prüfungen wie einen API-Schlüssel in Ordnung.

Die Attributform, für Handler in Methoden

Wenn Ihre Handler statische Methoden statt Lambdas sind, liest sich das Attribut besser und übersteht Refactorings, die das Mapping verschieben:

// .NET 11 RC 1
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Mvc;

app.MapPost("/webhooks/payments", PaymentHooks.Handle);

static class PaymentHooks
{
    [RequireAntiforgeryToken(false)]
    public static IResult Handle([FromForm] string eventId) => Results.Ok(eventId);
}

RequireAntiforgeryTokenAttribute implementiert IAntiforgeryMetadata direkt, und beide Middlewares fragen den Endpunkt nach GetMetadata<IAntiforgeryMetadata>(), daher ist das Ergebnis identisch mit .DisableAntiforgery(). Für MVC-Controller ist das Äquivalent [IgnoreAntiforgeryToken]; der AntiforgeryMiddlewareAuthorizationFilter in .NET 11 respektiert das Urteil beider Middlewares.

Deaktivierung für eine Gruppe von Webhooks

Wenn Sie mehrere Anbieter-Callbacks haben, legen Sie sie unter ein Präfix und nehmen Sie die Gruppe einmal aus:

// .NET 11 RC 1
var hooks = app.MapGroup("/hooks").DisableAntiforgery();
hooks.MapPost("/payments", ([FromForm] string name) => Results.Ok());
hooks.MapPost("/sms", ([FromForm] string name) => Results.Ok());

So bleibt die Sicherheitsentscheidung an einer sichtbaren Stelle, statt über Dateien verstreut zu sein, was überhaupt das Hauptargument dafür ist, Minimal-API-Endpunkte mit MapGroup zu organisieren.

Fallen bei der Rangfolge, auf die ich im Test gestoßen bin

Ein Opt-out auf Gruppenebene schlägt ein Opt-in auf Endpunktebene. Ich hatte erwartet, dass dies den Schutz für einen Endpunkt innerhalb der deaktivierten Gruppe wieder einschaltet:

// .NET 11 RC 1: does NOT re-enable validation
hooks.MapPost("/strict", ([FromForm] string name) => $"hello {name}")
     .WithMetadata(new RequireAntiforgeryTokenAttribute());

Tut es nicht. /hooks/strict lieferte für die Cross-Site-Anfrage in beiden Pipeline-Modi 200. Der Grund liegt in DisableAntiforgery selbst: Es registriert seine Metadaten mit builder.Finally(...), das nach den eigenen Konventionen des Endpunkts läuft, und GetMetadata<T>() gibt das letzte passende Element zurück. Das “nicht erforderlich” der Gruppe landet zuletzt und gewinnt. Wenn ein Endpunkt unter einem Präfix geschützt bleiben muss, deaktivieren Sie nicht auf Gruppenebene; deaktivieren Sie stattdessen pro Endpunkt oder teilen Sie das Präfix in zwei Gruppen auf.

Dieselbe Finally-Reihenfolge ist der Grund, warum .DisableAntiforgery() immer gegen die abgeleiteten Metadaten gewinnt. Der Formular-Binder fügt RequiresValidation = true beim Aufbau des Endpunkts hinzu; der Finally-Callback fügt danach false hinzu. Die Reihenfolge, in der Sie Aufrufe verketten, spielt keine Rolle.

Handler, die das Formular von Hand lesen, bekommen überhaupt keinen Schutz. Der Endpunkt /manual oben liest req.ReadFormAsync() ohne formulargebundenen Parameter, daher werden keine Metadaten abgeleitet und keine der beiden Middlewares schaut ihn sich an: Cross-Site-Posts bekamen in jedem Modus 200. Wenn Sie dort Schutz wollen, müssen Sie sich mit .WithMetadata(new RequireAntiforgeryTokenAttribute()) dafür entscheiden. Dann ändert sich aber das Fehlerverhalten: Ist das Urteil ungültig, weigert sich FormFeature, den Body zu lesen, und wirft InvalidOperationException: This form is being accessed with a failed antiforgery validation. Validate the IAntiforgeryValidationFeature on the request before reading from the form., was ein 500 ist, kein 400. Prüfen Sie das Feature zuerst selbst:

// .NET 11 RC 1
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.Features;

app.MapPost("/manual-protected", async (HttpContext ctx) =>
    {
        if (ctx.Features.Get<IAntiforgeryValidationFeature>() is { IsValid: false })
            return Results.BadRequest();

        var form = await ctx.Request.ReadFormAsync();
        return Results.Ok(form["name"].ToString());
    })
    .WithMetadata(new RequireAntiforgeryTokenAttribute());

DisableCsrfProtection ist kein Werkzeug pro Endpunkt. Es entfernt die automatisch eingefügte Middleware für die gesamte App. Diese Middleware ist auch das, was die Prüfung “a middleware was not found that supports anti-forgery” für Apps erfüllt, die nie UseAntiforgery() aufrufen. Wer den Schalter umlegt, um einen Webhook zu reparieren, macht also jeden anderen Formularendpunkt mit einem 500 kaputt (oben gemessen). Die Dokumentation nennt ihn einen Notausgang; behandeln Sie ihn auch so.

Engere Alternativen, bevor Sie etwas deaktivieren

Deaktivieren ist für signierte Server-zu-Server-Aufrufe richtig. Für Browser-Traffic gibt es zwei engere Optionen.

Einem bestimmten Cross-Origin-Aufrufer über CORS vertrauen. Die Standardimplementierung von ICsrfProtection zieht die CORS-Richtlinie heran, die für den Endpunkt gilt: Wenn der Origin der Anfrage von einer benannten oder der Standardrichtlinie erlaubt ist, wird der Post akzeptiert, auch wenn Sec-Fetch-Site gleich cross-site ist. AllowAnyOrigin wird absichtlich ignoriert.

// .NET 11 RC 1
builder.Services.AddCors(o => o.AddPolicy("partner",
    p => p.WithOrigins("https://pay.partner.example")));

var app = builder.Build();
app.UseCors();

app.MapPost("/partner-callback", ([FromForm] string orderId) => Results.Ok(orderId))
   .RequireCors("partner");

Gemessen auf der Standard-Pipeline: Der Partner-Origin bekam 200, ein fremder Origin und eine same-site-Schwesterdomain bekamen weiterhin 400, einfaches curl bekam 200. Zwei Einschränkungen. Ohne app.UseCors() wirft der Endpunkt contains CORS metadata, but a middleware was not found that supports CORS (500). Und dies lockert nur die Fetch-Metadata-Schicht: Mit UseAntiforgery() in der Pipeline läuft die Token-Validierung danach, überschreibt das Urteil, und der Partner-Post war wieder ein 400. Wenn Sie CORS bereits mit Cookies oder JWTs kombinieren, behandelt der Beitrag zum CORS-Setup für eine JWT-geschützte API die Richtlinienseite.

Callbacks von Identitätsanbietern dem Framework überlassen. OpenID Connect response_mode=form_post und WS-Federation-Callbacks sind konstruktionsbedingt Cross-Site-Formular-Posts. In .NET 11 unterdrücken die Remote-Authentifizierungshandler ein ungültiges Urteil, solange sie den Callback-Pfad besitzen (RemoteAuthenticationAntiforgery im Quellcode), weil der Parameter state und das Korrelations-Cookie sie bereits schützen. Sie brauchen kein .DisableAntiforgery() auf /signin-oidc und können es dort ohnehin nicht setzen, da der Handler eine Middleware ist, kein Endpunkt.

Stolperfallen beim Upgrade von .NET 8, 9 oder 10

Verwandte Beiträge

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück