چکلیست امنیت آپلود فایل در ASP.NET Core
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۱۲ ۱۸:۲۰
آدرس: www.dntips.ir
چکیده: امکان آپلود فایل یکی از ویژگیهای کلیدی و در عین حال مخاطرهآمیز در سامانههای وب مدرن از جمله سیستمهای بانکی، فینتک، بورس و سامانههای سازمانی به شمار میرود. عدم رعایت استاندارهای امنیتی در پردازش فایلهای دریافتی، برنامههای کاربردی را در معرض تهدیدات جدی نظیر اجرای از راه دور کد (RCE)، توزیع بدافزارها، حملات ازکاراندازی سرویس (DoS)، تزریق اسکریپت از طریق دامنه (Stored XSS) و افشای اطلاعات حساس قرار میدهد. این مقاله با تمرکز بر فریمورک ASP.NET Core، ضمن کالبدشکافی انواع بردارهای حمله مرتبط با آپلود فایل، راهکارهای عملی و کدنویسی امن بر پایه توصیههای OWASP را بررسی میکند. هدف این پژوهش، ارائه الگوی دفاع عمقی (Defense in Depth) جهت اطمینان از سلامت، اصالت و امنیت فایلهای دریافتی در اپلیکیشنهای Enterprise است.
invoice.pdf.aspx یا photo.php.png). در صورتی که سرور وب یا سرویس فایل از آخرین پسوند به درستی تحلیل نگند، ممکن است فایل را به عنوان یک اسکریپت اجرایی تفسیر و اجرا نماید.shell.aSp یا malware.ASPX) از لیست سیاه عبور میکند، در حالی که سرور ویندوز (IIS) هردو حالت را یکسان دانسته و کد را اجرا میکند.Content-Type توسط مرورگر ارسال میشود و بهراحتی با ابزارهایی مانند Burp Suite یا Postman قابل دستکاری است. فرستادن کدهای کلاینتسایدی یا شلهای اجرایی تحت هدر image/png یکی از متداولترین شیوههای دور زدن اعتبار سنجیهای سطح پایین است.../../../../web.config) یا اسکریپتهای مخرب (مانند .png) اقدام به بازنویسی فایلهای حیاتی سیستمعامل یا حملات Stored XSS نماید.| نوع فایل | پسوند مجاز | امضای جادویی (Hex Signature) | توضیحات |
| PNG Image | .png | 89 50 4E 47 0D 0A 1A 0A | امضای ثابت ۸ بایتی PNG |
| JPEG Image | .jpg, .jpeg | FF D8 FF | امضای ۳ بایتی JPEG |
| PDF Document | 25 50 44 46 | معادل عبارت %PDF در ASCII | |
| ZIP Archive / OpenXML | .zip, .docx, .xlsx | 50 4B 03 04 | امضای اصلی ساختار PKZIP |
public static class FileSecurityValidator
{
private static readonly Dictionary<string, List<byte[]>> AllowedSignatures =
new Dictionary<string, List<byte[]>>
{
{ ".png", new List<byte[]> { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } },
{ ".jpg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF } } },
{ ".jpeg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF } } },
{ ".pdf", new List<byte[]> { new byte[] { 0x25, 0x50, 0x44, 0x46 } } }
};
public static async Task<bool> IsValidFileSignatureAsync(IFormFile file, string extension)
{
if (file == null || file.Length == 0) return false;
if (!AllowedSignatures.ContainsKey(extension)) return false;
using var stream = file.OpenReadStream();
using var reader = new BinaryReader(stream);
var maxSignatureSize = AllowedSignatures[extension].Max(s => s.Length);
var headerBytes = reader.ReadBytes(maxSignatureSize);
return AllowedSignatures[extension].Any(signature =>
headerBytes.Take(signature.Length).SequenceEqual(signature));
}
}[RequestSizeLimit] یا کانفیگ Kestrel محدود کنید تا از حملات DoS جلوگیری شود.[HttpPost("upload")]
[RequestSizeLimit(10 * 1024 * 1024)] // محدودیت ۱۰ مگابایت
public async Task<IActionResult> UploadFile(IFormFile file)
{
// منطق پردازش
return Ok();
}Guid جدید تغییر یابد. نام اصلی فایل صرفاً به عنوان متاداده در پایگاهداده ذخیره شود.string fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
string safeFileName = $"{Guid.NewGuid():N}{fileExtension}";wwwroot یا مسیرهای مستقیم وبسرور نگهداری شوند. بهترین روش، استفاده از سرویسهای ذخیرهسازی مستقل مانند Network Attached Storage (NAS)، Azure Blob Storage، Amazon S3 یا MinIO است.Rate Limiting Middleware در ASP.NET Core 7+ یا API Gateway، تعداد درخواستهای آپلود به ازای هر IP / کاربر را محدود کنید.[HttpGet("download/{fileId}")]
[Authorize]
public async Task<IActionResult> DownloadFile(Guid fileId)
{
var fileMetadata = await _dbContext.Files.FindAsync(fileId);
if (fileMetadata == null) return NotFound();
// بررسی سطح دسترسی کاربر جاری
if (!UserHasAccessToFile(User, fileMetadata))
return Forbid();
var fileStream = await _storageService.GetFileStreamAsync(fileMetadata.StoredFileName);
return File(fileStream, fileMetadata.ContentType, fileMetadata.OriginalFileName);
}User ID، IP Address، Timestamp، File Hash (SHA-256) و Correlation ID ثبت (Log) شوند.// Program.cs
var builder = WebApplication.CreateBuilder(args);
// ۱. تنظیم محدودیتهای Kestrel در سطح سرور
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 524_288_000; // ۵۰۰ مگابایت حداکثر حجم Body
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(10);
});
// ۲. تنظیم FormOptions جهت جلوگیری از مصرف بیرویه RAM
builder.Services.Configure<FormOptions>(options =>
{
options.ValueLengthLimit = 1024 * 1024; // ۱ مگابایت حداکثر برای هر Value در فرم
options.MultipartBodyLengthLimit = 524_288_000; // ۵۰۰ مگابایت حداکثر حجم فایل
options.MultipartHeadersLengthLimit = 32_768; // ۳۲ کیلوبایت حداکثر هدر
options.MemoryBufferThreshold = 64 * 1024; // فایلهای بالای ۶۴ کیلوبایت مستقیماً روی دیسک Buffer شوند
});
// ۳. اعمال محدودیت تعداد درخواست (Rate Limiting) بر اساس شناسه کاربر/IP
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("UploadPolicy", context =>
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? context.Connection.RemoteIpAddress?.ToString() ?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(userId, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10, // مجاز: ۱۰ آپلود
Window = TimeSpan.FromMinutes(1), // در هر دقیقه
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 2 // حداکثر ۲ درخواست در صف
});
});
});MemoryBufferThreshold: تنظیم این مقدار روی 64KB باعث میشود فایلهای بزرگ بهجای بارگذاری کامل در RAM، روی دیسک موقت بافر شوند و از Crash کردن AppPool جلوگیری شود.public class UploadedFile
{
public Guid Id { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredFileName { get; set; } = string.Empty; // UUID
public string ContentType { get; set; } = string.Empty;
public string Extension { get; set; } = string.Empty;
public long FileSize { get; set; }
public string StoragePath { get; set; } = string.Empty;
public string StorageProvider { get; set; } = string.Empty; // e.g., "AzureBlob", "S3", "MinIO"
public FileStatus Status { get; set; } = FileStatus.Pending;
public string? ScanResult { get; set; }
public DateTime? ScannedAt { get; set; }
public string Checksum { get; set; } = string.Empty; // SHA-256 جهت بررسی یکپارچگی
public string UploadedBy { get; set; } = string.Empty;
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
public DateTime? ExpiresAt { get; set; } // نگهداری بر اساس سیاست retention
}
public enum FileStatus
{
Pending, // آپلود شده، در انتظار اسکن
Scanning, // در حال اسکن توسط آنتیویروس
Clean, // اسکن موفق، آماده پردازش
Infected, // بدافزار شناسایی شد (قرنطینه)
Processing, // در حال پردازش (تولید Thumbnail یا Transcode)
Ready, // پردازش کامل و آماده دانلود
Expired // منقضی شده
}public interface IFileValidator
{
ValidationResult Validate(IFormFile file);
}
public sealed class FileValidator : IFileValidator
{
private readonly ILogger<FileValidator> _logger;
private static readonly Dictionary<string, byte[]> FileSignatures = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = new byte[] { 0xFF, 0xD8, 0xFF },
[".jpeg"] = new byte[] { 0xFF, 0xD8, 0xFF },
[".png"] = new byte[] { 0x89, 0x50, 0x4E, 0x47 },
[".gif"] = new byte[] { 0x47, 0x49, 0x46, 0x38 },
[".pdf"] = new byte[] { 0x25, 0x50, 0x44, 0x46 },
[".docx"] = new byte[] { 0x50, 0x4B, 0x03, 0x04 },
[".xlsx"] = new byte[] { 0x50, 0x4B, 0x03, 0x04 },
[".mp4"] = new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70 }
};
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".pdf", ".docx", ".xlsx", ".mp4"
};
private const long MaxFileSize = 500 * 1024 * 1024; // 500MB
private const long MaxImageSize = 10 * 1024 * 1024; // 10MB
public FileValidator(ILogger<FileValidator> logger) => _logger = logger;
public ValidationResult Validate(IFormFile file)
{
var errors = new List<string>();
// ۱. بررسی عدم خالی بودن و حجم کلی
if (file == null || file.Length == 0)
return ValidationResult.Failure(new[] { "فایل ارسال شده خالی است." });
if (file.Length > MaxFileSize)
errors.Add($"حجم فایل بیش از حد مجاز ({MaxFileSize / 1024 / 1024}MB) است.");
// ۲. اعتبارسنجی پسوند
var extension = Path.GetExtension(file.FileName);
if (string.IsNullOrEmpty(extension) || !AllowedExtensions.Contains(extension))
{
_logger.LogWarning("تلاش برای آپلود پسوند غیرمجاز: {Extension}", extension);
return ValidationResult.Failure(new[] { $"پسوند '{extension}' مجاز نیست." });
}
// محدودیت سختگیرانهتر برای تصاویر
if (IsImage(extension) && file.Length > MaxImageSize)
errors.Add($"حجم تصاویر نباید بیش از {MaxImageSize / 1024 / 1024}MB باشد.");
// ۳. تطابق MIME Type ادعا شده با پسوند
var expectedMimeType = GetMimeTypeForExtension(extension);
if (!string.Equals(file.ContentType, expectedMimeType, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("عدم تطابق MIME Type: انتظار میرفت {Expected} اما {Actual} دریافت شد.", expectedMimeType, file.ContentType);
errors.Add("نوع Content-Type با پسوند فایل همخوانی ندارد.");
}
// ۴. اعتبارسنجی امضای بایتی (Magic Numbers)
if (!ValidateMagicNumber(file, extension))
{
_logger.LogWarning("اعتبارسنجی امضای بایتی ناپایدار بود: {FileName}", file.FileName);
errors.Add("محتوای واقعی فایل با پسوند آن همخوانی ندارد (احتمال Spoofing).");
}
return errors.Any() ? ValidationResult.Failure(errors) : ValidationResult.Success();
}
private static bool ValidateMagicNumber(IFormFile file, string extension)
{
if (!FileSignatures.TryGetValue(extension, out var signature))
return false;
using var stream = file.OpenReadStream();
var header = new byte[signature.Length];
var bytesRead = stream.Read(header, 0, signature.Length);
return bytesRead == signature.Length && header.SequenceEqual(signature);
}
private static bool IsImage(string ext) => ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase) ||
ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) ||
ext.Equals(".png", StringComparison.OrdinalIgnoreCase) ||
ext.Equals(".gif", StringComparison.OrdinalIgnoreCase);
private static string GetMimeTypeForExtension(string ext) => ext.ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".pdf" => "application/pdf",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".mp4" => "video/mp4",
_ => "application/octet-stream"
};
}
public record ValidationResult(bool IsValid, IReadOnlyList<string> Errors)
{
public static ValidationResult Success() => new(true, Array.Empty<string>());
public static ValidationResult Failure(IEnumerable<string> errors) => new(false, errors.ToList());
}MemoryStream باعث Overload شدن GC و Crash کردن برنامه میشود. حتماً از Stream و Bufferهای محدود روی دیسک استفاده کنید.