From 73f67d13426413760b0133d46c274e7ceb14e056 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:37:44 +0300 Subject: [PATCH] Protect FileDownloadController with reCAPTCHA v3 and rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Require captchaToken query param on initial (non-range) download requests - Range requests (HTTP resume) bypass captcha — they are continuations of an already-validated download - Add download rate limit policy: 5 requests / 1 min per IP (configured in .env) - Inject ICaptchaVerifier; action name is file_download UI change required: execute grecaptcha.execute(siteKey, {action: 'file_download'}) before triggering the download and append ?captchaToken= to the URL. Co-Authored-By: Claude Sonnet 4.6 --- .../api/Controllers/FileDownloadController.cs | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index c34ee17..5585648 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Swashbuckle.AspNetCore.Annotations; using Common.Responses; +using Microsoft.AspNetCore.RateLimiting; namespace Api.Controllers { @@ -17,38 +18,44 @@ namespace Api.Controllers [ApiController] [Route("api/[controller]")] [EnableCors("FrontendOnly")] + [EnableRateLimiting("download")] public sealed class FileDownloadController : ControllerBase { private readonly ILogger _logger; private readonly FileStorageSettings _fileStorageSettings; private readonly IContentTypeProvider _contentTypeProvider; private readonly IEmailSender _emailSender; - private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance + private readonly ICaptchaVerifier _captcha; + private const int BufferSize = 81920; public FileDownloadController( ILogger logger, IOptions fileStorageSettings, IContentTypeProvider contentTypeProvider, - IEmailSender emailSender) + IEmailSender emailSender, + ICaptchaVerifier captcha) { _logger = logger; _fileStorageSettings = fileStorageSettings.Value; _contentTypeProvider = contentTypeProvider; _emailSender = emailSender; + _captcha = captcha; } /// /// Downloads a file with support for resume (range requests) and chunked transfer. /// Supports HTTP 206 Partial Content for efficient downloads and resume capability. + /// Requires a valid reCAPTCHA v3 token on the initial (non-range) request. /// Sends email notification when download starts. /// - /// The name of the file to download (optional - uses default from settings if not provided) + /// The name of the file to download (optional - uses default from settings if not provided). + /// reCAPTCHA v3 token — required on the initial download request; omit on subsequent range requests. /// File stream with appropriate headers for resumable downloads [HttpGet("{fileName?}")] - [SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")] + [SwaggerOperation(Summary = "Download file", Description = "Downloads a file. Requires a reCAPTCHA v3 token on the initial request. Range requests for resume do not require a token.")] [SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")] [SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "No file name provided and no default configured")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Missing/invalid captcha token, no file name, or no default configured")] [SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")] [SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")] [SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")] @@ -58,10 +65,29 @@ namespace Api.Controllers [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] - public async Task DownloadFile(string? fileName = null) + public async Task DownloadFile(string? fileName = null, [FromQuery] string? captchaToken = null) { try { + // Captcha required on the initial (full) download only — range requests are resume continuations. + var isRangeRequest = !string.IsNullOrEmpty(Request.Headers[HeaderNames.Range].ToString()); + if (!isRangeRequest) + { + if (string.IsNullOrWhiteSpace(captchaToken)) + { + _logger.LogWarning("Download attempt without captcha token from IP={IP}", HttpContext.Connection.RemoteIpAddress); + return BadRequest(new ErrorResponse { Error = "Captcha token is required.", Code = "captcha_token_missing" }); + } + + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var verdict = await _captcha.VerifyAsync(captchaToken, userIp, "file_download", CancellationToken.None); + if (!verdict.Success) + { + _logger.LogWarning("Download blocked by captcha. IP={IP} Score={Score}", userIp, verdict.Score); + return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" }); + } + } + if (string.IsNullOrWhiteSpace(fileName)) { fileName = _fileStorageSettings.DefaultFileName;