ASP.NET Core

Qualité d'API

Versionnage, pagination, ProblemDetails, OpenAPI, CORS, limitation de débit.

Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 15 min

Une API qui répond juste n'est pas encore une API qu'on peut utiliser longtemps. Ses clients, qu'on ne livre pas en même temps qu'elle, ont besoin de savoir ce qui changera et quand, de parcourir une liste sans perdre de lignes, de reconnaître une erreur sans lire sa prose, de générer leur code depuis une description fiable, d'être appelés depuis un navigateur, et de savoir quand ralentir. Ce sont six conventions, que ce cours prend une à une sur ASP.NET Core 10. Le pipeline, la liaison, la validation et la forme de base de ProblemDetails sont traités dans « Construire une API » ; l'authentification dans « Authentification et autorisation ».

Versionner

On ne versionne que ce qui casse. Ajouter un champ facultatif à une réponse ne casse pas un client qui ignore les champs inconnus ; renommer un champ, changer son type ou rendre obligatoire un paramètre, si. Une nouvelle version sert à ce que les anciens clients continuent de recevoir l'ancien contrat pendant qu'ils migrent — c'est donc aussi une promesse de maintenir deux contrats, qu'il faut pouvoir retirer un jour.

La version se porte dans l'URL (/v2/commandes), dans un en-tête, dans la chaîne de requête ou dans un paramètre du type de média. L'URL est visible, se colle dans un navigateur et se journalise sans effort, mais elle fait d'une même ressource deux adresses. L'en-tête garde l'adresse stable ; il est en revanche invisible dans un lien, et un cache partagé doit savoir que la réponse varie selon lui, par Vary: Api-Version, que le paquet n'émet pas et qu'on pose soi-même. Écrits à la main, les deux se réduisent à du routage : deux MapGroup, ou un test sur l'en-tête. Le paquet Asp.Versioning.Http — 10.2.3, publié en août 2026 par la .NET Foundation — en fait une politique : une même manière de lire la version quelle que soit sa source, un 400 au format ProblemDetails pour une version absente, invalide ou inconnue, l'annonce des versions servies dans chaque réponse, et les en-têtes normalisés de dépréciation et de retrait.

using Asp.Versioning;

var builder = WebApplication.CreateBuilder(args);

// Paquet : Asp.Versioning.Http 10.2.3, pour Minimal API (Asp.Versioning.Mvc
// pour les controleurs). Les reponses qui suivent viennent de cette version.
builder.Services.AddProblemDetails();
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1);

    // Chaque reponse annonce les versions servies et celles qui sont depreciees.
    options.ReportApiVersions = true;

    // La version se lit dans un en-tete. Par l'URL, ce serait
    // new UrlSegmentApiVersionReader() et MapGroup("/v{version:apiVersion}/commandes").
    options.ApiVersionReader = new HeaderApiVersionReader("Api-Version");

    // Deprecation (RFC 9745) : la v1 est depreciee depuis le 1er juin 2026.
    // Sunset (RFC 8594) : elle cessera de repondre le 1er janvier 2027.
    options.Policies.Deprecate(new ApiVersion(1))
        .Effective(new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero))
        .Link("https://exemple.fr/api/migration-v2");
    options.Policies.Sunset(new ApiVersion(1))
        .Effective(new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero))
        .Link("https://exemple.fr/api/migration-v2");
});

var app = builder.Build();

// La reponse depend d'un en-tete de la requete : un cache partage doit le
// savoir. Le paquet n'emet aucun Vary, on le pose soi-meme.
app.Use(async (contexte, next) =>
{
    contexte.Response.Headers.Append("Vary", "Api-Version");
    await next(contexte);
});

var commandes = app.NewVersionedApi("Commandes").MapGroup("/commandes");

// Meme route, deux gestionnaires : c'est la version demandee qui choisit.
commandes.MapGet("/{id:int}", (int id) => new CommandeV1(id, 42.5m))
    .HasDeprecatedApiVersion(1);

// La v2 casse le contrat : Total devient un objet.
commandes.MapGet("/{id:int}", (int id) => new CommandeV2(id, new Montant(42.5m, "EUR")))
    .HasApiVersion(2);

app.Run();

