Chức năng tải lên file là một tính năng phổ biến trong các ứng dụng web, nhưng nó cũng là một trong những vector tấn công phổ biến nhất. Một đánh giá bảo mật gần đây của các ứng dụng của chúng tôi đã tiết lộ một số lỗ hổng trong xử lý tải lên file cần được chú ý. Đây là bài viết đầu tiên trong một loạt bài mà tôi sẽ chia sẻ cách chúng tôi hệ thống hóa việc bảo mật chức năng tải lên file của mình.
Mục lục
Vấn đề
Vấn đề cơ bản với việc tải lên file là tin tưởng. Khi người dùng tải lên file, về cơ bản chúng ta đang cho phép họ lưu trữ nội dung trên máy chủ của chúng ta. Không có xác thực phù hợp, kẻ tấn công có thể:
- Tải lên các script độc hại ngụy trang dưới dạng file vô hại
- Vượt qua các kiểm soát bảo mật bằng cách thao túng phần mở rộng file
- Thực thi mã phía server thông qua các payload được thiết kế
- Tiêu thụ tài nguyên server quá mức với file có kích thước lớn
Phòng thủ đầu tiên? Kiểm tra loại nội dung.
Cách tiếp cận của chúng tôi: Pipeline xác thực
Thay vì triển khai các kiểm tra xác thực ad-hoc rải rác khắp codebase, chúng tôi đã thiết kế một pipeline xác thực sạch sẽ, có thể mở rộng. Cách tiếp cận này cho phép chúng tôi:
- Dễ dàng thêm các bước xác thực mới
- Duy trì logic xác thực nhất quán trên toàn ứng dụng
- Kiểm tra từng bước xác thực độc lập
- Thất bại nhanh khi bất kỳ bước xác thực nào thất bại
Đây là cấu trúc cốt lõi:
public interface IValidationStep<br>
{<br>
Task<ValidationResult> ValidateAsync(IFormFile file, CancellationToken token);<br>
}<br>
public class ValidationResult<br>
{<br>
[MemberNotNullWhen(false, nameof(ErrorResult))]<br>
public bool IsValid { get; }<br>
public IActionResult? ErrorResult { get; }<br><br>
private ValidationResult(bool isValid, IActionResult? errorResult = null)<br>
{<br>
IsValid = isValid;<br>
ErrorResult = errorResult;<br>
}<br><br>
// Sử dụng một thể hiện static readonly cho kết quả hợp lệ để giảm bộ nhớ.<br>
private static readonly ValidationResult Valid = new(true);<br><br>
public static ValidationResult Success() => Valid;<br>
public static ValidationResult Fail(IActionResult errorResult, string reason = "") => new(false, errorResult);<br>
}<br>
Kiểm tra loại nội dung: Cổng kiểm tra đầu tiên
Kiểm tra loại nội dung là điểm kiểm tra đầu tiên của chúng tôi. Nó đảm bảo rằng chỉ những file với loại MIME được phê duyệt mới có thể vào hệ thống của chúng tôi. Đây là triển khai của chúng tôi:
public sealed class ContentTypeValidationStep : IValidationStep<br>
{<br>
private readonly SupportedContentTypes _supportedContentTypes;<br>
private readonly ILogger _logger;<br><br>
public ContentTypeValidationStep(SupportedContentTypes supportedContentTypes, ILogger logger)<br>
{<br>
_supportedContentTypes = supportedContentTypes;<br>
_logger = logger;<br>
}<br><br>
public Task<ValidationResult> ValidateAsync(IFormFile file, CancellationToken token)<br>
{<br>
if (string.IsNullOrEmpty(file.ContentType))<br>
{<br>
_logger.LogWarning("ContentTypeValidator: Không tìm thấy loại nội dung cho file {FileName}.", file.FileName);<br>
return Task.FromResult(ValidationResult.Fail(new UnsupportedMediaTypeResult()));<br>
}<br><br>
// Làm phẳng các giá trị từ điển thành một danh sách các loại nội dung<br>
var contentTypes = _supportedContentTypes.Values.SelectMany(x => x).ToList();<br><br>
// Kiểm tra xem có loại MIME nào trong mảng khớp với file.ContentType không<br>
if (!contentTypes.Any(type => type.Equals(file.ContentType, StringComparison.OrdinalIgnoreCase)))<br>
{<br>
_logger.LogWarning("ContentTypeValidator: File {FileName} có loại nội dung '{ContentType}' không có trong danh sách các loại nội dung được hỗ trợ.", file.FileName, file.ContentType);<br>
return Task.FromResult(ValidationResult.Fail(new UnsupportedMediaTypeResult()));<br>
}<br>
return Task.FromResult(ValidationResult.Success());<br>
}<br>
}<br>
Đây là những quyết định thiết kế chính mà chúng tôi đã thực hiện khi phát triển tính năng này:
1. Danh sách cho phép rõ ràng
Chúng tôi sử dụng cấu hình SupportedContentTypes xác định rõ ràng các loại MIME nào được phép. Điều này an toàn hơn nhiều so với việc cố gắng chặn các loại nguy hiểm đã biết, vì các vector tấn công mới liên tục xuất hiện.
2. So sánh không phân biệt chữ hoa chữ thường
Các loại MIME được so sánh bằng StringComparison.OrdinalIgnoreCase để xử lý các biến thể về viết hoa có thể được sử dụng để vượt qua khớp chuỗi ngây thơ. Một sai lầm dễ mắc phải mà chúng tôi đã mắc quá nhiều trước đây…
3. Ghi log toàn diện
Mọi lần xác thực thất bại đều được ghi log với ngữ cảnh. Điều này giúp gỡ lỗi các lần tải lên thất bại hợp lệ và cung cấp dấu vết kiểm toán cho các sự cố bảo mật tiềm ẩn.
4. An toàn null
Mã kiểm tra rõ ràng các loại nội dung null hoặc rỗng, có thể xảy ra với các yêu cầu bị lỗi hoặc một số hành vi trình duyệt nhất định. Không có gì đặc biệt, nhưng chúng tôi muốn ở phía an toàn.
Pipeline hoàn chỉnh
Bước kiểm tra loại nội dung phù hợp trong một pipeline xác thực rộng hơn:
public sealed class ValidationPipeline<br>
{<br>
private readonly IEnumerable<IValidationStep> _steps;<br><br>
public ValidationPipeline(IEnumerable<IValidationStep> steps)<br>
{<br>
_steps = steps;<br>
}<br><br>
public async Task<ValidationResult> ValidateAsync(IFormFile file, CancellationToken token)<br>
{<br>
foreach (var step in _steps)<br>
{<br>
var result = await step.ValidateAsync(file, token);<br>
if (!result.IsValid)<br>
return result; // Thất bại nhanh<br>
}<br>
return ValidationResult.Success();<br>
}<br><br>
public static ValidationPipeline CreateDefaultPipeline(ILogger logger)<br>
{<br>
const long maxFileSize = 128L * 1024 * 1024; // 128 MB<br>
var pipeline = new ValidationPipeline(<br>
[<br>
new FileSizeValidationStep(maxFileSize, logger),<br>
new ContentTypeValidationStep(SupportedTypes, logger),<br>
new FileSignatureValidationStep(SupportedTypes, logger),<br>
new MalwareScanValidationStep(logger)<br>
]);<br>
return pipeline;<br>
}<br>
}<br>
Tại sao kiểm tra loại nội dung là không đủ
Điều quan trọng là phải hiểu rằng kiểm tra loại nội dung không phải là một giải pháp hoàn chỉnh. Header Content-Type được kiểm soát bởi client và có thể dễ dàng bị giả mạo. Một kẻ tấn công có thể tải lên một script PHP độc hại nhưng đặt loại nội dung thành image/jpeg.
Kiểm tra loại nội dung cung cấp lớp phòng thủ đầu tiên của chúng tôi, nhưng như đã đề cập, nó không đủ một mình. Trong các bài viết tiếp theo, chúng tôi sẽ đi sâu vào:
- Kiểm tra chữ ký file: Xác minh file thực sự là những gì chúng tuyên bố
- Giới hạn kích thước file: Ngăn chặn các cuộc tấn công cạn kiệt tài nguyên
- Quét mã độc: Lưới an toàn cuối cùng chống lại nội dung độc hại
- Bảo mật lưu trữ: Xử lý an toàn các file đã tải lên
Hãy theo dõi bài viết tiếp theo, nơi chúng tôi sẽ khám phá kiểm tra chữ ký file và cách phát hiện các file nói dối về định dạng thực sự của chúng.



