From a137cb582d44c1f89c18291b1f1b286b1b42607d Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Wed, 12 Aug 2026 17:46:52 +0200 Subject: [PATCH] Rate limiting sur les endpoints IA, partitionne par instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ManagerService/Controllers/AiController.cs | 12 +++++++ ManagerService/Startup.cs | 41 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/ManagerService/Controllers/AiController.cs b/ManagerService/Controllers/AiController.cs index eaddf10..392f337 100644 --- a/ManagerService/Controllers/AiController.cs +++ b/ManagerService/Controllers/AiController.cs @@ -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 { + /// + /// Politique de limitation appliquée aux endpoints qui consomment des jetons. + /// Déclarée ici parce que c'est le seul contrôleur concerné ; Startup la lit + /// pour enregistrer le limiteur. + /// + public const string RateLimitPolicy = "ai"; + /// /// 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 /// [HttpPost("translate")] + [EnableRateLimiting(RateLimitPolicy)] [ProducesResponseType(typeof(AiTranslateResponse), 200)] [ProducesResponseType(403)] + [ProducesResponseType(429)] [ProducesResponseType(typeof(string), 500)] public async Task 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 /// [HttpPost("chat")] + [EnableRateLimiting(RateLimitPolicy)] [ProducesResponseType(typeof(AiChatResponse), 200)] [ProducesResponseType(403)] + [ProducesResponseType(429)] [ProducesResponseType(typeof(string), 500)] public async Task Chat([FromBody] AiChatRequest request) { diff --git a/ManagerService/Startup.cs b/ManagerService/Startup.cs index af0ce15..92ef10f 100644 --- a/ManagerService/Startup.cs +++ b/ManagerService/Startup.cs @@ -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