public sealed record CommandeV1(int Id, decimal Total);
public sealed record Montant(decimal Valeur, string Devise);
public sealed record CommandeV2(int Id, Montant Total);
# dotnet run --no-launch-profile (donc en Production) ; Date, Server et
# Transfer-Encoding omis, en-tetes deja montres non repetes, traceId raccourcis.
curl -i -H "Api-Version: 1" $API/commandes/7
# HTTP/1.1 200 OK
# Vary: Api-Version
# api-supported-versions: 2
# api-deprecated-versions: 1
# Sunset: Fri, 01 Jan 2027 00:00:00 GMT
# Link: <https://exemple.fr/api/migration-v2>; rel="sunset"
# Link: <https://exemple.fr/api/migration-v2>; rel="deprecation"
# Deprecation: @1780272000
# {"id":7,"total":42.5}

curl -i -H "Api-Version: 2" $API/commandes/7
# HTTP/1.1 200 OK
# {"id":7,"total":{"valeur":42.5,"devise":"EUR"}}

curl -i -H "Api-Version: 3" $API/commandes/7
# HTTP/1.1 400 Bad Request
# Content-Type: application/problem+json
# {"type":"https://docs.api-versioning.org/problems#unsupported",
#  "title":"Unsupported API version","status":400,
#  "detail":"The HTTP resource that matches the request URI
#            'http://localhost:5513/commandes/7' does not support the API version '3'.",
#  "code":"UnsupportedApiVersion","traceId":"00-99efb734...-00"}

curl -i $API/commandes/7
# HTTP/1.1 400 Bad Request
# {"type":"https://docs.api-versioning.org/problems#unspecified",
#  "title":"Unspecified API version","status":400,
#  "detail":"An API version is required, but was not specified.",
#  "code":"ApiVersionUnspecified","traceId":"00-588a22eb...-00"}

# Lecture par l'URL : le paquet traite l'URL comme l'identite de la
# ressource, et une version inconnue est une ressource qui n'existe pas.
# GET /v3/commandes/7
# HTTP/1.1 404 Not Found
# Content-Length: 0

Deprecation porte une date au format de la RFC 9745, un @ suivi de secondes Unix ; Sunset une date HTTP. Un client peut ainsi détecter, sans lire de documentation, qu'il appelle une version en sursis. Sans version dans la requête, le paquet répond 400 : c'est voulu, et son analyseur signale AssumeDefaultVersionWhenUnspecified = true (avertissement AV0016) comme réservé à une API déjà en service dont les clients n'envoient aucune version. Avec la lecture par l'URL, en revanche, le paquet traite l'URL comme l'identité de la ressource : une version inconnue donne un 404 vide au lieu d'un diagnostic. Pour les contrôleurs, le paquet est Asp.Versioning.Mvc ; pour publier un document OpenAPI par version, Asp.Versioning.OpenApi, de la même série, se branche sur le générateur de la section OpenAPI.

Paginer sans mentir

La pagination par décalage désigne une page par sa position : sauter (page - 1) × taille lignes, en prendre taille. Elle est intuitive, permet d'aller directement à la page 40 et se combine avec un total. Elle a deux défauts. La base doit parcourir toutes les lignes sautées, si bien que la page 40 coûte quarante fois la première. Et la position d'une ligne change dès que la liste bouge entre deux appels : l'exemple suivant livre une commande deux fois quand une autre arrive, puis en perd une quand une autre disparaît.

// Une liste en memoire tient lieu de table ; le raisonnement est le meme en SQL,
// ou Skip et Take deviennent OFFSET et FETCH (ou LIMIT).
var commandes = Enumerable.Range(1, 6)
    .Select(i => new Commande(i, new DateTime(2026, 9, i)))
    .ToList();

// La page est une position : sauter (numero - 1) * taille lignes, en prendre taille.
List<Commande> Page(int numero, int taille) => commandes
    .OrderByDescending(c => c.Creee)
    .Skip((numero - 1) * taille)
    .Take(taille)
    .ToList();

void Afficher(string etiquette, List<Commande> page) =>
    Console.WriteLine($"{etiquette} : {string.Join(", ", page.Select(c => c.Id))}");

Afficher("page 1", Page(1, 3));

// Entre les deux appels du client, une commande arrive en tete de liste.
commandes.Add(new Commande(7, new DateTime(2026, 9, 7)));
Afficher("page 2", Page(2, 3));

