Rate limiting sur les endpoints IA, partitionne par instance

La cle API publique d'une instance est embarquee dans les apps visiteur et
lisible en clair dans le navigateur sur visitapp-web : elle est publique par
construction. Les endpoints IA sont les seuls qui coutent de l'argent reel
(jetons Gemini), et rien n'empechait d'y boucler jusqu'a vider le quota
mensuel d'un client qui n'a rien fait.

AddRateLimiter natif .NET 8, fenetre fixe 120 req/min, 429 avec Retry-After.
Applique a chat ET translate : les deux consomment des jetons, et translate
est atteignable avec la meme cle.

Partition par instance parce que c'est l'instance qui porte le quota protege :
l'abus chez un client ne doit pas ralentir les autres.

Deux choix de placement qui ne sont pas cosmetiques. UseRateLimiter est apres
UseCors — un 429 pose avant les en-tetes CORS s'affiche comme une erreur CORS
et le client ne voit jamais le vrai code — et apres UseAuthentication, sinon
la partition n'a pas le claim d'instance et tout le monde tombe dans le meme
seau, ce qui transformerait la protection en panne globale.

Jamais exerce a l'execution : le projet n'a aucune infrastructure de test HTTP
et en monter une pour ce seul controle serait disproportionne. A verifier une
fois par une boucle de 130 appels, qui doit basculer en 429 au 121e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-12 17:46:52 +02:00
parent fa92395d17
commit a137cb582d
2 changed files with 53 additions and 0 deletions

View File

@ -4,6 +4,7 @@ using ManagerService.DTOs;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using NSwag.Annotations;
using Microsoft.EntityFrameworkCore;
@ -19,6 +20,13 @@ namespace ManagerService.Controllers
[OpenApiTag("AI", Description = "Assistant IA")]
public class AiController : ControllerBase
{
/// <summary>
/// Politique de limitation appliquée aux endpoints qui consomment des jetons.
/// Déclarée ici parce que c'est le seul contrôleur concerné ; <c>Startup</c> la lit
/// pour enregistrer le limiteur.
/// </summary>
public const string RateLimitPolicy = "ai";
/// <summary>
/// Plafond IA cumulé sur toute la durée de l'essai gratuit (14 jours), distinct du
/// compteur mensuel : ~30 requêtes * ~10k tokens/req estimés. Empêche qu'un essai à
@ -301,8 +309,10 @@ namespace ManagerService.Controllers
/// Traduit un texte HTML vers plusieurs langues via IA
/// </summary>
[HttpPost("translate")]
[EnableRateLimiting(RateLimitPolicy)]
[ProducesResponseType(typeof(AiTranslateResponse), 200)]
[ProducesResponseType(403)]
[ProducesResponseType(429)]
[ProducesResponseType(typeof(string), 500)]
public async Task<IActionResult> Translate([FromBody] AiTranslateRequest request, [FromQuery] string instanceId)
{
@ -336,8 +346,10 @@ namespace ManagerService.Controllers
/// Envoie un message à l'assistant IA, scopé à l'instance et optionnellement à une configuration
/// </summary>
[HttpPost("chat")]
[EnableRateLimiting(RateLimitPolicy)]
[ProducesResponseType(typeof(AiChatResponse), 200)]
[ProducesResponseType(403)]
[ProducesResponseType(429)]
[ProducesResponseType(typeof(string), 500)]
public async Task<IActionResult> Chat([FromBody] AiChatRequest request)
{

View File

@ -25,6 +25,9 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using ManagerService.Controllers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@ -262,6 +265,38 @@ namespace ManagerService
services.AddHealthChecks()
.AddNpgSql(connectionString);
// La clé API publique d'une instance est embarquée dans les apps visiteur et
// lisible en clair dans le navigateur sur visitapp-web : elle est publique par
// construction. Les endpoints IA sont les seuls qui coûtent de l'argent réel
// (jetons Gemini), et rien n'empêchait d'y boucler jusqu'à vider le quota mensuel
// d'un client qui n'a rien fait.
//
// Partition par instance, parce que c'est l'instance qui porte le quota qu'on
// protège : l'abus chez un client ne doit pas ralentir les autres. Le plafond est
// large — un musée à 100 visiteurs simultanés posant une question par minute
// reste très en dessous — il coupe la boucle automatique, pas l'usage réel.
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = (context, _) =>
{
context.HttpContext.Response.Headers.RetryAfter = "60";
return ValueTask.CompletedTask;
};
options.AddPolicy(AiController.RateLimitPolicy, httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120,
Window = TimeSpan.FromMinutes(1)
}));
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
@ -302,6 +337,12 @@ namespace ManagerService
#endif
);
// Après UseCors : un 429 renvoyé avant que les en-têtes CORS soient posés
// s'affiche comme une erreur CORS dans le navigateur, et visitapp-web ne verrait
// jamais le vrai code. Après UseAuthentication aussi, sinon la partition n'a pas
// encore le claim d'instance et tout le monde tomberait dans le même seau.
app.UseRateLimiter();
#if DEBUG
app.UseHangfireDashboard("/hangfire");
#else