// Meme scenario, mais c'est une commande deja vue qui disparait.
commandes.RemoveAll(c => c.Id is 6 or 7);
Afficher("page 2", Page(2, 3));

public sealed record Commande(int Id, DateTime Creee);

// page 1 : 6, 5, 4
// page 2 : 4, 3, 2    <- 4 est livree deux fois
// page 2 : 2, 1       <- 3 n'est jamais livree

La pagination par curseur — dite aussi par clé, ou keyset — désigne la page par la dernière ligne livrée : « les commandes plus anciennes que celle-ci ». Une insertion en tête ou une suppression déjà lue ne déplace plus rien, et la condition sur la clé est servie par un index, quelle que soit la profondeur.

using System.Buffers.Text;
using System.Globalization;
using System.Text;

var commandes = Enumerable.Range(1, 6)
    .Select(i => new Commande(i, new DateTime(2026, 9, i)))
    .ToList();

// Le curseur designe la derniere ligne livree, pas une position. Le tri porte
// sur un couple unique (Creee, Id) : a date egale, Id departage, sinon deux
// lignes a egalite pourraient changer d'ordre d'un appel a l'autre.
(List<Commande> Lignes, string? Suivant) PageApres(string? curseur, int taille)
{
    IEnumerable<Commande> requete = commandes
        .OrderByDescending(c => c.Creee)
        .ThenByDescending(c => c.Id);

    if (curseur is not null)
    {
        var (creee, id) = Curseur.Lire(curseur);
        // En SQL : WHERE (Creee, Id) < (@creee, @id), servi par un index sur ce
        // couple. Le cout ne depend plus de la profondeur de la page.
        requete = requete.Where(c => c.Creee < creee || (c.Creee == creee && c.Id < id));
    }

    // Une ligne de plus que demande : si elle existe, il y a une page suivante.
    // Pas besoin de COUNT pour le savoir.
    var lignes = requete.Take(taille + 1).ToList();
    var suivant = lignes.Count > taille ? Curseur.Ecrire(lignes[taille - 1]) : null;
    return (lignes.Take(taille).ToList(), suivant);
}

void Afficher(string etiquette, List<Commande> page) =>
    Console.WriteLine($"{etiquette} : {string.Join(", ", page.Select(c => c.Id))}");

var (page1, suivant) = PageApres(null, 3);
Afficher("page 1", page1);
Console.WriteLine($"curseur : {suivant}");

// Les deux perturbations de l'exemple precedent, entre les deux appels.
commandes.Add(new Commande(7, new DateTime(2026, 9, 7)));
commandes.RemoveAll(c => c.Id == 6);

var (page2, _) = PageApres(suivant, 3);
Afficher("page 2", page2);

public sealed record Commande(int Id, DateTime Creee);

// Opaque pour le client : il le renvoie tel quel et n'en deduit rien. Le
// format peut donc changer sans casser personne.
public static class Curseur
{
    public static string Ecrire(Commande c) =>
        Base64Url.EncodeToString(Encoding.UTF8.GetBytes(
            $"{c.Creee.ToString("O", CultureInfo.InvariantCulture)}|{c.Id}"));

    public static (DateTime Creee, int Id) Lire(string curseur)
    {
        var parties = Encoding.UTF8.GetString(Base64Url.DecodeFromChars(curseur)).Split('|');
        return (
            DateTime.Parse(parties[0], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
            int.Parse(parties[1], CultureInfo.InvariantCulture));
    }
}

// page 1 : 6, 5, 4
// curseur : MjAyNi0wOS0wNFQwMDowMDowMC4wMDAwMDAwfDQ
// page 2 : 3, 2, 1    <- ni doublon ni trou, malgre l'ajout et la suppression

Trois règles tiennent ce mécanisme. Le tri doit être total : trier seulement par date laisse deux lignes de même date dans un ordre que la base est libre de changer, et la documentation d'EF Core le rappelle pour les deux méthodes. Le curseur est opaque : encodé, le client le renvoie sans chercher à le fabriquer, ce qui laisse le serveur libre d'en changer le contenu. Et l'accès direct à la page 40 disparaît, comme le total gratuit ; la réponse porte les éléments et le curseur suivant, nul à la fin. Le décalage reste acceptable pour une liste courte et stable qu'un humain parcourt ; un client qui synchronise, exporte ou fait défiler à l'infini a besoin du curseur.

Des erreurs qui ont toutes la même forme

Un client écrit son traitement d'erreur une fois. Il ne peut le faire que si toutes les erreurs de l'API, qu'elles viennent d'un gestionnaire, du domaine, du routage, de la liaison ou d'une exception imprévue, partagent le format de la RFC 9457. Le contre-exemple n'a rien d'exotique : chaque gestionnaire invente sa forme, et le message technique d'une panne part chez le client. Le socle qui donne un corps aux erreurs du framework est décrit dans « Construire une API » ; ce qui suit ajoute les erreurs métier.

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

// Chaque gestionnaire invente sa forme d'erreur.
app.MapGet("/commandes/{id:int}", (int id) =>
    id == 7
        ? Results.Ok(new Commande(7, "expediee"))
        : Results.NotFound(new { message = $"Commande {id} introuvable" }));

app.MapPost("/commandes/{id:int}/annulation", (int id) =>
    Results.Conflict(new { erreur = "TRANSITION_INTERDITE", code = 1042 }));

app.MapGet("/panne", () =>
{
    try
    {
        throw new InvalidOperationException("Chaine de connexion invalide : Server=db01;Password=...");
    }
    catch (Exception ex)
    {
        // Le message technique part chez le client, en Production comme ailleurs.
        return Results.Json(new { error = ex.Message }, statusCode: 500);
    }
});

app.MapPost("/commandes", (Commande corps) => Results.Created($"/commandes/{corps.Id}", corps));

app.Run();

public sealed record Commande(int Id, string Etat);

La version cohérente laisse le domaine lever ses erreurs métier sans rien savoir de HTTP, et confie leur traduction à un IExceptionHandler, appelé par UseExceptionHandler. Celui-ci ne traite que ce qu'il connaît et rend false pour le reste, qui finit en 500 générique.

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Le socle, detaille dans « Construire une API » : AddProblemDetails,
// UseExceptionHandler, UseStatusCodePages. Le neuf est le traducteur.
builder.Services.AddProblemDetails();

// Les gestionnaires sont des singletons, appeles dans l'ordre d'inscription
// jusqu'au premier qui rend true.
builder.Services.AddExceptionHandler<TraducteurErreursMetier>();

var app = builder.Build();

app.UseExceptionHandler();  // exceptions : metier -> traducteur, le reste -> 500
app.UseStatusCodePages();   // statuts d'erreur sans corps : 404, 405, 415...

app.MapGet("/commandes/{id:int}", Results<Ok<Commande>, NotFound> (int id) =>
    id == 7 ? TypedResults.Ok(new Commande(7, "expediee")) : TypedResults.NotFound());

app.MapPost("/commandes/{id:int}/annulation", (int id) =>
{
    ServiceCommandes.Annuler(id);
    return TypedResults.NoContent();
});

app.MapGet("/panne", () =>
{
    throw new InvalidOperationException("Chaine de connexion invalide : Server=db01;Password=...");
});

app.MapPost("/commandes", (Commande corps) => TypedResults.Created($"/commandes/{corps.Id}", corps));

app.Run();

public sealed record Commande(int Id, string Etat);

// Le domaine leve ses erreurs sans rien savoir de HTTP ; seul le traducteur
// connait le statut et le type de probleme.
public static class ServiceCommandes
{
    public static void Annuler(int id) => throw new TransitionInterdite(id, "expediee", "annulee");
}

public abstract class ErreurMetier(string message) : Exception(message)
{
    public abstract int Statut { get; }
    public abstract string Type { get; }
    public abstract string Titre { get; }
}

public sealed class TransitionInterdite(int id, string de, string vers)
    : ErreurMetier($"La commande {id} est {de} : elle ne peut plus passer a l'etat {vers}.")
{
    public override int Statut => StatusCodes.Status409Conflict;
    public override string Type => "https://exemple.fr/problemes/transition-interdite";
    public override string Titre => "Transition interdite";
}

public sealed class TraducteurErreursMetier(IProblemDetailsService problemes) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext http, Exception exception, CancellationToken ct)
    {
        // false : l'exception n'est pas pour nous, le middleware continue.
        if (exception is not ErreurMetier erreur) return false;

        http.Response.StatusCode = erreur.Statut;
        return await problemes.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = http,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Type = erreur.Type,
                Title = erreur.Titre,
                Status = erreur.Statut,
                Detail = erreur.Message,
            },
        });
    }
}
# Les deux API lancees par dotnet run --no-launch-profile (Production).
# Corps de la seconde en application/problem+json ; « ... » remplace le traceId.

# POST /commandes/7/annulation
#   disparate : 409 {"erreur":"TRANSITION_INTERDITE","code":1042}
#   coherente : 409 {"type":"https://exemple.fr/problemes/transition-interdite",
#                    "title":"Transition interdite","status":409,
#                    "detail":"La commande 7 est expediee : elle ne peut plus passer a l'etat annulee.",...}

# GET /panne
#   disparate : 500 {"error":"Chaine de connexion invalide : Server=db01;Password=..."}
#   coherente : 500 {"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1",
#                    "title":"An error occurred while processing your request.","status":500,...}

# POST /commandes, Content-Type: text/plain
#   disparate : 415, Content-Length: 0
#   coherente : 415 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16",
#                    "title":"Unsupported Media Type","status":415,...}

# DELETE /commandes/7 (methode non prevue)
#   disparate : 405, Content-Length: 0, Allow: GET
#   coherente : 405 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.6",
#                    "title":"Method Not Allowed","status":405,...}, Allow: GET

Le 500 ne dit plus rien de la chaîne de connexion : en Production, le middleware écrit un titre générique, et le détail part dans le journal, pas chez le client. ASP.NET Core 10 a changé un comportement voisin : une exception qu'un IExceptionHandler déclare traitée n'est plus journalisée comme erreur ni marquée en erreur dans les métriques. C'est ce qu'on veut pour une transition interdite ; si l'on veut garder la trace, ExceptionHandlerOptions.SuppressDiagnosticsCallback le permet. La cohérence se vérifie enfin sur ce que le framework produit seul — 405, 415, JSON malformé — et sur les middlewares ajoutés plus tard : le 400 du versionnage suit déjà le format, le 429 de la dernière section ne le suivra que si UseStatusCodePages le rattrape, ou si OnRejected l'écrit, seul endroit où l'on dispose du bail, donc de Retry-After.

OpenAPI en .NET 10

Depuis .NET 9, les templates de projet ne référencent plus Swashbuckle : la génération du document est fournie par Microsoft.AspNetCore.OpenApi, qui lit les métadonnées des points de terminaison — types de retour, WithName, WithSummary, ProducesResponseType — et sert le document par MapOpenApi. En .NET 10, ce document est en OpenAPI 3.1 par défaut, et peut être servi en YAML.

using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);

// Paquet : Microsoft.AspNetCore.OpenApi 10.0.12 (Microsoft.OpenApi 2.x).
builder.Services.AddOpenApi(options =>
    // Un transformer de document : l'equivalent des filtres de Swashbuckle.
    // Il en existe aussi pour les operations et les schemas.
    options.AddDocumentTransformer((document, contexte, ct) =>
    {
        document.Info.Title = "API Commandes";
        document.Info.Version = "v1";
        return Task.CompletedTask;
    }));

var app = builder.Build();

// Le template de projet ne mappe le document (en JSON seulement) que si
// IsDevelopment() : il decrit toute la surface de l'API.
app.MapOpenApi();                                  // /openapi/v1.json
app.MapOpenApi("/openapi/{documentName}.yaml");    // /openapi/v1.yaml

// Le type de retour declare suffit a documenter les deux reponses.
app.MapGet("/commandes/{id:int}", Results<Ok<Commande>, NotFound> (int id) =>
    id == 7 ? TypedResults.Ok(new Commande(7, 42.5m, null)) : TypedResults.NotFound())
    .WithName("LireCommande")
    .WithSummary("Lit une commande par son identifiant.");

app.Run();

public sealed record Commande(int Id, decimal Total, string? Commentaire);

Le document obtenu, réduit aux parties qui ont changé :

{
  "openapi": "3.1.1",
  "info": { "title": "API Commandes", "version": "v1" },
  "paths": {
    "/commandes/{id}": {
      "get": {
        "summary": "Lit une commande par son identifiant.",
        "operationId": "LireCommande",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Commande" } }
            }
          },
          "404": { "description": "Not Found" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Commande": {
        "required": ["id", "total", "commentaire"],
        "type": "object",
        "properties": {
          "id": {
            "pattern": "^-?(?:0|[1-9]\\d*)$",
            "type": ["integer", "string"],
            "format": "int32"
          },
          "total": {
            "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
            "type": ["number", "string"],
            "format": "double"
          },
          "commentaire": { "type": ["null", "string"] }
        }
      }
    }
  }
}

OpenAPI 3.1 aligne les schémas sur JSON Schema 2020-12, et cela se voit. Un string? n'est plus "nullable": true mais un type ["null", "string"]. Un int devient ["integer", "string"] avec un motif : les options JSON par défaut d'ASP.NET Core acceptent de lire un nombre écrit entre guillemets, et le schéma le déclare ; le passer à JsonNumberHandling.Strict rend un integer seul. Les générateurs de clients encore limités à 3.0 lisent mal ces formes, et options.OpenApiVersion permet de revenir à 3.0.

Le reste de la migration tient en quatre points. La personnalisation passe par des transformers de document, d'opération et de schéma, là où Swashbuckle avait des filtres ; ils reposent sur Microsoft.OpenApi 2.x, dont les objets sont devenus des interfaces et où JsonNode remplace OpenApiAny, ce qui oblige à réécrire un transformer venu de .NET 9, même en restant en 3.0. WithOpenApi est déprécié (ASPDEPR002) au profit d'AddOpenApiOperationTransformer. Aucune interface de consultation n'est fournie : Swagger UI ou Scalar s'ajoutent à part et pointent sur /openapi/v1.json. Et le paquet Microsoft.Extensions.ApiDescription.Server produit le même document à la compilation, ce qui permet de le versionner et de le comparer en revue. Swashbuckle, lui, n'est pas mort : la 10.2.3 fonctionne sur .NET 10, produit du 3.0.4 par défaut, avec "nullable": true, et du 3.1.1 sur demande.

CORS

Un navigateur applique la politique de même origine : un script de https://app.exemple.fr peut envoyer une requête à https://api.exemple.fr, mais n'a pas le droit d'en lire la réponse. CORS est le moyen, pour le serveur, de lever cette interdiction pour certaines origines, par l'en-tête Access-Control-Allow-Origin. Pour une requête qui n'est pas « simple » — un POST JSON, un en-tête Authorization — le navigateur demande d'abord la permission par un pré-vol OPTIONS.

Ce n'est pas le serveur qui décide si les cookies partent : c'est le script, par credentials: 'include' (ou withCredentials), sous réserve de l'attribut SameSite du cookie. AllowCredentials ne fait qu'émettre Access-Control-Allow-Credentials: true, qui autorise le script à lire la réponse et, après un pré-vol, laisse partir la requête réelle avec ses cookies. Le retirer n'empêche donc pas une requête simple de partir avec eux. Répondre * à une requête avec cookies reviendrait à laisser n'importe quel site lire les données de l'utilisateur connecté ; la spécification Fetch l'interdit, et ASP.NET Core refuse de démarrer avec une telle politique. Le contournement habituel est pire que ce qu'il contourne.

var builder = WebApplication.CreateBuilder(args);

// Premier essai : AllowAnyOrigin().AllowCredentials(). L'application refuse
// de demarrer (ASP.NET Core 10.0.8) :
//   System.InvalidOperationException: The CORS protocol does not allow
//   specifying a wildcard (any) origin and credentials at the same time.
// Deuxieme essai, qui demarre : accepter toute origine, une par une.
builder.Services.AddCors(options => options.AddPolicy("Front", politique => politique
    .SetIsOriginAllowed(_ => true)
    .AllowAnyMethod()
    .AllowAnyHeader()
    .AllowCredentials()));

var app = builder.Build();

app.UseCors();

app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireCors("Front");

app.Run();

// GET /commandes   Origin: https://pirate.exemple
// HTTP/1.1 200 OK
// Access-Control-Allow-Credentials: true
// Access-Control-Allow-Origin: https://pirate.exemple
// Vary: Origin
// ["C-1","C-2"]

Refléter toute origine envoyée, c'est exactement la combinaison que le framework refusait, déguisée. La politique juste est nommée, énumère ses origines, ses méthodes et ses en-têtes, et se pose sur les routes qui en ont besoin.

var builder = WebApplication.CreateBuilder(args);

// Une politique nommee, qui enumere ce qu'elle autorise.
builder.Services.AddCors(options => options.AddPolicy("Front", politique => politique
    .WithOrigins("https://app.exemple.fr")
    .WithMethods("GET", "POST")
    .WithHeaders("Content-Type", "Authorization")
    .AllowCredentials()
    .SetPreflightMaxAge(TimeSpan.FromMinutes(10))));

builder.Services.AddSingleton<Compteur>();

var app = builder.Build();

app.UseCors();

// La politique est posee sur le groupe : les autres routes n'en ont aucune.
var commandes = app.MapGroup("/commandes").RequireCors("Front");

commandes.MapGet("/", () => new[] { "C-1", "C-2" });

commandes.MapPost("/{id:int}/annulation", (int id, Compteur compteur) =>
{
    compteur.Valeur++;
    return TypedResults.Ok(new { id, annulations = compteur.Valeur });
});

app.Run();

public sealed class Compteur
{
    public int Valeur;
}
# L'API precedente ; Date, Server et Transfer-Encoding omis.
curl -i -H "Origin: https://app.exemple.fr" $API/commandes/
# HTTP/1.1 200 OK
# Access-Control-Allow-Credentials: true
# Access-Control-Allow-Origin: https://app.exemple.fr
# ["C-1","C-2"]

curl -i -H "Origin: https://pirate.exemple" $API/commandes/
# HTTP/1.1 200 OK
# ["C-1","C-2"]          <- le corps part ; seul l'en-tete CORS manque

# Pre-vol d'un POST JSON venu d'une origine inconnue :
curl -i -X OPTIONS -H "Origin: https://pirate.exemple" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type" $API/commandes/7/annulation
# HTTP/1.1 204 No Content   <- sans Access-Control-Allow-*, le navigateur n'envoie pas le POST

# Mais un POST text/plain est une requete « simple » : pas de pre-vol.
curl -i -X POST -H "Origin: https://pirate.exemple" -H "Content-Type: text/plain" \
  $API/commandes/7/annulation
# HTTP/1.1 200 OK
# {"id":7,"annulations":1}   <- l'action a eu lieu ; seule la lecture est refusee

CORS ne protège pas le serveur. Le serveur a exécuté la requête venue de l'origine inconnue et renvoyé son corps ; c'est le navigateur, et lui seul, qui refuse de le donner au script. curl, un autre serveur ou un script hors navigateur n'en tiennent aucun compte. Pire, une requête simple part sans pré-vol : le dernier appel a bien annulé la commande. Ce qui protège une API, c'est l'authentification et l'autorisation ; ce qui protège un utilisateur connecté par cookie contre un site hostile, ce sont SameSite et un jeton anti-CSRF. CORS ne fait qu'ouvrir, prudemment, une porte que le navigateur tenait fermée.

Limiter le débit

Le limiteur protège le serveur d'un client trop gourmand, et les clients les uns des autres. AddRateLimiter enregistre des politiques nommées, UseRateLimiter les applique, RequireRateLimiting en pose une sur un point de terminaison. Les algorithmes viennent de System.Threading.RateLimiting ; RetryAfter indique ce qu'ils savent prédire, mesuré sur le runtime 10.0.8 — la fenêtre glissante n'en fournit pas, contrairement à ce qu'affirme la page d'échantillons de la documentation ; le code de .NET 11 le calcule.

AlgorithmeMéthodeCe qu'il limiteRetryAfter
Fenêtre fixeAddFixedWindowLimiter N requêtes par fenêtre, compteur remis à zéro à chaque fenêtre ; 2N possibles à cheval sur une frontière oui, la durée entière de la fenêtre
Fenêtre glissanteAddSlidingWindowLimiter N requêtes par fenêtre découpée en segments, qui rendent leurs permis en expirant non en .NET 10, contrairement à la page d'échantillons ; calculé en .NET 11
Seau à jetonsAddTokenBucketLimiterune rafale jusqu'à la taille du seau, puis le débit de réapprovisionnementoui, la période de réapprovisionnement
ConcurrenceAddConcurrencyLimiterN requêtes simultanées, permis rendu à la fin de chacunenon

Le premier exemple qu'on écrit partage un seul compteur entre tous les clients et rejette par un 503 vide : deux erreurs que rien ne signale à la compilation.

using Microsoft.AspNetCore.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

// Trois requetes par fenetre de 10 secondes. Pour qui ? Pour tout le monde a
// la fois : AddFixedWindowLimiter cree un seul compteur, partage par tous les
// clients.
builder.Services.AddRateLimiter(options =>
    options.AddFixedWindowLimiter("lecture", fenetre =>
    {
        fenetre.PermitLimit = 3;
        fenetre.Window = TimeSpan.FromSeconds(10);
    }));

var app = builder.Build();

app.UseRateLimiter();

app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireRateLimiting("lecture");

app.Run();

// Client A, requetes 1 a 3 -> 200
// Client A, requete 4      -> 503 Service Unavailable, Content-Length: 0
// Client B, sa premiere    -> 503 Service Unavailable : A a vide le compteur

Les méthodes Add…Limiter créent une seule partition : un client bavard épuise le quota de tous les autres. Et le rejet est un 503 vide, qui dit « serveur indisponible » au lieu de « vous allez trop vite » et ne dit pas quand revenir. La version juste partitionne par client, répond 429, transmet Retry-After et reprend le format d'erreur de l'API.

using System.Globalization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();

builder.Services.AddRateLimiter(options =>
{
    // 503 par defaut : « le serveur est en panne », que le client peut
    // interpreter comme une raison de reessayer tout de suite.
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    // Un seau par client : l'utilisateur authentifie, a defaut l'adresse IP.
    // Derriere un proxy, cette adresse est celle du proxy tant que
    // UseForwardedHeaders ne l'a pas corrigee.
    options.AddPolicy("lecture", http => RateLimitPartition.GetTokenBucketLimiter(
        partitionKey: http.User.Identity?.Name
            ?? http.Connection.RemoteIpAddress?.ToString()
            ?? "inconnu",
        factory: _ => new TokenBucketRateLimiterOptions
        {
            TokenLimit = 3,                                // rafale maximale
            TokensPerPeriod = 1,                           // debit soutenu :
            ReplenishmentPeriod = TimeSpan.FromSeconds(4), // un jeton toutes les 4 s
            QueueLimit = 0,                                // refuser, ne pas faire attendre
        }));

    options.OnRejected = async (contexte, ct) =>
    {
        var http = contexte.HttpContext;

        // Le limiteur sait quand un jeton reviendra ; Retry-After le dit au client.
        if (contexte.Lease.TryGetMetadata(MetadataName.RetryAfter, out var delai))
            http.Response.Headers.RetryAfter =
                ((int)Math.Ceiling(delai.TotalSeconds)).ToString(CultureInfo.InvariantCulture);

        // Le rejet a la meme forme que les autres erreurs de l'API.
        var problemes = http.RequestServices.GetRequiredService<IProblemDetailsService>();
        await problemes.WriteAsync(new ProblemDetailsContext
        {
            HttpContext = http,
            ProblemDetails = new ProblemDetails
            {
                Type = "https://exemple.fr/problemes/trop-de-requetes",
                Title = "Trop de requetes",
                Status = StatusCodes.Status429TooManyRequests,
                Detail = "Le quota de lecture est epuise pour ce client.",
            },
        });
    };
});

var app = builder.Build();

app.UseRateLimiter();

app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireRateLimiting("lecture");

app.Run();

// Client A, requetes 1 a 3 -> 200
// Client A, requete 4      -> 429 Too Many Requests
//   Content-Type: application/problem+json
//   Retry-After: 4
//   {"type":"https://exemple.fr/problemes/trop-de-requetes","title":"Trop de requetes",
//    "status":429,"detail":"Le quota de lecture est epuise pour ce client.","traceId":"00-..."}
// Client B, sa premiere    -> 200 : son seau est plein

Le choix de la clé de partition est la vraie décision. L'adresse IP regroupe tous les utilisateurs d'un même réseau d'entreprise, se falsifie, et vaut celle du proxy si UseForwardedHeaders n'est pas placé en tête ; l'identité de l'utilisateur ou une clé d'API sont plus justes, à condition que l'authentification ait eu lieu avant le limiteur. Enfin, ces compteurs vivent dans la mémoire du processus : trois instances derrière un répartiteur accordent chacune leur quota, et une limite globale se pose alors en amont, sur la passerelle.

Ce cours vous a servi ? Offrir un café Signaler une erreur