ASP.NET Core에서 파일 업로드

러트거 스톰

ASP.NET Core는 소용량 파일의 경우에는 버퍼링된 모델 바인딩을 사용하여 하나 이상의 파일을 업로드하고, 대용량 파일의 경우에는 버퍼링되지 않은 스트리밍을 지원합니다.

샘플 코드 보기 및 다운로드(다운로드 방법)

보안 고려 사항

사용자에게 서버에 파일을 업로드하는 기능을 제공할 때는 주의해야 합니다. 공격자는 다음을 시도할 수 있습니다.

  • 서비스 거부 공격을 실행.
  • 바이러스 또는 맬웨어를 업로드.
  • 다른 방법으로 네트워크 및 서버를 손상.

공격이 성공할 가능성을 줄이는 보안 단계는 다음과 같습니다.

  • 전용 파일 업로드 영역(바람직하게는 시스템 드라이브가 아닌 위치)에 파일을 업로드합니다. 전용 위치를 사용하면 업로드된 파일에 대한 보안 제한을 더 쉽게 적용할 수 있습니다. 파일 업로드 위치에 대한 실행 권한을 사용하지 않도록 설정합니다.†
  • 업로드된 파일을 앱과 동일한 디렉터리 트리에 보관하지 마세요.
  • 앱에 의해 결정된 안전한 파일 이름을 사용합니다. 사용자가 제공한 파일 이름이나 업로드된 파일의 신뢰할 수 없는 파일 이름을 사용하지 마세요.† HTML은 이름을 표시할 때 신뢰할 수 없는 파일 이름일 경우 이를 인코딩합니다. 파일 이름을 로깅하거나 UI에 표시하는 경우를 예로 들 수 있습니다(Razor에서 자동으로 출력을 HTML로 인코딩).
  • 앱의 디자인 사양으로 승인된 파일 확장명만 허용합니다.†
  • 서버에서 클라이언트 쪽 검사가 수행되는지 확인합니다.† 클라이언트 쪽 검사는 쉽게 우회할 수 있습니다.
  • 업로드된 파일의 크기를 확인합니다. 대규모로 업로드되지 않도록 최대 크기 제한을 설정합니다.†
  • 업로드된 파일이 같은 이름의 파일을 덮어쓰면 안 되는 경우 파일을 업로드하기 전에 데이터베이스 또는 실제 스토리지에서 파일 이름을 확인합니다.
  • 파일이 저장되기 전에 업로드된 콘텐츠에 대해 바이러스/맬웨어 스캐너를 실행합니다.

†샘플 앱은 조건을 충족하는 방법을 보여 줍니다.

Warning

시스템에 악성 코드를 업로드하는 행위는 흔히 다음을 수행할 수 있는 코드를 실행하기 위한 첫 단계가 됩니다.

  • 시스템을 완전히 제어합니다.
  • 시스템 작동이 중단되는 결과로 시스템을 오버로드합니다.
  • 사용자 또는 시스템 데이터를 손상시킵니다.
  • 공용 UI에 그래피티를 적용합니다.

사용자의 파일을 수락할 때 공격 노출 영역을 줄이는 방법에 대한 자세한 내용은 다음 리소스를 참조하세요.

샘플 앱의 예제를 포함하여 보안 조치를 구현하는 방법에 대한 자세한 내용은 유효성 검사 섹션을 참조하세요.

스토리지 시나리오

파일에 대한 일반적인 스토리지 옵션은 다음과 같습니다.

  • Database

    • 작은 파일 업로드의 경우 데이터베이스가 실제 스토리지(파일 시스템 또는 네트워크 공유) 옵션보다 빠른 경우가 많습니다.
    • 사용자 데이터에 대한 데이터베이스 레코드를 검색할 때 파일 콘텐츠(예: 아바타 이미지)를 동시에 제공할 수 있으므로 데이터베이스는 실제 스토리지 옵션보다 편리합니다.
    • 데이터베이스는 클라우드 데이터 스토리지 서비스를 사용하는 것보다 비용이 적게 들 수 있습니다.
  • 실제 스토리지(파일 시스템 또는 네트워크 공유)

    • 대용량 파일 업로드의 경우:
      • 데이터베이스 한도 때문에 업로드 크기가 제한될 수 있습니다.
      • 실제 스토리지는 데이터베이스 스토리지보다 경제적이지 않은 경우가 자주 있습니다.
    • 실제 스토리지는 클라우드 데이터 스토리지 서비스를 사용하는 것보다 비용이 적게 들 수 있습니다.
    • 앱의 프로세스에는 스토리지 위치에 대한 읽기 및 쓰기 권한이 있어야 합니다. 실행 권한을 부여하지 마세요.
  • 클라우드 데이터 스토리지 서비스(예: Azure Blob Storage)

    • 서비스는 일반적으로 단일 실패 지점에 노출되는 온-프레미스 솔루션에 비해 향상된 확장성 및 복원력을 제공합니다.
    • 서비스는 대용량 스토리지 인프라 시나리오에서 비용이 더 저렴할 수 있습니다.

    자세한 내용은 빠른 시작: .NET을 사용하여 개체 스토리지에 Blob 만들기를 참조하세요.

작은 파일과 대용량 파일

크고 작은 파일의 정의는 사용 가능한 컴퓨팅 리소스에 따라 달라집니다. 앱은 예상 크기를 처리하기 위해 사용되는 스토리지 접근 방식을 벤치마킹해야 합니다. 메모리, CPU, 디스크 및 데이터베이스 성능을 벤치마킹합니다.

배포에 대해 어떤 것이 작고 큰지에 대한 특정 경계를 제공할 수는 없지만 FormOptions를 위한 AspNetCore의 관련 기본값은 다음과 같습니다.

  • 기본적으로 HttpRequest.Form은 전체 요청 본문(BufferBody)을 버퍼링하지 않지만 포함된 모든 다중 파트 양식 파일을 버퍼링합니다.
  • MultipartBodyLengthLimit은 버퍼링된 양식 파일의 최대 크기이며 기본값은 128MB입니다.
  • MemoryBufferThreshold는 디스크의 버퍼 파일로 전환하기 전에 메모리의 파일을 버퍼링할 양을 나타내며, 기본값은 64KB입니다. MemoryBufferThreshold는 작은 파일과 대용량 파일 간의 경계 역할을 하며 앱 리소스 및 시나리오에 따라 올라가거나 내려갑니다.

FormOptions에 대한 자세한 내용은 소스 코드를 참조하세요.

파일 업로드 시나리오

파일 업로드를 위한 일반적인 방법 두 가지는 버퍼링 및 스트리밍입니다.

버퍼링

전체 파일을 IFormFile로 읽습니다. IFormFile은 파일을 처리하거나 저장하는 데 사용되는 파일의 C# 표현입니다.

파일 업로드에 사용되는 디스크 및 메모리는 동시 파일 업로드의 수와 크기에 따라 달라집니다. 앱이 너무 많은 업로드를 버퍼링하려 할 경우 메모리 또는 디스크 공간이 부족하면 사이트의 작동이 중단됩니다. 파일 업로드의 크기 또는 빈도로 인해 앱 리소스가 소진되는 경우 스트리밍을 사용합니다.

버퍼링된 단일 파일이 64KB를 초과하는 경우 메모리에서 디스크의 임시 파일로 이동됩니다.

더 큰 요청에 대한 임시 파일은 ASPNETCORE_TEMP 환경 변수에 명명된 위치에 기록됩니다. ASPNETCORE_TEMP가 정의되지 않은 경우 파일은 현재 사용자의 임시 폴더에 기록됩니다.

소용량 파일 버퍼링은 이 항목의 다음 섹션에서 설명합니다.

스트리밍

파일은 다중 파트 요청에서 수신되며 앱에서 직접 처리하거나 저장합니다. 스트리밍은 성능을 크게 개선하지 않습니다. 스트리밍을 통해 파일을 업로드하면 메모리 또는 디스크 공간에 대한 요구가 줄어듭니다.

대용량 파일 스트리밍은 스트리밍을 사용하여 대용량 파일 업로드 섹션에서 설명합니다.

버퍼링된 모델 바인딩을 사용하여 소용량 파일을 실제 스토리지에 업로드

소용량 파일을 업로드하려면 다중 파트 양식을 사용하거나 JavaScript를 사용하여 POST 요청을 생성합니다.

다음 예제에서는 페이지 양식을 사용하여 Razor 샘플 앱에서 단일 파일을Pages/BufferedSingleFileUploadPhysical.cshtml 업로드하는 방법을 보여 줍니다.

<form enctype="multipart/form-data" method="post">
    <dl>
        <dt>
            <label asp-for="FileUpload.FormFile"></label>
        </dt>
        <dd>
            <input asp-for="FileUpload.FormFile" type="file">
            <span asp-validation-for="FileUpload.FormFile"></span>
        </dd>
    </dl>
    <input asp-page-handler="Upload" class="btn" type="submit" value="Upload" />
</form>

다음 예제는 이전 예제와 비슷하지만 다음과 같은 차이가 있습니다.

  • 양식의 데이터를 제출하는 데 JavaScript(Fetch API)가 사용됩니다.
  • 유효성 검사를 수행하지 않습니다.
<form action="BufferedSingleFileUploadPhysical/?handler=Upload" 
      enctype="multipart/form-data" onsubmit="AJAXSubmit(this);return false;" 
      method="post">
    <dl>
        <dt>
            <label for="FileUpload_FormFile">File</label>
        </dt>
        <dd>
            <input id="FileUpload_FormFile" type="file" 
                name="FileUpload.FormFile" />
        </dd>
    </dl>

    <input class="btn" type="submit" value="Upload" />

    <div style="margin-top:15px">
        <output name="result"></output>
    </div>
</form>

<script>
  async function AJAXSubmit (oFormElement) {
    var resultElement = oFormElement.elements.namedItem("result");
    const formData = new FormData(oFormElement);

    try {
    const response = await fetch(oFormElement.action, {
      method: 'POST',
      body: formData
    });

    if (response.ok) {
      window.location.href = '/';
    }

    resultElement.value = 'Result: ' + response.status + ' ' + 
      response.statusText;
    } catch (error) {
      console.error('Error:', error);
    }
  }
</script>

Fetch API를 지원하지 않는 클라이언트에 대해 JavaScript로 양식 POST를 수행하려면 다음 방법 중 하나를 사용합니다.

  • Fetch Polyfill(예: window.fetch polyfill (github/fetch))을 사용합니다.

  • XMLHttpRequest을 사용합니다. 예시:

    <script>
      "use strict";
    
      function AJAXSubmit (oFormElement) {
        var oReq = new XMLHttpRequest();
        oReq.onload = function(e) { 
        oFormElement.elements.namedItem("result").value = 
          'Result: ' + this.status + ' ' + this.statusText;
        };
        oReq.open("post", oFormElement.action);
        oReq.send(new FormData(oFormElement));
      }
    </script>
    

파일 업로드를 지원하려면 HTML 양식에서 multipart/form-data의 인코딩 유형(enctype)을 지정해야 합니다.

files 입력 요소가 다중 파일 업로드를 지원하려면 <input> 요소에 multiple 특성을 제공합니다.

<input asp-for="FileUpload.FormFiles" type="file" multiple>

서버에 업로드된 개별 파일은 IFormFile을 사용하여 모델 바인딩을 통해 액세스할 수 있습니다. 샘플 앱은 데이터베이스 및 실제 스토리지 시나리오에 대한 다중 버퍼링된 파일 업로드를 보여 줍니다.

Warning

표시 및 로깅 이외에는 IFormFileFileName 속성을 사용하지 마세요. 표시하거나 로깅할 경우 파일 이름을 HTML로 인코딩합니다. 공격자는 전체 경로나 상대 경로를 포함하여 악의적인 파일 이름을 제공할 수 있습니다. 애플리케이션에서 다음을 수행해야 합니다.

  • 사용자가 제공한 파일 이름에서 경로를 제거합니다.
  • UI 또는 로깅을 위해 HTML로 인코딩되고 경로가 제거된 파일 이름을 저장합니다.
  • 스토리지의 새 임의 파일 이름을 생성합니다.

다음 코드는 파일 이름에서 경로를 제거합니다.

string untrustedFileName = Path.GetFileName(pathName);

지금까지 제시한 예제에서는 보안 고려 사항을 감안하지 않습니다. 추가 정보는 다음 섹션과 샘플 앱에서 제공합니다.

모델 바인딩 및 IFormFile을 사용하여 파일을 업로드하는 경우 작업 메서드에서 다음을 허용할 수 있습니다.

참고 항목

바인딩은 이름을 기준으로 양식 파일을 일치시킵니다. 예를 들어 <input type="file" name="formFile">의 HTML name 값은 바인딩된 C# 매개 변수/속성(FormFile)과 일치해야 합니다. 자세한 내용은 이름 특성 값을 POST 메서드의 매개 변수 이름과 일치 섹션을 참조하세요.

다음 예제를 참조하세요.

  • 하나 이상의 업로드된 파일을 반복합니다.
  • Path.GetTempFileName을 사용하여 파일 이름을 포함하는 파일 전체 경로를 반환합니다.
  • 앱에서 생성한 파일 이름을 사용하여 로컬 파일 시스템에 파일을 저장합니다.
  • 업로드된 파일의 총 수와 크기를 반환합니다.
public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> files)
{
    long size = files.Sum(f => f.Length);

    foreach (var formFile in files)
    {
        if (formFile.Length > 0)
        {
            var filePath = Path.GetTempFileName();

            using (var stream = System.IO.File.Create(filePath))
            {
                await formFile.CopyToAsync(stream);
            }
        }
    }

    // Process uploaded files
    // Don't rely on or trust the FileName property without validation.

    return Ok(new { count = files.Count, size });
}

Path.GetRandomFileName을 사용하여 경로 없이 파일 이름을 생성합니다. 다음 예제에서는 경로를 구성에서 가져옵니다.

foreach (var formFile in files)
{
    if (formFile.Length > 0)
    {
        var filePath = Path.Combine(_config["StoredFilesPath"], 
            Path.GetRandomFileName());

        using (var stream = System.IO.File.Create(filePath))
        {
            await formFile.CopyToAsync(stream);
        }
    }
}

FileStream에 전달된 경로는 반드시 파일 이름을 포함해야 합니다. 파일 이름을 제공하지 않으면 런타임에 UnauthorizedAccessException이 발생합니다.

IFormFile 기술을 사용하여 업로드된 파일은 처리 전에 서버의 메모리나 디스크에 버퍼링됩니다. 작업 메서드 내부에서 IFormFile 내용을 Stream으로 액세스할 수 있습니다. 로컬 파일 시스템 외에도 파일을 네트워크 공유 또는 파일 스토리지 서비스(예: Azure Blob Storage)에 저장할 수 있습니다.

업로드를 위해 여러 파일을 반복하고 안전한 파일 이름을 사용하는 또 다른 예제는 샘플 앱을 참조 Pages/BufferedMultipleFileUploadPhysical.cshtml.cs 하세요.

Warning

이전 임시 파일을 삭제하지 않고 65,535개를 초과하는 파일을 만들면 Path.GetTempFileNameIOException을 throw합니다. 65535개 파일 제한은 서버당 제한입니다. Windows OS에서 이 제한에 대한 자세한 내용은 다음 항목의 설명을 참조하세요.

버퍼링된 모델 바인딩을 사용하여 소용량 파일을 데이터베이스에 업로드

Entity Framework를 사용하여 데이터베이스에 이진 파일 데이터를 저장하려면 엔터티에서 Byte 배열 속성을 정의합니다.

public class AppFile
{
    public int Id { get; set; }
    public byte[] Content { get; set; }
}

IFormFile을 포함하는 클래스에 대한 페이지 모델 속성을 지정합니다.

public class BufferedSingleFileUploadDbModel : PageModel
{
    ...

    [BindProperty]
    public BufferedSingleFileUploadDb FileUpload { get; set; }

    ...
}

public class BufferedSingleFileUploadDb
{
    [Required]
    [Display(Name="File")]
    public IFormFile FormFile { get; set; }
}

참고 항목

IFormFile은 작업 메서드 매개 변수 또는 바운딩 모델 속성으로 직접 사용할 수 있습니다. 위 예제에서는 바인딩된 모델 속성을 사용합니다.

FileUpload는 Razor Pages 양식에 사용됩니다.

<form enctype="multipart/form-data" method="post">
    <dl>
        <dt>
            <label asp-for="FileUpload.FormFile"></label>
        </dt>
        <dd>
            <input asp-for="FileUpload.FormFile" type="file">
        </dd>
    </dl>
    <input asp-page-handler="Upload" class="btn" type="submit" value="Upload">
</form>

양식이 서버에 POST되면 IFormFile을 스트림으로 복사하여 데이터베이스에 바이트 배열로 저장합니다. 다음 예제에서 _dbContext는 앱의 데이터베이스 컨텍스트를 저장합니다.

public async Task<IActionResult> OnPostUploadAsync()
{
    using (var memoryStream = new MemoryStream())
    {
        await FileUpload.FormFile.CopyToAsync(memoryStream);

        // Upload the file if less than 2 MB
        if (memoryStream.Length < 2097152)
        {
            var file = new AppFile()
            {
                Content = memoryStream.ToArray()
            };

            _dbContext.File.Add(file);

            await _dbContext.SaveChangesAsync();
        }
        else
        {
            ModelState.AddModelError("File", "The file is too large.");
        }
    }

    return Page();
}

위의 예제는 샘플 앱에서 보여 주는 시나리오와 비슷합니다.

  • Pages/BufferedSingleFileUploadDb.cshtml
  • Pages/BufferedSingleFileUploadDb.cshtml.cs

Warning

관계형 데이터베이스에 이진 데이터를 저장할 경우 성능에 나쁜 영향을 줄 수 있으므로 주의하세요.

유효성 검사 없이 IFormFileFileName 속성을 의존하거나 신뢰하지 마세요. FileName 속성은 반드시 HTML 인코딩 후 표시 목적으로만 사용해야 합니다.

제시한 예제에서는 보안 고려 사항을 감안하지 않습니다. 추가 정보는 다음 섹션과 샘플 앱에서 제공합니다.

스트리밍을 사용하여 대용량 파일 업로드

3.1 예제는 JavaScript를 사용하여 파일을 컨트롤러 작업으로 스트리밍하는 방법을 보여 줍니다. 사용자 지정 필터 특성을 사용하여 파일의 위조 방지 토큰이 생성되고 요청 본문 대신 클라이언트 HTTP 헤더에 전달됩니다. 작업 메서드에서 업로드된 데이터를 직접 처리하므로 다른 사용자 지정 필터에서 형식 모델 바인딩을 사용할 수 없습니다. 작업 내에서 양식의 콘텐츠는 각 개별 MultipartSection을 읽고 적절하게 파일을 처리하거나 콘텐츠를 저장하는 MultipartReader를 사용하여 읽습니다. 다중 파트 섹션을 읽은 후 작업에서 자체 모델 바인딩을 수행합니다.

초기 페이지 응답에서는 양식을 로드하고 위조 방지 토큰을 cookie에 저장합니다(GenerateAntiforgeryTokenCookieAttribute 특성을 통해). 이 특성은 ASP.NET Core의 기본 제공 위조 방지 지원을 사용하여 요청 토큰으로 cookie를 설정합니다.

public class GenerateAntiforgeryTokenCookieAttribute : ResultFilterAttribute
{
    public override void OnResultExecuting(ResultExecutingContext context)
    {
        var antiforgery = context.HttpContext.RequestServices.GetService<IAntiforgery>();

        // Send the request token as a JavaScript-readable cookie
        var tokens = antiforgery.GetAndStoreTokens(context.HttpContext);

        context.HttpContext.Response.Cookies.Append(
            "RequestVerificationToken",
            tokens.RequestToken,
            new CookieOptions() { HttpOnly = false });
    }

    public override void OnResultExecuted(ResultExecutedContext context)
    {
    }
}

DisableFormValueModelBindingAttribute은 모델 바인딩을 사용하지 않도록 설정하는 데 사용됩니다.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        var factories = context.ValueProviderFactories;
        factories.RemoveType<FormValueProviderFactory>();
        factories.RemoveType<FormFileValueProviderFactory>();
        factories.RemoveType<JQueryFormValueProviderFactory>();
    }

    public void OnResourceExecuted(ResourceExecutedContext context)
    {
    }
}

샘플 앱에서 GenerateAntiforgeryTokenCookieAttributeDisableFormValueModelBindingAttributeRazor Pages 규칙을 사용하여 Startup.ConfigureServices/StreamedSingleFileUploadDb/StreamedSingleFileUploadPhysical의 페이지 애플리케이션 모델에 필터로 적용됩니다.

services.AddRazorPages(options =>
{
    options.Conventions
        .AddPageApplicationModelConvention("/StreamedSingleFileUploadDb",
            model =>
            {
                model.Filters.Add(
                    new GenerateAntiforgeryTokenCookieAttribute());
                model.Filters.Add(
                    new DisableFormValueModelBindingAttribute());
            });
    options.Conventions
        .AddPageApplicationModelConvention("/StreamedSingleFileUploadPhysical",
            model =>
            {
                model.Filters.Add(
                    new GenerateAntiforgeryTokenCookieAttribute());
                model.Filters.Add(
                    new DisableFormValueModelBindingAttribute());
            });
});

모델 바인딩은 양식을 읽지 않으므로 양식에서 바인딩된 매개 변수가 바인딩되지 않습니다(쿼리, 경로 및 헤더는 계속 작동함). 작업 메서드는 Request 속성으로 직접 작동합니다. MultipartReader는 각 섹션을 읽는 데 사용됩니다. 키/값 데이터는 KeyValueAccumulator에 저장됩니다. 다중 파트 섹션을 읽은 후 KeyValueAccumulator의 내용이 양식 데이터를 모델 형식으로 바인딩하는 데 사용됩니다.

EF Core를 사용하여 데이터베이스에 스트리밍하기 위한 전체 StreamingController.UploadDatabase 메서드는 다음과 같습니다.

[HttpPost]
[DisableFormValueModelBinding]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadDatabase()
{
    if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
    {
        ModelState.AddModelError("File", 
            $"The request couldn't be processed (Error 1).");
        // Log error

        return BadRequest(ModelState);
    }

    // Accumulate the form data key-value pairs in the request (formAccumulator).
    var formAccumulator = new KeyValueAccumulator();
    var trustedFileNameForDisplay = string.Empty;
    var untrustedFileNameForStorage = string.Empty;
    var streamedFileContent = Array.Empty<byte>();

    var boundary = MultipartRequestHelper.GetBoundary(
        MediaTypeHeaderValue.Parse(Request.ContentType),
        _defaultFormOptions.MultipartBoundaryLengthLimit);
    var reader = new MultipartReader(boundary, HttpContext.Request.Body);

    var section = await reader.ReadNextSectionAsync();

    while (section != null)
    {
        var hasContentDispositionHeader = 
            ContentDispositionHeaderValue.TryParse(
                section.ContentDisposition, out var contentDisposition);

        if (hasContentDispositionHeader)
        {
            if (MultipartRequestHelper
                .HasFileContentDisposition(contentDisposition))
            {
                untrustedFileNameForStorage = contentDisposition.FileName.Value;
                // Don't trust the file name sent by the client. To display
                // the file name, HTML-encode the value.
                trustedFileNameForDisplay = WebUtility.HtmlEncode(
                        contentDisposition.FileName.Value);

                streamedFileContent = 
                    await FileHelpers.ProcessStreamedFile(section, contentDisposition, 
                        ModelState, _permittedExtensions, _fileSizeLimit);

                if (!ModelState.IsValid)
                {
                    return BadRequest(ModelState);
                }
            }
            else if (MultipartRequestHelper
                .HasFormDataContentDisposition(contentDisposition))
            {
                // Don't limit the key name length because the 
                // multipart headers length limit is already in effect.
                var key = HeaderUtilities
                    .RemoveQuotes(contentDisposition.Name).Value;
                var encoding = GetEncoding(section);

                if (encoding == null)
                {
                    ModelState.AddModelError("File", 
                        $"The request couldn't be processed (Error 2).");
                    // Log error

                    return BadRequest(ModelState);
                }

                using (var streamReader = new StreamReader(
                    section.Body,
                    encoding,
                    detectEncodingFromByteOrderMarks: true,
                    bufferSize: 1024,
                    leaveOpen: true))
                {
                    // The value length limit is enforced by 
                    // MultipartBodyLengthLimit
                    var value = await streamReader.ReadToEndAsync();

                    if (string.Equals(value, "undefined", 
                        StringComparison.OrdinalIgnoreCase))
                    {
                        value = string.Empty;
                    }

                    formAccumulator.Append(key, value);

                    if (formAccumulator.ValueCount > 
                        _defaultFormOptions.ValueCountLimit)
                    {
                        // Form key count limit of 
                        // _defaultFormOptions.ValueCountLimit 
                        // is exceeded.
                        ModelState.AddModelError("File", 
                            $"The request couldn't be processed (Error 3).");
                        // Log error

                        return BadRequest(ModelState);
                    }
                }
            }
        }

        // Drain any remaining section body that hasn't been consumed and
        // read the headers for the next section.
        section = await reader.ReadNextSectionAsync();
    }

    // Bind form data to the model
    var formData = new FormData();
    var formValueProvider = new FormValueProvider(
        BindingSource.Form,
        new FormCollection(formAccumulator.GetResults()),
        CultureInfo.CurrentCulture);
    var bindingSuccessful = await TryUpdateModelAsync(formData, prefix: "",
        valueProvider: formValueProvider);

    if (!bindingSuccessful)
    {
        ModelState.AddModelError("File", 
            "The request couldn't be processed (Error 5).");
        // Log error

        return BadRequest(ModelState);
    }

    // **WARNING!**
    // In the following example, the file is saved without
    // scanning the file's contents. In most production
    // scenarios, an anti-virus/anti-malware scanner API
    // is used on the file before making the file available
    // for download or for use by other systems. 
    // For more information, see the topic that accompanies 
    // this sample app.

    var file = new AppFile()
    {
        Content = streamedFileContent,
        UntrustedName = untrustedFileNameForStorage,
        Note = formData.Note,
        Size = streamedFileContent.Length, 
        UploadDT = DateTime.UtcNow
    };

    _context.File.Add(file);
    await _context.SaveChangesAsync();

    return Created(nameof(StreamingController), null);
}

MultipartRequestHelper (Utilities/MultipartRequestHelper.cs):

using System;
using System.IO;
using Microsoft.Net.Http.Headers;

namespace SampleApp.Utilities
{
    public static class MultipartRequestHelper
    {
        // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq"
        // The spec at https://tools.ietf.org/html/rfc2046#section-5.1 states that 70 characters is a reasonable limit.
        public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
        {
            var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value;

            if (string.IsNullOrWhiteSpace(boundary))
            {
                throw new InvalidDataException("Missing content-type boundary.");
            }

            if (boundary.Length > lengthLimit)
            {
                throw new InvalidDataException(
                    $"Multipart boundary length limit {lengthLimit} exceeded.");
            }

            return boundary;
        }

        public static bool IsMultipartContentType(string contentType)
        {
            return !string.IsNullOrEmpty(contentType)
                   && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition)
        {
            // Content-Disposition: form-data; name="key";
            return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && string.IsNullOrEmpty(contentDisposition.FileName.Value)
                && string.IsNullOrEmpty(contentDisposition.FileNameStar.Value);
        }

        public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition)
        {
            // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg"
            return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && (!string.IsNullOrEmpty(contentDisposition.FileName.Value)
                    || !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value));
        }
    }
}

실제 위치로 스트리밍하기 위한 전체 StreamingController.UploadPhysical 메서드는 다음과 같습니다.

[HttpPost]
[DisableFormValueModelBinding]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadPhysical()
{
    if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
    {
        ModelState.AddModelError("File", 
            $"The request couldn't be processed (Error 1).");
        // Log error

        return BadRequest(ModelState);
    }

    var boundary = MultipartRequestHelper.GetBoundary(
        MediaTypeHeaderValue.Parse(Request.ContentType),
        _defaultFormOptions.MultipartBoundaryLengthLimit);
    var reader = new MultipartReader(boundary, HttpContext.Request.Body);
    var section = await reader.ReadNextSectionAsync();

    while (section != null)
    {
        var hasContentDispositionHeader = 
            ContentDispositionHeaderValue.TryParse(
                section.ContentDisposition, out var contentDisposition);

        if (hasContentDispositionHeader)
        {
            // This check assumes that there's a file
            // present without form data. If form data
            // is present, this method immediately fails
            // and returns the model error.
            if (!MultipartRequestHelper
                .HasFileContentDisposition(contentDisposition))
            {
                ModelState.AddModelError("File", 
                    $"The request couldn't be processed (Error 2).");
                // Log error

                return BadRequest(ModelState);
            }
            else
            {
                // Don't trust the file name sent by the client. To display
                // the file name, HTML-encode the value.
                var trustedFileNameForDisplay = WebUtility.HtmlEncode(
                        contentDisposition.FileName.Value);
                var trustedFileNameForFileStorage = Path.GetRandomFileName();

                // **WARNING!**
                // In the following example, the file is saved without
                // scanning the file's contents. In most production
                // scenarios, an anti-virus/anti-malware scanner API
                // is used on the file before making the file available
                // for download or for use by other systems. 
                // For more information, see the topic that accompanies 
                // this sample.

                var streamedFileContent = await FileHelpers.ProcessStreamedFile(
                    section, contentDisposition, ModelState, 
                    _permittedExtensions, _fileSizeLimit);

                if (!ModelState.IsValid)
                {
                    return BadRequest(ModelState);
                }

                using (var targetStream = System.IO.File.Create(
                    Path.Combine(_targetFilePath, trustedFileNameForFileStorage)))
                {
                    await targetStream.WriteAsync(streamedFileContent);

                    _logger.LogInformation(
                        "Uploaded file '{TrustedFileNameForDisplay}' saved to " +
                        "'{TargetFilePath}' as {TrustedFileNameForFileStorage}", 
                        trustedFileNameForDisplay, _targetFilePath, 
                        trustedFileNameForFileStorage);
                }
            }
        }

        // Drain any remaining section body that hasn't been consumed and
        // read the headers for the next section.
        section = await reader.ReadNextSectionAsync();
    }

    return Created(nameof(StreamingController), null);
}

샘플 앱에서 유효성 검사는 FileHelpers.ProcessStreamedFile에 의해 처리됩니다.

유효성 검사

샘플 앱의 FileHelpers 클래스는 버퍼링된 IFormFile 및 스트리밍된 파일 업로드에 대한 몇 가지 검사를 보여 줍니다. 샘플 앱에서 버퍼링된 파일 업로드를 처리하는 IFormFile 방법은 파일의 메서드를 ProcessFormFileUtilities/FileHelpers.cs 참조하세요. 스트리밍된 파일의 처리는 동일한 파일의 ProcessStreamedFile 메서드를 참조하세요.

Warning

샘플 앱에서 보여 주는 유효성 검사 처리 메서드는 업로드된 파일의 내용을 검사하지 않습니다. 대부분의 프로덕션 시나리오에서는 사용자 또는 다른 시스템에서 파일을 사용할 수 있도록 하기 전에 파일에 바이러스/맬웨어 스캐너 API를 사용합니다.

항목 샘플에서는 유효성 검사 기술에 대한 작업 예제를 제공하지만, 다음과 같은 경우가 아니면 프로덕션 앱에서 FileHelpers 클래스를 구현하지 마세요.

  • 구현을 완전히 이해합니다.
  • 앱의 환경 및 사양에 맞게 구현을 수정합니다.

이러한 요구 사항을 해결하지 않고 앱에서 보안 코드를 무분별하게 구현해서는 안 됩니다.

내용 유효성 검사

업로드된 콘텐츠에 타사 바이러스/맬웨어 검사 API를 사용합니다.

대용량 시나리오에서 파일 검사는 많은 서버 리소스를 요구합니다. 파일 검사로 인해 요청 처리 성능이 저하된 경우 검사 작업을 백그라운드 서비스로 오프로드하는 것이 좋습니다.이 경우 앱 서버와 다른 서버에서 실행되는 서비스가 있을 수 있습니다. 일반적으로 업로드된 파일은 백그라운드 바이러스 검사 프로그램에서 검사될 때까지 격리된 영역에 저장됩니다. 파일이 전달되면 파일이 일반 파일 스토리지 위치로 이동됩니다. 이러한 단계는 일반적으로 파일의 검사 상태를 나타내는 데이터베이스 레코드와 함께 수행됩니다. 이러한 방법을 사용하여 앱 및 앱 서버는 요청에 응답하는 데 집중합니다.

파일 확장명 유효성 검사

업로드된 파일의 확장명을 허용된 확장명 목록에 따라 확인해야 합니다. 예시:

private string[] permittedExtensions = { ".txt", ".pdf" };

var ext = Path.GetExtension(uploadedFileName).ToLowerInvariant();

if (string.IsNullOrEmpty(ext) || !permittedExtensions.Contains(ext))
{
    // The extension is invalid ... discontinue processing the file
}

파일 서명 유효성 검사

파일의 서명은 파일 시작 부분의 처음 몇 바이트에 의해 결정됩니다. 이러한 바이트는 확장명이 파일 내용과 일치하는지 여부를 나타내는 데 사용할 수 있습니다. 샘플 앱은 몇 가지 일반적인 파일 형식에 대한 파일 서명을 확인합니다. 다음 예제에서는 파일에서 JPEG 이미지 파일에 대한 서명을 확인합니다.

private static readonly Dictionary<string, List<byte[]>> _fileSignature = 
    new Dictionary<string, List<byte[]>>
{
    { ".jpeg", new List<byte[]>
        {
            new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
            new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 },
            new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 },
        }
    },
};

using (var reader = new BinaryReader(uploadedFileData))
{
    var signatures = _fileSignature[ext];
    var headerBytes = reader.ReadBytes(signatures.Max(m => m.Length));

    return signatures.Any(signature => 
        headerBytes.Take(signature.Length).SequenceEqual(signature));
}

추가 파일 서명을 얻으려면 파일 서명 데이터베이스(Google 검색 결과) 및 공식 파일 사양을 사용합니다. 공식 파일 사양을 참조하여 선택한 서명이 유효한지 확인할 수 있습니다.

파일 이름 보안

실제 스토리지에 파일을 저장하는 데 클라이언트에서 제공하는 파일 이름을 사용하지 마세요. Path.GetRandomFileName 또는 Path.GetTempFileName을 사용하여 임시 스토리지에 대한 전체 경로(파일 이름을 포함)를 만들어 파일에 대한 안전한 파일 이름을 만듭니다.

Razor는 표시를 위해 속성 값을 자동으로 HTML 인코딩합니다. 다음 코드는 안전하게 사용할 수 있습니다.

@foreach (var file in Model.DatabaseFiles) {
    <tr>
        <td>
            @file.UntrustedName
        </td>
    </tr>
}

Razor 외부에서는 항상 사용자 요청의 HtmlEncode 파일 이름 콘텐츠입니다.

많은 구현에서 파일 존재 여부에 대한 확인이 포함되어야 합니다. 그렇지 않으면 파일이 같은 이름의 파일을 덮어씁니다. 앱의 사양을 충족하는 추가 논리를 제공합니다.

크기 유효성 검사

업로드된 파일의 크기를 제한합니다.

샘플 앱에서 파일 크기는 2MB(바이트 단위로 표시)로 제한됩니다. 이 제한은 appsettings.json 파일의 구성을 통해 제공됩니다.

{
  "FileSizeLimit": 2097152
}

FileSizeLimitPageModel 클래스에 삽입됩니다.

public class BufferedSingleFileUploadPhysicalModel : PageModel
{
    private readonly long _fileSizeLimit;

    public BufferedSingleFileUploadPhysicalModel(IConfiguration config)
    {
        _fileSizeLimit = config.GetValue<long>("FileSizeLimit");
    }

    ...
}

파일 크기가 제한을 초과하는 파일은 거부됩니다.

if (formFile.Length > _fileSizeLimit)
{
    // The file is too large ... discontinue processing the file
}

이름 특성 값을 POST 메서드의 매개 변수 이름과 일치

양식 데이터를 POST하거나 JavaScript의 FormData를 직접 사용하는 비 Razor 양식에서 양식의 요소 또는 FormData에 지정된 이름은 컨트롤러의 작업에서 매개 변수 이름과 일치해야 합니다.

다음 예제에서

  • <input> 요소를 사용하는 경우 name 특성은 값 battlePlans로 설정됩니다.

    <input type="file" name="battlePlans" multiple>
    
  • JavaScript에서 FormData를 사용하는 경우 이름이 값 battlePlans로 설정됩니다.

    var formData = new FormData();
    
    for (var file in files) {
      formData.append("battlePlans", file, file.name);
    }
    

C# 메서드(battlePlans)의 매개 변수와 일치하는 이름을 사용합니다.

  • 이름이 Upload인 Razor Pages 페이지 처리기 메서드:

    public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> battlePlans)
    
  • MVC POST 컨트롤러 작업 메서드:

    public async Task<IActionResult> Post(List<IFormFile> battlePlans)
    

서버 및 앱 구성

다중 파트 본문 길이 제한

MultipartBodyLengthLimit에서 각 다중 파트 본문의 길이에 대한 제한을 설정합니다. 양식 섹션이 이 제한을 초과하면 구문 분석할 때 InvalidDataException이 throw됩니다. 기본값은 134,217,728(128MB)입니다. Startup.ConfigureServices에서 MultipartBodyLengthLimit 설정을 사용하여 제한을 사용자 지정합니다.

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<FormOptions>(options =>
    {
        // Set the limit to 256 MB
        options.MultipartBodyLengthLimit = 268435456;
    });
}

RequestFormLimitsAttribute는 단일 페이지 또는 작업에 대해 MultipartBodyLengthLimit을 설정하는 데 사용됩니다.

Razor Pages 앱에서 Startup.ConfigureServices규칙을 사용하여 필터를 적용합니다.

services.AddRazorPages(options =>
{
    options.Conventions
        .AddPageApplicationModelConvention("/FileUploadPage",
            model.Filters.Add(
                new RequestFormLimitsAttribute()
                {
                    // Set the limit to 256 MB
                    MultipartBodyLengthLimit = 268435456
                });
});

Razor Pages 앱 또는 MVC 앱에서 페이지 모델 또는 작업 메서드에 필터를 적용합니다.

// Set the limit to 256 MB
[RequestFormLimits(MultipartBodyLengthLimit = 268435456)]
public class BufferedSingleFileUploadPhysicalModel : PageModel
{
    ...
}

Kestrel 최대 요청 본문 크기

Kestrel에서 호스트되는 앱의 경우 기본 최대 요청 본문 크기는 30,000,000바이트(약 28.6MB)입니다. MaxRequestBodySizeKestrel 서버 옵션을 사용하여 제한을 사용자 지정합니다.

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.ConfigureKestrel((context, options) =>
            {
                // Handle requests up to 50 MB
                options.Limits.MaxRequestBodySize = 52428800;
            })
            .UseStartup<Startup>();
        });

RequestSizeLimitAttribute는 단일 페이지 또는 작업에 대해 MaxRequestBodySize를 설정하는 데 사용됩니다.

Razor Pages 앱에서 Startup.ConfigureServices규칙을 사용하여 필터를 적용합니다.

services.AddRazorPages(options =>
{
    options.Conventions
        .AddPageApplicationModelConvention("/FileUploadPage",
            model =>
            {
                // Handle requests up to 50 MB
                model.Filters.Add(
                    new RequestSizeLimitAttribute(52428800));
            });
});

Razor Pages 앱 또는 MVC 앱에서 페이지 처리기 모델 또는 작업 메서드에 필터를 적용합니다.

// Handle requests up to 50 MB
[RequestSizeLimit(52428800)]
public class BufferedSingleFileUploadPhysicalModel : PageModel
{
    ...
}

지시 RequestSizeLimitAttribute 문을 사용하여 적용할 수도 있습니다.@attributeRazor

@attribute [RequestSizeLimitAttribute(52428800)]

다른 Kestrel 제한

다른 Kestrel 제한이 Kestrel에서 호스트되는 앱에 적용될 수 있습니다.

IIS

기본 요청 제한(maxAllowedContentLength)은 30,000,000바이트(약 28.6MB)입니다. web.config 파일에서 제한을 사용자 지정합니다. 다음 예제에서 제한은 50MB(52,428,800바이트)로 설정됩니다.

<system.webServer>
  <security>
    <requestFiltering>
      <requestLimits maxAllowedContentLength="52428800" />
    </requestFiltering>
  </security>
</system.webServer>

maxAllowedContentLength 설정은 IIS에만 적용됩니다. 자세한 내용은 요청 제한<requestLimits>을 참조하세요.

문제 해결

다음은 파일 업로드 및 가능한 솔루션을 사용하여 작업할 때 자주 발생하는 몇 가지 일반적인 문제입니다.

IIS 서버에 배포할 때 찾을 수 없음 오류

다음 오류는 업로드된 파일이 서버의 구성된 콘텐츠 길이를 초과했음을 나타냅니다.

HTTP 404.13 - Not Found
The request filtering module is configured to deny a request that exceeds the request content length.

자세한 내용은 IIS 섹션을 참조하세요.

연결 실패

연결 오류 및 서버 연결 다시 설정은 업로드된 파일이 Kestrel의 최대 요청 본문 크기를 초과함을 나타낼 수 있습니다. 자세한 내용은 Kestrel 최대 요청 본문 크기 섹션을 참조하세요. Kestrel 클라이언트 연결 제한을 조정해야 할 수도 있습니다.

IFormFile을 사용한 Null 참조 예외

컨트롤러에서 IFormFile을 사용하여 업로드된 파일을 수락하지만 값이 null이면 HTML 양식에서 multipart/form-dataenctype 값을 지정하는지 확인합니다. <form> 요소에서 이 특성이 설정되지 않으면 파일 업로드가 실행되지 않고 바인딩된 IFormFile 인수는 모두 null이 됩니다. 또한 양식 데이터의 업로드 이름 지정이 앱의 이름 지정과 일치하는지 확인합니다.

스트림이 너무 깁니다.

이 항목의 예제에서는 업로드된 파일의 콘텐츠를 저장하는 데 MemoryStream에 의존합니다. MemoryStream의 크기 제한은 int.MaxValue입니다. 앱의 파일 업로드 시나리오에서 50MB보다 큰 파일 콘텐츠를 보관해야 하는 경우 업로드된 파일의 콘텐츠를 보관할 때 단일 MemoryStream에 의존하지 않는 대체 방법을 사용하세요.

ASP.NET Core는 소용량 파일의 경우에는 버퍼링된 모델 바인딩을 사용하여 하나 이상의 파일을 업로드하고, 대용량 파일의 경우에는 버퍼링되지 않은 스트리밍을 지원합니다.

샘플 코드 보기 및 다운로드(다운로드 방법)

보안 고려 사항

사용자에게 서버에 파일을 업로드하는 기능을 제공할 때는 주의해야 합니다. 공격자는 다음을 시도할 수 있습니다.

  • 서비스 거부 공격을 실행.
  • 바이러스 또는 맬웨어를 업로드.
  • 다른 방법으로 네트워크 및 서버를 손상.

공격이 성공할 가능성을 줄이는 보안 단계는 다음과 같습니다.

  • 전용 파일 업로드 영역(바람직하게는 시스템 드라이브가 아닌 위치)에 파일을 업로드합니다. 전용 위치를 사용하면 업로드된 파일에 대한 보안 제한을 더 쉽게 적용할 수 있습니다. 파일 업로드 위치에 대한 실행 권한을 사용하지 않도록 설정합니다.†
  • 업로드된 파일을 앱과 동일한 디렉터리 트리에 보관하지 마세요.
  • 앱에 의해 결정된 안전한 파일 이름을 사용합니다. 사용자가 제공한 파일 이름이나 업로드된 파일의 신뢰할 수 없는 파일 이름을 사용하지 마세요.† HTML은 이름을 표시할 때 신뢰할 수 없는 파일 이름일 경우 이를 인코딩합니다. 파일 이름을 로깅하거나 UI에 표시하는 경우를 예로 들 수 있습니다(Razor에서 자동으로 출력을 HTML로 인코딩).
  • 앱의 디자인 사양으로 승인된 파일 확장명만 허용합니다.†
  • 서버에서 클라이언트 쪽 검사가 수행되는지 확인합니다.† 클라이언트 쪽 검사는 쉽게 우회할 수 있습니다.
  • 업로드된 파일의 크기를 확인합니다. 대규모로 업로드되지 않도록 최대 크기 제한을 설정합니다.†
  • 업로드된 파일이 같은 이름의 파일을 덮어쓰면 안 되는 경우 파일을 업로드하기 전에 데이터베이스 또는 실제 스토리지에서 파일 이름을 확인합니다.
  • 파일이 저장되기 전에 업로드된 콘텐츠에 대해 바이러스/맬웨어 스캐너를 실행합니다.

†샘플 앱은 조건을 충족하는 방법을 보여 줍니다.

Warning

시스템에 악성 코드를 업로드하는 행위는 흔히 다음을 수행할 수 있는 코드를 실행하기 위한 첫 단계가 됩니다.

  • 시스템을 완전히 제어합니다.
  • 시스템 작동이 중단되는 결과로 시스템을 오버로드합니다.
  • 사용자 또는 시스템 데이터를 손상시킵니다.
  • 공용 UI에 그래피티를 적용합니다.

사용자의 파일을 수락할 때 공격 노출 영역을 줄이는 방법에 대한 자세한 내용은 다음 리소스를 참조하세요.

샘플 앱의 예제를 포함하여 보안 조치를 구현하는 방법에 대한 자세한 내용은 유효성 검사 섹션을 참조하세요.

스토리지 시나리오

파일에 대한 일반적인 스토리지 옵션은 다음과 같습니다.

  • Database

    • 작은 파일 업로드의 경우 데이터베이스가 실제 스토리지(파일 시스템 또는 네트워크 공유) 옵션보다 빠른 경우가 많습니다.
    • 사용자 데이터에 대한 데이터베이스 레코드를 검색할 때 파일 콘텐츠(예: 아바타 이미지)를 동시에 제공할 수 있으므로 데이터베이스는 실제 스토리지 옵션보다 편리합니다.
    • 데이터베이스는 데이터 스토리지 서비스를 사용하는 것보다 비용이 적게 들 수 있습니다.
  • 실제 스토리지(파일 시스템 또는 네트워크 공유)

    • 대용량 파일 업로드의 경우:
      • 데이터베이스 한도 때문에 업로드 크기가 제한될 수 있습니다.
      • 실제 스토리지는 데이터베이스 스토리지보다 경제적이지 않은 경우가 자주 있습니다.
    • 실제 스토리지는 데이터 스토리지 서비스를 사용하는 것보다 비용이 적게 들 수 있습니다.
    • 앱의 프로세스에는 스토리지 위치에 대한 읽기 및 쓰기 권한이 있어야 합니다. 실행 권한을 부여하지 마세요.
  • 데이터 스토리지 서비스(예: Azure Blob Storage)

    • 서비스는 일반적으로 단일 실패 지점에 노출되는 온-프레미스 솔루션에 비해 향상된 확장성 및 복원력을 제공합니다.
    • 서비스는 대용량 스토리지 인프라 시나리오에서 비용이 더 저렴할 수 있습니다.

    자세한 내용은 빠른 시작: .NET을 사용하여 개체 스토리지에 Blob 만들기를 참조하세요.

파일 업로드 시나리오

파일 업로드를 위한 일반적인 방법 두 가지는 버퍼링 및 스트리밍입니다.

버퍼링

전체 파일을 파일 처리 또는 저장에 사용되는 파일의 C# 표현인 IFormFile로 읽어 들입니다.

파일 업로드에서 사용되는 리소스(디스크, 메모리)는 동시 파일 업로드 크기와 수에 따라 달라집니다. 앱이 너무 많은 업로드를 버퍼링하려 할 경우 메모리 또는 디스크 공간이 부족하면 사이트의 작동이 중단됩니다. 파일 업로드의 크기 또는 빈도로 인해 앱 리소스가 소진되는 경우 스트리밍을 사용합니다.

참고 항목

버퍼링된 단일 파일이 64KB를 초과하는 경우 메모리에서 디스크의 임시 파일로 이동됩니다.

소용량 파일 버퍼링은 이 항목의 다음 섹션에서 설명합니다.

스트리밍

파일은 다중 파트 요청에서 수신되며 앱에서 직접 처리하거나 저장합니다. 스트리밍은 성능을 크게 개선하지 않습니다. 스트리밍을 통해 파일을 업로드하면 메모리 또는 디스크 공간에 대한 요구가 줄어듭니다.

대용량 파일 스트리밍은 스트리밍을 사용하여 대용량 파일 업로드 섹션에서 설명합니다.

버퍼링된 모델 바인딩을 사용하여 소용량 파일을 실제 스토리지에 업로드

소용량 파일을 업로드하려면 다중 파트 양식을 사용하거나 JavaScript를 사용하여 POST 요청을 생성합니다.

다음 예제에서는 페이지 양식을 사용하여 Razor 샘플 앱에서 단일 파일을Pages/BufferedSingleFileUploadPhysical.cshtml 업로드하는 방법을 보여 줍니다.

<form enctype="multipart/form-data" method="post">
    <dl>
        <dt>
            <label asp-for="FileUpload.FormFile"></label>
        </dt>
        <dd>
            <input asp-for="FileUpload.FormFile" type="file">
            <span asp-validation-for="FileUpload.FormFile"></span>
        </dd>
    </dl>
    <input asp-page-handler="Upload" class="btn" type="submit" value="Upload" />
</form>

다음 예제는 이전 예제와 비슷하지만 다음과 같은 차이가 있습니다.

  • 양식의 데이터를 제출하는 데 JavaScript(Fetch API)가 사용됩니다.
  • 유효성 검사를 수행하지 않습니다.
<form action="BufferedSingleFileUploadPhysical/?handler=Upload" 
      enctype="multipart/form-data" onsubmit="AJAXSubmit(this);return false;" 
      method="post">
    <dl>
        <dt>
            <label for="FileUpload_FormFile">File</label>
        </dt>
        <dd>
            <input id="FileUpload_FormFile" type="file" 
                name="FileUpload.FormFile" />
        </dd>
    </dl>

    <input class="btn" type="submit" value="Upload" />

    <div style="margin-top:15px">
        <output name="result"></output>
    </div>
</form>

<script>
  async function AJAXSubmit (oFormElement) {
    var resultElement = oFormElement.elements.namedItem("result");
    const formData = new FormData(oFormElement);

    try {
    const response = await fetch(oFormElement.action, {
      method: 'POST',
      body: formData
    });

    if (response.ok) {
      window.location.href = '/';
    }

    resultElement.value = 'Result: ' + response.status + ' ' + 
      response.statusText;
    } catch (error) {
      console.error('Error:', error);
    }
  }
</script>

Fetch API를 지원하지 않는 클라이언트에 대해 JavaScript로 양식 POST를 수행하려면 다음 방법 중 하나를 사용합니다.

  • Fetch Polyfill(예: window.fetch polyfill (github/fetch))을 사용합니다.

  • XMLHttpRequest을 사용합니다. 예시:

    <script>
      "use strict";
    
      function AJAXSubmit (oFormElement) {
        var oReq = new XMLHttpRequest();
        oReq.onload = function(e) { 
        oFormElement.elements.namedItem("result").value = 
          'Result: ' + this.status + ' ' + this.statusText;
        };
        oReq.open("post", oFormElement.action);
        oReq.send(new FormData(oFormElement));
      }
    </script>
    

파일 업로드를 지원하려면 HTML 양식에서 multipart/form-data의 인코딩 유형(enctype)을 지정해야 합니다.

files 입력 요소가 다중 파일 업로드를 지원하려면 <input> 요소에 multiple 특성을 제공합니다.

<input asp-for="FileUpload.FormFiles" type="file" multiple>

서버에 업로드된 개별 파일은 IFormFile을 사용하여 모델 바인딩을 통해 액세스할 수 있습니다. 샘플 앱은 데이터베이스 및 실제 스토리지 시나리오에 대한 다중 버퍼링된 파일 업로드를 보여 줍니다.

Warning

표시 및 로깅 이외에는 IFormFileFileName 속성을 사용하지 마세요. 표시하거나 로깅할 경우 파일 이름을 HTML로 인코딩합니다. 공격자는 전체 경로나 상대 경로를 포함하여 악의적인 파일 이름을 제공할 수 있습니다. 애플리케이션에서 다음을 수행해야 합니다.

  • 사용자가 제공한 파일 이름에서 경로를 제거합니다.
  • UI 또는 로깅을 위해 HTML로 인코딩되고 경로가 제거된 파일 이름을 저장합니다.
  • 스토리지의 새 임의 파일 이름을 생성합니다.

다음 코드는 파일 이름에서 경로를 제거합니다.

string untrustedFileName = Path.GetFileName(pathName);

지금까지 제시한 예제에서는 보안 고려 사항을 감안하지 않습니다. 추가 정보는 다음 섹션과 샘플 앱에서 제공합니다.

모델 바인딩 및 IFormFile을 사용하여 파일을 업로드하는 경우 작업 메서드에서 다음을 허용할 수 있습니다.

참고 항목

바인딩은 이름을 기준으로 양식 파일을 일치시킵니다. 예를 들어 <input type="file" name="formFile">의 HTML name 값은 바인딩된 C# 매개 변수/속성(FormFile)과 일치해야 합니다. 자세한 내용은 이름 특성 값을 POST 메서드의 매개 변수 이름과 일치 섹션을 참조하세요.

다음 예제를 참조하세요.

  • 하나 이상의 업로드된 파일을 반복합니다.
  • Path.GetTempFileName을 사용하여 파일 이름을 포함하는 파일 전체 경로를 반환합니다.
  • 앱에서 생성한 파일 이름을 사용하여 로컬 파일 시스템에 파일을 저장합니다.
  • 업로드된 파일의 총 수와 크기를 반환합니다.
public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> files)
{
    long size = files.Sum(f => f.Length);

    foreach (var formFile in files)
    {
        if (formFile.Length > 0)
        {
            var filePath = Path.GetTempFileName();

            using (var stream = System.IO.File.Create(filePath))
            {
                await formFile.CopyToAsync(stream);
            }
        }
    }

    // Process uploaded files
    // Don't rely on or trust the FileName property without validation.

    return Ok(new { count = files.Count, size });
}

Path.GetRandomFileName을 사용하여 경로 없이 파일 이름을 생성합니다. 다음 예제에서는 경로를 구성에서 가져옵니다.

foreach (var formFile in files)
{
    if (formFile.Length > 0)
    {
        var filePath = Path.Combine(_config["StoredFilesPath"], 
            Path.GetRandomFileName());

        using (var stream = System.IO.File.Create(filePath))
        {
            await formFile.CopyToAsync(stream);
        }
    }
}

FileStream에 전달된 경로는 반드시 파일 이름을 포함해야 합니다. 파일 이름을 제공하지 않으면 런타임에 UnauthorizedAccessException이 발생합니다.

IFormFile 기술을 사용하여 업로드된 파일은 처리 전에 서버의 메모리나 디스크에 버퍼링됩니다. 작업 메서드 내부에서 IFormFile 내용을 Stream으로 액세스할 수 있습니다. 로컬 파일 시스템 외에도 파일을 네트워크 공유 또는 파일 스토리지 서비스(예: Azure Blob Storage)에 저장할 수 있습니다.

업로드를 위해 여러 파일을 반복하고 안전한 파일 이름을 사용하는 또 다른 예제는 샘플 앱을 참조 Pages/BufferedMultipleFileUploadPhysical.cshtml.cs 하세요.

Warning

이전 임시 파일을 삭제하지 않고 65,535개를 초과하는 파일을 만들면 Path.GetTempFileNameIOException을 throw합니다. 65535개 파일 제한은 서버당 제한입니다. Windows OS에서 이 제한에 대한 자세한 내용은 다음 항목의 설명을 참조하세요.

버퍼링된 모델 바인딩을 사용하여 소용량 파일을 데이터베이스에 업로드

Entity Framework를 사용하여 데이터베이스에 이진 파일 데이터를 저장하려면 엔터티에서 Byte 배열 속성을 정의합니다.

public class AppFile
{
    public int Id { get; set; }
    public byte[] Content { get; set; }
}

IFormFile을 포함하는 클래스에 대한 페이지 모델 속성을 지정합니다.

public class BufferedSingleFileUploadDbModel : PageModel
{
    ...

    [BindProperty]
    public BufferedSingleFileUploadDb FileUpload { get; set; }

    ...
}

public class BufferedSingleFileUploadDb
{
    [Required]
    [Display(Name="File")]
    public IFormFile FormFile { get; set; }
}

참고 항목

IFormFile은 작업 메서드 매개 변수 또는 바운딩 모델 속성으로 직접 사용할 수 있습니다. 위 예제에서는 바인딩된 모델 속성을 사용합니다.

FileUpload는 Razor Pages 양식에 사용됩니다.

<form enctype="multipart/form-data" method="post">
    <dl>
        <dt>
            <label asp-for="FileUpload.FormFile"></label>
        </dt>
        <dd>
            <input asp-for="FileUpload.FormFile" type="file">
        </dd>
    </dl>
    <input asp-page-handler="Upload" class="btn" type="submit" value="Upload">
</form>

양식이 서버에 POST되면 IFormFile을 스트림으로 복사하여 데이터베이스에 바이트 배열로 저장합니다. 다음 예제에서 _dbContext는 앱의 데이터베이스 컨텍스트를 저장합니다.

public async Task<IActionResult> OnPostUploadAsync()
{
    using (var memoryStream = new MemoryStream())
    {
        await FileUpload.FormFile.CopyToAsync(memoryStream);

        // Upload the file if less than 2 MB
        if (memoryStream.Length < 2097152)
        {
            var file = new AppFile()
            {
                Content = memoryStream.ToArray()
            };

            _dbContext.File.Add(file);

            await _dbContext.SaveChangesAsync();
        }
        else
        {
            ModelState.AddModelError("File", "The file is too large.");
        }
    }

    return Page();
}

위의 예제는 샘플 앱에서 보여 주는 시나리오와 비슷합니다.

  • Pages/BufferedSingleFileUploadDb.cshtml
  • Pages/BufferedSingleFileUploadDb.cshtml.cs

Warning

관계형 데이터베이스에 이진 데이터를 저장할 경우 성능에 나쁜 영향을 줄 수 있으므로 주의하세요.

유효성 검사 없이 IFormFileFileName 속성을 의존하거나 신뢰하지 마세요. FileName 속성은 반드시 HTML 인코딩 후 표시 목적으로만 사용해야 합니다.

제시한 예제에서는 보안 고려 사항을 감안하지 않습니다. 추가 정보는 다음 섹션과 샘플 앱에서 제공합니다.

스트리밍을 사용하여 대용량 파일 업로드

다음 예제에서는 JavaScript를 사용하여 컨트롤러 작업에 파일을 스트리밍하는 방법을 보여 줍니다. 사용자 지정 필터 특성을 사용하여 파일의 위조 방지 토큰이 생성되고 요청 본문 대신 클라이언트 HTTP 헤더에 전달됩니다. 작업 메서드에서 업로드된 데이터를 직접 처리하므로 다른 사용자 지정 필터에서 형식 모델 바인딩을 사용할 수 없습니다. 작업 내에서 양식의 콘텐츠는 각 개별 MultipartSection을 읽고 적절하게 파일을 처리하거나 콘텐츠를 저장하는 MultipartReader를 사용하여 읽습니다. 다중 파트 섹션을 읽은 후 작업에서 자체 모델 바인딩을 수행합니다.

초기 페이지 응답에서는 양식을 로드하고 위조 방지 토큰을 cookie에 저장합니다(GenerateAntiforgeryTokenCookieAttribute 특성을 통해). 이 특성은 ASP.NET Core의 기본 제공 위조 방지 지원을 사용하여 요청 토큰으로 cookie를 설정합니다.

public class GenerateAntiforgeryTokenCookieAttribute : ResultFilterAttribute
{
    public override void OnResultExecuting(ResultExecutingContext context)
    {
        var antiforgery = context.HttpContext.RequestServices.GetService<IAntiforgery>();

        // Send the request token as a JavaScript-readable cookie
        var tokens = antiforgery.GetAndStoreTokens(context.HttpContext);

        context.HttpContext.Response.Cookies.Append(
            "RequestVerificationToken",
            tokens.RequestToken,
            new CookieOptions() { HttpOnly = false });
    }

    public override void OnResultExecuted(ResultExecutedContext context)
    {
    }
}

DisableFormValueModelBindingAttribute은 모델 바인딩을 사용하지 않도록 설정하는 데 사용됩니다.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        var factories = context.ValueProviderFactories;
        factories.RemoveType<FormValueProviderFactory>();
        factories.RemoveType<FormFileValueProviderFactory>();
        factories.RemoveType<JQueryFormValueProviderFactory>();
    }

    public void OnResourceExecuted(ResourceExecutedContext context)
    {
    }
}

샘플 앱에서 GenerateAntiforgeryTokenCookieAttributeDisableFormValueModelBindingAttributeRazor Pages 규칙을 사용하여 Startup.ConfigureServices/StreamedSingleFileUploadDb/StreamedSingleFileUploadPhysical의 페이지 애플리케이션 모델에 필터로 적용됩니다.

services.AddRazorPages(options =>
{
    options.Conventions
        .AddPageApplicationModelConvention("/StreamedSingleFileUploadDb",
            model =>
            {
                model.Filters.Add(
                    new GenerateAntiforgeryTokenCookieAttribute());
                model.Filters.Add(
                    new DisableFormValueModelBindingAttribute());
            });
    options.Conventions
        .AddPageApplicationModelConvention("/StreamedSingleFileUploadPhysical",
            model =>
            {
                model.Filters.Add(
                    new GenerateAntiforgeryTokenCookieAttribute());
                model.Filters.Add(
                    new DisableFormValueModelBindingAttribute());
            });
});

모델 바인딩은 양식을 읽지 않으므로 양식에서 바인딩된 매개 변수가 바인딩되지 않습니다(쿼리, 경로 및 헤더는 계속 작동함). 작업 메서드는 Request 속성으로 직접 작동합니다. MultipartReader는 각 섹션을 읽는 데 사용됩니다. 키/값 데이터는 KeyValueAccumulator에 저장됩니다. 다중 파트 섹션을 읽은 후 KeyValueAccumulator의 내용이 양식 데이터를 모델 형식으로 바인딩하는 데 사용됩니다.

EF Core를 사용하여 데이터베이스에 스트리밍하기 위한 전체 StreamingController.UploadDatabase 메서드는 다음과 같습니다.

[HttpPost]
[DisableFormValueModelBinding]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadDatabase()
{
    if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
    {
        ModelState.AddModelError("File", 
            $"The request couldn't be processed (Error 1).");
        // Log error

        return BadRequest(ModelState);
    }

    // Accumulate the form data key-value pairs in the request (formAccumulator).
    var formAccumulator = new KeyValueAccumulator();
    var trustedFileNameForDisplay = string.Empty;
    var untrustedFileNameForStorage = string.Empty;
    var streamedFileContent = Array.Empty<byte>();

    var boundary = MultipartRequestHelper.GetBoundary(
        MediaTypeHeaderValue.Parse(Request.ContentType),
        _defaultFormOptions.MultipartBoundaryLengthLimit);
    var reader = new MultipartReader(boundary, HttpContext.Request.Body);

    var section = await reader.ReadNextSectionAsync();

    while (section != null)
    {
        var hasContentDispositionHeader = 
            ContentDispositionHeaderValue.TryParse(
                section.ContentDisposition, out var contentDisposition);

        if (hasContentDispositionHeader)
        {
            if (MultipartRequestHelper
                .HasFileContentDisposition(contentDisposition))
            {
                untrustedFileNameForStorage = contentDisposition.FileName.Value;
                // Don't trust the file name sent by the client. To display
                // the file name, HTML-encode the value.
                trustedFileNameForDisplay = WebUtility.HtmlEncode(
                        contentDisposition.FileName.Value);

                streamedFileContent = 
                    await FileHelpers.ProcessStreamedFile(section, contentDisposition, 
                        ModelState, _permittedExtensions, _fileSizeLimit);

                if (!ModelState.IsValid)
                {
                    return BadRequest(ModelState);
                }
            }
            else if (MultipartRequestHelper
                .HasFormDataContentDisposition(contentDisposition))
            {
                // Don't limit the key name length because the 
                // multipart headers length limit is already in effect.
                var key = HeaderUtilities
                    .RemoveQuotes(contentDisposition.Name).Value;
                var encoding = GetEncoding(section);

                if (encoding == null)
                {
                    ModelState.AddModelError("File", 
                        $"The request couldn't be processed (Error 2).");
                    // Log error

                    return BadRequest(ModelState);
                }

                using (var streamReader = new StreamReader(
                    section.Body,
                    encoding,
                    detectEncodingFromByteOrderMarks: true,
                    bufferSize: 1024,
                    leaveOpen: true))
                {
                    // The value length limit is enforced by 
                    // MultipartBodyLengthLimit
                    var value = await streamReader.ReadToEndAsync();

                    if (string.Equals(value, "undefined", 
                        StringComparison.OrdinalIgnoreCase))
                    {
                        value = string.Empty;
                    }

                    formAccumulator.Append(key, value);

                    if (formAccumulator.ValueCount > 
                        _defaultFormOptions.ValueCountLimit)
                    {
                        // Form key count limit of 
                        // _defaultFormOptions.ValueCountLimit 
                        // is exceeded.
                        ModelState.AddModelError("File", 
                            $"The request couldn't be processed (Error 3).");
                        // Log error

                        return BadRequest(ModelState);
                    }
                }
            }
        }

        // Drain any remaining section body that hasn't been consumed and
        // read the headers for the next section.
        section = await reader.ReadNextSectionAsync();
    }

    // Bind form data to the model
    var formData = new FormData();
    var formValueProvider = new FormValueProvider(
        BindingSource.Form,
        new FormCollection(formAccumulator.GetResults()),
        CultureInfo.CurrentCulture);
    var bindingSuccessful = await TryUpdateModelAsync(formData, prefix: "",
        valueProvider: formValueProvider);

    if (!bindingSuccessful)
    {
        ModelState.AddModelError("File", 
            "The request couldn't be processed (Error 5).");
        // Log error

        return BadRequest(ModelState);
    }

    // **WARNING!**
    // In the following example, the file is saved without
    // scanning the file's contents. In most production
    // scenarios, an anti-virus/anti-malware scanner API
    // is used on the file before making the file available
    // for download or for use by other systems. 
    // For more information, see the topic that accompanies 
    // this sample app.

    var file = new AppFile()
    {
        Content = streamedFileContent,
        UntrustedName = untrustedFileNameForStorage,
        Note = formData.Note,
        Size = streamedFileContent.Length, 
        UploadDT = DateTime.UtcNow
    };

    _context.File.Add(file);
    await _context.SaveChangesAsync();

    return Created(nameof(StreamingController), null);
}

MultipartRequestHelper (Utilities/MultipartRequestHelper.cs):

using System;
using System.IO;
using Microsoft.Net.Http.Headers;

namespace SampleApp.Utilities
{
    public static class MultipartRequestHelper
    {
        // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq"
        // The spec at https://tools.ietf.org/html/rfc2046#section-5.1 states that 70 characters is a reasonable limit.
        public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
        {
            var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value;

            if (string.IsNullOrWhiteSpace(boundary))
            {
                throw new InvalidDataException("Missing content-type boundary.");
            }

            if (boundary.Length > lengthLimit)
            {
                throw new InvalidDataException(
                    $"Multipart boundary length limit {lengthLimit} exceeded.");
            }

            return boundary;
        }

        public static bool IsMultipartContentType(string contentType)
        {
            return !string.IsNullOrEmpty(contentType)
                   && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition)
        {
            // Content-Disposition: form-data; name="key";
            return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && string.IsNullOrEmpty(contentDisposition.FileName.Value)
                && string.IsNullOrEmpty(contentDisposition.FileNameStar.Value);
        }

        public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition)
        {
            // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg"
            return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && (!string.IsNullOrEmpty(contentDisposition.FileName.Value)
                    || !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value));
        }
    }
}

실제 위치로 스트리밍하기 위한 전체 StreamingController.UploadPhysical 메서드는 다음과 같습니다.

[HttpPost]
[DisableFormValueModelBinding]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadPhysical()
{
    if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
    {
        ModelState.AddModelError("File", 
            $"The request couldn't be processed (Error 1).");
        // Log error

        return BadRequest(ModelState);
    }

    var boundary = MultipartRequestHelper.GetBoundary(
        MediaTypeHeaderValue.Parse(Request.ContentType),
        _defaultFormOptions.MultipartBoundaryLengthLimit);
    var reader = new MultipartReader(boundary, HttpContext.Request.Body);
    var section = await reader.ReadNextSectionAsync();

    while (section != null)
    {
        var hasContentDispositionHeader = 
            ContentDispositionHeaderValue.TryParse(
                section.ContentDisposition, out var contentDisposition);

        if (hasContentDispositionHeader)
        {
            // This check assumes that there's a file
            // present without form data. If form data
            // is present, this method immediately fails
            // and returns the model error.
            if (!MultipartRequestHelper
                .HasFileContentDisposition(contentDisposition))
            {
                ModelState.AddModelError("File", 
                    $"The request couldn't be processed (Error 2).");
                // Log error

                return BadRequest(ModelState);
            }
            else
            {
                // Don't trust the file name sent by the client. To display
                // the file name, HTML-encode the value.
                var trustedFileNameForDisplay = WebUtility.HtmlEncode(
                        contentDisposition.FileName.Value);
                var trustedFileNameForFileStorage = Path.GetRandomFileName();

                // **WARNING!**
                // In the following example, the file is saved without
                // scanning the file's contents. In most production
                // scenarios, an anti-virus/anti-malware scanner API
                // is used on the file before making the file available
                // for download or for use by other systems. 
                // For more information, see the topic that accompanies 
                // this sample.

                var streamedFileContent = await FileHelpers.ProcessStreamedFile(
                    section, contentDisposition, ModelState, 
                    _permittedExtensions, _fileSizeLimit);

                if (!ModelState.IsValid)
                {
                    return BadRequest(ModelState);
                }

                using (var targetStream = System.IO.File.Create(
                    Path.Combine(_targetFilePath, trustedFileNameForFileStorage)))
                {
                    await targetStream.WriteAsync(streamedFileContent);

                    _logger.LogInformation(
                        "Uploaded file '{TrustedFileNameForDisplay}' saved to " +
                        "'{TargetFilePath}' as {TrustedFileNameForFileStorage}", 
                        trustedFileNameForDisplay, _targetFilePath, 
                        trustedFileNameForFileStorage);
                }
            }
        }

        // Drain any remaining section body that hasn't been consumed and
        // read the headers for the next section.
        section = await reader.ReadNextSectionAsync();
    }

    return Created(nameof(StreamingController), null);
}

샘플 앱에서 유효성 검사는 FileHelpers.ProcessStreamedFile에 의해 처리됩니다.

유효성 검사

샘플 앱의 FileHelpers 클래스는 버퍼링된 IFormFile 및 스트리밍된 파일 업로드에 대한 여러 검사를 보여 줍니다. 샘플 앱에서 버퍼링된 파일 업로드를 처리하는 IFormFile 방법은 파일의 메서드를 ProcessFormFileUtilities/FileHelpers.cs 참조하세요. 스트리밍된 파일의 처리는 동일한 파일의 ProcessStreamedFile 메서드를 참조하세요.

Warning

샘플 앱에서 보여 주는 유효성 검사 처리 메서드는 업로드된 파일의 내용을 검사하지 않습니다. 대부분의 프로덕션 시나리오에서는 사용자 또는 다른 시스템에서 파일을 사용할 수 있도록 하기 전에 파일에 바이러스/맬웨어 스캐너 API를 사용합니다.

항목 샘플에서는 유효성 검사 기술에 대한 작업 예제를 제공하지만, 다음과 같은 경우가 아니면 프로덕션 앱에서 FileHelpers 클래스를 구현하지 마세요.

  • 구현을 완전히 이해합니다.
  • 앱의 환경 및 사양에 맞게 구현을 수정합니다.

이러한 요구 사항을 해결하지 않고 앱에서 보안 코드를 무분별하게 구현해서는 안 됩니다.

내용 유효성 검사

업로드된 콘텐츠에 타사 바이러스/맬웨어 검사 API를 사용합니다.

대용량 시나리오에서 파일 검사는 많은 서버 리소스를 요구합니다. 파일 검사로 인해 요청 처리 성능이 저하된 경우 검사 작업을 백그라운드 서비스로 오프로드하는 것이 좋습니다.이 경우 앱 서버와 다른 서버에서 실행되는 서비스가 있을 수 있습니다. 일반적으로 업로드된 파일은 백그라운드 바이러스 검사 프로그램에서 검사될 때까지 격리된 영역에 저장됩니다. 파일이 전달되면 파일이 일반 파일 스토리지 위치로 이동됩니다. 이러한 단계는 일반적으로 파일의 검사 상태를 나타내는 데이터베이스 레코드와 함께 수행됩니다. 이러한 방법을 사용하여 앱 및 앱 서버는 요청에 응답하는 데 집중합니다.

파일 확장명 유효성 검사

업로드된 파일의 확장명을 허용된 확장명 목록에 따라 확인해야 합니다. 예시:

private string[] permittedExtensions = { ".txt", ".pdf" };

var ext = Path.GetExtension(uploadedFileName).ToLowerInvariant();

if (string.IsNullOrEmpty(ext) || !permittedExtensions.Contains(ext))
{
    // The extension is invalid ... discontinue processing the file
}

파일 서명 유효성 검사

파일의 서명은 파일 시작 부분의 처음 몇 바이트에 의해 결정됩니다. 이러한 바이트는 확장명이 파일 내용과 일치하는지 여부를 나타내는 데 사용할 수 있습니다. 샘플 앱은 몇 가지 일반적인 파일 형식에 대한 파일 서명을 확인합니다. 다음 예제에서는 파일에서 JPEG 이미지 파일에 대한 서명을 확인합니다.

private static readonly Dictionary<string, List<byte[]>> _fileSignature = 
    new Dictionary<string, List<byte[]>>
{
    { ".jpeg", new List<byte[]>
        {
            new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
            new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 },
            new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 },
        }
    },
};

using (var reader = new BinaryReader(uploadedFileData))
{
    var signatures = _fileSignature[ext];
    var headerBytes = reader.ReadBytes(signatures.Max(m => m.Length));

    return signatures.Any(signature => 
        headerBytes.Take(signature.Length).SequenceEqual(signature));
}

추가 파일 서명을 얻으려면 파일 서명 데이터베이스(Google 검색 결과) 및 공식 파일 사양을 사용합니다. 공식 파일 사양을 참조하여 선택한 서명이 유효한지 확인할 수 있습니다.

파일 이름 보안

실제 스토리지에 파일을 저장하는 데 클라이언트에서 제공하는 파일 이름을 사용하지 마세요. Path.GetRandomFileName 또는 Path.GetTempFileName을 사용하여 임시 스토리지에 대한 전체 경로(파일 이름을 포함)를 만들어 파일에 대한 안전한 파일 이름을 만듭니다.

Razor는 표시를 위해 속성 값을 자동으로 HTML 인코딩합니다. 다음 코드는 안전하게 사용할 수 있습니다.

@foreach (var file in Model.DatabaseFiles) {
    <tr>
        <td>
            @file.UntrustedName
        </td>
    </tr>
}

Razor 외부에서는 항상 사용자 요청의 HtmlEncode 파일 이름 콘텐츠입니다.

많은 구현에서 파일 존재 여부에 대한 확인이 포함되어야 합니다. 그렇지 않으면 파일이 같은 이름의 파일을 덮어씁니다. 앱의 사양을 충족하는 추가 논리를 제공합니다.

크기 유효성 검사

업로드된 파일의 크기를 제한합니다.

샘플 앱에서 파일 크기는 2MB(바이트 단위로 표시)로 제한됩니다. 이 제한은 appsettings.json 파일의 구성을 통해 제공됩니다.

{
  "FileSizeLimit": 2097152
}

FileSizeLimitPageModel 클래스에 삽입됩니다.

public class BufferedSingleFileUploadPhysicalModel : PageModel
{
    private readonly long _fileSizeLimit;

    public BufferedSingleFileUploadPhysicalModel(IConfiguration config)
    {
        _fileSizeLimit = config.GetValue<long>("FileSizeLimit");
    }

    ...
}

파일 크기가 제한을 초과하는 파일은 거부됩니다.

if (formFile.Length > _fileSizeLimit)
{
    // The file is too large ... discontinue processing the file
}

이름 특성 값을 POST 메서드의 매개 변수 이름과 일치

양식 데이터를 POST하거나 JavaScript의 FormData를 직접 사용하는 비 Razor 양식에서 양식의 요소 또는 FormData에 지정된 이름은 컨트롤러의 작업에서 매개 변수 이름과 일치해야 합니다.

다음 예제에서

  • <input> 요소를 사용하는 경우 name 특성은 값 battlePlans로 설정됩니다.

    <input type="file" name="battlePlans" multiple>
    
  • JavaScript에서 FormData를 사용하는 경우 이름이 값 battlePlans로 설정됩니다.

    var formData = new FormData();
    
    for (var file in files) {
      formData.append("battlePlans", file, file.name);
    }
    

C# 메서드(battlePlans)의 매개 변수와 일치하는 이름을 사용합니다.

  • 이름이 Upload인 Razor Pages 페이지 처리기 메서드:

    public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> battlePlans)
    
  • MVC POST 컨트롤러 작업 메서드:

    public async Task<IActionResult> Post(List<IFormFile> battlePlans)
    

서버 및 앱 구성

다중 파트 본문 길이 제한

MultipartBodyLengthLimit에서 각 다중 파트 본문의 길이에 대한 제한을 설정합니다. 양식 섹션이 이 제한을 초과하면 구문 분석할 때 InvalidDataException이 throw됩니다. 기본값은 134,217,728(128MB)입니다. Startup.ConfigureServices에서 MultipartBodyLengthLimit 설정을 사용하여 제한을 사용자 지정합니다.

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<FormOptions>(options =>
    {
        // Set the limit to 256 MB
        options.MultipartBodyLengthLimit = 268435456;
    });
}

RequestFormLimitsAttribute는 단일 페이지 또는 작업에 대해 MultipartBodyLengthLimit을 설정하는 데 사용됩니다.

Razor Pages 앱에서 Startup.ConfigureServices규칙을 사용하여 필터를 적용합니다.

services.AddRazorPages(options =>
{
    options.Conventions
        .AddPageApplicationModelConvention("/FileUploadPage",
            model.Filters.Add(
                new RequestFormLimitsAttribute()
                {
                    // Set the limit to 256 MB
                    MultipartBodyLengthLimit = 268435456
                });
});

Razor Pages 앱 또는 MVC 앱에서 페이지 모델 또는 작업 메서드에 필터를 적용합니다.

// Set the limit to 256 MB
[RequestFormLimits(MultipartBodyLengthLimit = 268435456)]
public class BufferedSingleFileUploadPhysicalModel : PageModel
{
    ...
}

Kestrel 최대 요청 본문 크기

Kestrel에서 호스트되는 앱의 경우 기본 최대 요청 본문 크기는 30,000,000바이트(약 28.6MB)입니다. MaxRequestBodySizeKestrel 서버 옵션을 사용하여 제한을 사용자 지정합니다.

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.ConfigureKestrel((context, options) =>
            {
                // Handle requests up to 50 MB
                options.Limits.MaxRequestBodySize = 52428800;
            })
            .UseStartup<Startup>();
        });

RequestSizeLimitAttribute는 단일 페이지 또는 작업에 대해 MaxRequestBodySize를 설정하는 데 사용됩니다.

Razor Pages 앱에서 Startup.ConfigureServices규칙을 사용하여 필터를 적용합니다.

services.AddRazorPages(options =>
{
    options.Conventions
        .AddPageApplicationModelConvention("/FileUploadPage",
            model =>
            {
                // Handle requests up to 50 MB
                model.Filters.Add(
                    new RequestSizeLimitAttribute(52428800));
            });
});

Razor Pages 앱 또는 MVC 앱에서 페이지 처리기 모델 또는 작업 메서드에 필터를 적용합니다.

// Handle requests up to 50 MB
[RequestSizeLimit(52428800)]
public class BufferedSingleFileUploadPhysicalModel : PageModel
{
    ...
}

지시 RequestSizeLimitAttribute 문을 사용하여 적용할 수도 있습니다.@attributeRazor

@attribute [RequestSizeLimitAttribute(52428800)]

다른 Kestrel 제한

다른 Kestrel 제한이 Kestrel에서 호스트되는 앱에 적용될 수 있습니다.

IIS

기본 요청 제한(maxAllowedContentLength)은 30,000,000바이트(약 28.6MB)입니다. web.config 파일에서 제한을 사용자 지정합니다. 다음 예제에서 제한은 50MB(52,428,800바이트)로 설정됩니다.

<system.webServer>
  <security>
    <requestFiltering>
      <requestLimits maxAllowedContentLength="52428800" />
    </requestFiltering>
  </security>
</system.webServer>

maxAllowedContentLength 설정은 IIS에만 적용됩니다. 자세한 내용은 요청 제한<requestLimits>을 참조하세요.

Startup.ConfigureServices에서 IISServerOptions.MaxRequestBodySize을 설정하여 HTTP 요청에 대한 최대 요청 본문 크기를 늘립니다. 다음 예제에서 제한은 50MB(52,428,800바이트)로 설정됩니다.

services.Configure<IISServerOptions>(options =>
{
    options.MaxRequestBodySize = 52428800;
});

자세한 내용은 IIS가 있는 Windows에서 ASP.NET Core 호스팅을 참조하세요.

문제 해결

다음은 파일 업로드 및 가능한 솔루션을 사용하여 작업할 때 자주 발생하는 몇 가지 일반적인 문제입니다.

IIS 서버에 배포할 때 찾을 수 없음 오류

다음 오류는 업로드된 파일이 서버의 구성된 콘텐츠 길이를 초과했음을 나타냅니다.

HTTP 404.13 - Not Found
The request filtering module is configured to deny a request that exceeds the request content length.

자세한 내용은 IIS 섹션을 참조하세요.

연결 실패

연결 오류 및 서버 연결 다시 설정은 업로드된 파일이 Kestrel의 최대 요청 본문 크기를 초과함을 나타낼 수 있습니다. 자세한 내용은 Kestrel 최대 요청 본문 크기 섹션을 참조하세요. Kestrel 클라이언트 연결 제한을 조정해야 할 수도 있습니다.

IFormFile을 사용한 Null 참조 예외

컨트롤러에서 IFormFile을 사용하여 업로드된 파일을 수락하지만 값이 null이면 HTML 양식에서 multipart/form-dataenctype 값을 지정하는지 확인합니다. <form> 요소에서 이 특성이 설정되지 않으면 파일 업로드가 실행되지 않고 바인딩된 IFormFile 인수는 모두 null이 됩니다. 또한 양식 데이터의 업로드 이름 지정이 앱의 이름 지정과 일치하는지 확인합니다.

스트림이 너무 깁니다.

이 항목의 예제에서는 업로드된 파일의 콘텐츠를 저장하는 데 MemoryStream에 의존합니다. MemoryStream의 크기 제한은 int.MaxValue입니다. 앱의 파일 업로드 시나리오에서 50MB보다 큰 파일 콘텐츠를 보관해야 하는 경우 업로드된 파일의 콘텐츠를 보관할 때 단일 MemoryStream에 의존하지 않는 대체 방법을 사용하세요.

ASP.NET Core는 소용량 파일의 경우에는 버퍼링된 모델 바인딩을 사용하여 하나 이상의 파일을 업로드하고, 대용량 파일의 경우에는 버퍼링되지 않은 스트리밍을 지원합니다.

샘플 코드 보기 및 다운로드(다운로드 방법)

보안 고려 사항

사용자에게 서버에 파일을 업로드하는 기능을 제공할 때는 주의해야 합니다. 공격자는 다음을 시도할 수 있습니다.

  • 서비스 거부 공격을 실행.
  • 바이러스 또는 맬웨어를 업로드.
  • 다른 방법으로 네트워크 및 서버를 손상.

공격이 성공할 가능성을 줄이는 보안 단계는 다음과 같습니다.

  • 전용 파일 업로드 영역(바람직하게는 시스템 드라이브가 아닌 위치)에 파일을 업로드합니다. 전용 위치를 사용하면 업로드된 파일에 대한 보안 제한을 더 쉽게 적용할 수 있습니다. 파일 업로드 위치에 대한 실행 권한을 사용하지 않도록 설정합니다.†
  • 업로드된 파일을 앱과 동일한 디렉터리 트리에 보관하지 마세요.
  • 앱에 의해 결정된 안전한 파일 이름을 사용합니다. 사용자가 제공한 파일 이름이나 업로드된 파일의 신뢰할 수 없는 파일 이름을 사용하지 마세요.† HTML은 이름을 표시할 때 신뢰할 수 없는 파일 이름일 경우 이를 인코딩합니다. 파일 이름을 로깅하거나 UI에 표시하는 경우를 예로 들 수 있습니다(Razor에서 자동으로 출력을 HTML로 인코딩).
  • 앱의 디자인 사양으로 승인된 파일 확장명만 허용합니다.†
  • 서버에서 클라이언트 쪽 검사가 수행되는지 확인합니다.† 클라이언트 쪽 검사는 쉽게 우회할 수 있습니다.
  • 업로드된 파일의 크기를 확인합니다. 대규모로 업로드되지 않도록 최대 크기 제한을 설정합니다.†
  • 업로드된 파일이 같은 이름의 파일을 덮어쓰면 안 되는 경우 파일을 업로드하기 전에 데이터베이스 또는 실제 스토리지에서 파일 이름을 확인합니다.
  • 파일이 저장되기 전에 업로드된 콘텐츠에 대해 바이러스/맬웨어 스캐너를 실행합니다.

†샘플 앱은 조건을 충족하는 방법을 보여 줍니다.

Warning

시스템에 악성 코드를 업로드하는 행위는 흔히 다음을 수행할 수 있는 코드를 실행하기 위한 첫 단계가 됩니다.

  • 시스템을 완전히 제어합니다.
  • 시스템 작동이 중단되는 결과로 시스템을 오버로드합니다.
  • 사용자 또는 시스템 데이터를 손상시킵니다.
  • 공용 UI에 그래피티를 적용합니다.

사용자의 파일을 수락할 때 공격 노출 영역을 줄이는 방법에 대한 자세한 내용은 다음 리소스를 참조하세요.

샘플 앱의 예제를 포함하여 보안 조치를 구현하는 방법에 대한 자세한 내용은 유효성 검사 섹션을 참조하세요.

스토리지 시나리오

파일에 대한 일반적인 스토리지 옵션은 다음과 같습니다.

  • Database

    • 작은 파일 업로드의 경우 데이터베이스가 실제 스토리지(파일 시스템 또는 네트워크 공유) 옵션보다 빠른 경우가 많습니다.
    • 사용자 데이터에 대한 데이터베이스 레코드를 검색할 때 파일 콘텐츠(예: 아바타 이미지)를 동시에 제공할 수 있으므로 데이터베이스는 실제 스토리지 옵션보다 편리합니다.
    • 데이터베이스는 데이터 스토리지 서비스를 사용하는 것보다 비용이 적게 들 수 있습니다.
  • 실제 스토리지(파일 시스템 또는 네트워크 공유)

    • 대용량 파일 업로드의 경우:
      • 데이터베이스 한도 때문에 업로드 크기가 제한될 수 있습니다.
      • 실제 스토리지는 데이터베이스 스토리지보다 경제적이지 않은 경우가 자주 있습니다.
    • 실제 스토리지는 데이터 스토리지 서비스를 사용하는 것보다 비용이 적게 들 수 있습니다.
    • 앱의 프로세스에는 스토리지 위치에 대한 읽기 및 쓰기 권한이 있어야 합니다. 실행 권한을 부여하지 마세요.
  • 데이터 스토리지 서비스(예: Azure Blob Storage)

    • 서비스는 일반적으로 단일 실패 지점에 노출되는 온-프레미스 솔루션에 비해 향상된 확장성 및 복원력을 제공합니다.
    • 서비스는 대용량 스토리지 인프라 시나리오에서 비용이 더 저렴할 수 있습니다.

    자세한 내용은 빠른 시작: .NET을 사용하여 개체 스토리지에 Blob 만들기를 참조하세요. 이 항목에서는 UploadFromFileAsync를 보여 주지만, Stream에서 작업할 때 UploadFromStreamAsync를 사용하여 FileStream을 Blob Storage에 저장할 수 있습니다.

파일 업로드 시나리오

파일 업로드를 위한 일반적인 방법 두 가지는 버퍼링 및 스트리밍입니다.

버퍼링

전체 파일을 파일 처리 또는 저장에 사용되는 파일의 C# 표현인 IFormFile로 읽어 들입니다.

파일 업로드에서 사용되는 리소스(디스크, 메모리)는 동시 파일 업로드 크기와 수에 따라 달라집니다. 앱이 너무 많은 업로드를 버퍼링하려 할 경우 메모리 또는 디스크 공간이 부족하면 사이트의 작동이 중단됩니다. 파일 업로드의 크기 또는 빈도로 인해 앱 리소스가 소진되는 경우 스트리밍을 사용합니다.

참고 항목

버퍼링된 단일 파일이 64KB를 초과하는 경우 메모리에서 디스크의 임시 파일로 이동됩니다.

소용량 파일 버퍼링은 이 항목의 다음 섹션에서 설명합니다.

스트리밍

파일은 다중 파트 요청에서 수신되며 앱에서 직접 처리하거나 저장합니다. 스트리밍은 성능을 크게 개선하지 않습니다. 스트리밍을 통해 파일을 업로드하면 메모리 또는 디스크 공간에 대한 요구가 줄어듭니다.

대용량 파일 스트리밍은 스트리밍을 사용하여 대용량 파일 업로드 섹션에서 설명합니다.

버퍼링된 모델 바인딩을 사용하여 소용량 파일을 실제 스토리지에 업로드

소용량 파일을 업로드하려면 다중 파트 양식을 사용하거나 JavaScript를 사용하여 POST 요청을 생성합니다.

다음 예제에서는 페이지 양식을 사용하여 Razor 샘플 앱에서 단일 파일을Pages/BufferedSingleFileUploadPhysical.cshtml 업로드하는 방법을 보여 줍니다.

<form enctype="multipart/form-data" method="post">
    <dl>
        <dt>
            <label asp-for="FileUpload.FormFile"></label>
        </dt>
        <dd>
            <input asp-for="FileUpload.FormFile" type="file">
            <span asp-validation-for="FileUpload.FormFile"></span>
        </dd>
    </dl>
    <input asp-page-handler="Upload" class="btn" type="submit" value="Upload" />
</form>

다음 예제는 이전 예제와 비슷하지만 다음과 같은 차이가 있습니다.

  • 양식의 데이터를 제출하는 데 JavaScript(Fetch API)가 사용됩니다.
  • 유효성 검사를 수행하지 않습니다.
<form action="BufferedSingleFileUploadPhysical/?handler=Upload" 
      enctype="multipart/form-data" onsubmit="AJAXSubmit(this);return false;" 
      method="post">
    <dl>
        <dt>
            <label for="FileUpload_FormFile">File</label>
        </dt>
        <dd>
            <input id="FileUpload_FormFile" type="file" 
                name="FileUpload.FormFile" />
        </dd>
    </dl>

    <input class="btn" type="submit" value="Upload" />

    <div style="margin-top:15px">
        <output name="result"></output>
    </div>
</form>

<script>
  async function AJAXSubmit (oFormElement) {
    var resultElement = oFormElement.elements.namedItem("result");
    const formData = new FormData(oFormElement);

    try {
    const response = await fetch(oFormElement.action, {
      method: 'POST',
      body: formData
    });

    if (response.ok) {
      window.location.href = '/';
    }

    resultElement.value = 'Result: ' + response.status + ' ' + 
      response.statusText;
    } catch (error) {
      console.error('Error:', error);
    }
  }
</script>

Fetch API를 지원하지 않는 클라이언트에 대해 JavaScript로 양식 POST를 수행하려면 다음 방법 중 하나를 사용합니다.

  • Fetch Polyfill(예: window.fetch polyfill (github/fetch))을 사용합니다.

  • XMLHttpRequest을 사용합니다. 예시:

    <script>
      "use strict";
    
      function AJAXSubmit (oFormElement) {
        var oReq = new XMLHttpRequest();
        oReq.onload = function(e) { 
        oFormElement.elements.namedItem("result").value = 
          'Result: ' + this.status + ' ' + this.statusText;
        };
        oReq.open("post", oFormElement.action);
        oReq.send(new FormData(oFormElement));
      }
    </script>
    

파일 업로드를 지원하려면 HTML 양식에서 multipart/form-data의 인코딩 유형(enctype)을 지정해야 합니다.

files 입력 요소가 다중 파일 업로드를 지원하려면 <input> 요소에 multiple 특성을 제공합니다.

<input asp-for="FileUpload.FormFiles" type="file" multiple>

서버에 업로드된 개별 파일은 IFormFile을 사용하여 모델 바인딩을 통해 액세스할 수 있습니다. 샘플 앱은 데이터베이스 및 실제 스토리지 시나리오에 대한 다중 버퍼링된 파일 업로드를 보여 줍니다.

Warning

표시 및 로깅 이외에는 IFormFileFileName 속성을 사용하지 마세요. 표시하거나 로깅할 경우 파일 이름을 HTML로 인코딩합니다. 공격자는 전체 경로나 상대 경로를 포함하여 악의적인 파일 이름을 제공할 수 있습니다. 애플리케이션에서 다음을 수행해야 합니다.

  • 사용자가 제공한 파일 이름에서 경로를 제거합니다.
  • UI 또는 로깅을 위해 HTML로 인코딩되고 경로가 제거된 파일 이름을 저장합니다.
  • 스토리지의 새 임의 파일 이름을 생성합니다.

다음 코드는 파일 이름에서 경로를 제거합니다.

string untrustedFileName = Path.GetFileName(pathName);

지금까지 제시한 예제에서는 보안 고려 사항을 감안하지 않습니다. 추가 정보는 다음 섹션과 샘플 앱에서 제공합니다.

모델 바인딩 및 IFormFile을 사용하여 파일을 업로드하는 경우 작업 메서드에서 다음을 허용할 수 있습니다.

참고 항목

바인딩은 이름을 기준으로 양식 파일을 일치시킵니다. 예를 들어 <input type="file" name="formFile">의 HTML name 값은 바인딩된 C# 매개 변수/속성(FormFile)과 일치해야 합니다. 자세한 내용은 이름 특성 값을 POST 메서드의 매개 변수 이름과 일치 섹션을 참조하세요.

다음 예제를 참조하세요.

  • 하나 이상의 업로드된 파일을 반복합니다.
  • Path.GetTempFileName을 사용하여 파일 이름을 포함하는 파일 전체 경로를 반환합니다.
  • 앱에서 생성한 파일 이름을 사용하여 로컬 파일 시스템에 파일을 저장합니다.
  • 업로드된 파일의 총 수와 크기를 반환합니다.
public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> files)
{
    long size = files.Sum(f => f.Length);

    foreach (var formFile in files)
    {
        if (formFile.Length > 0)
        {
            var filePath = Path.GetTempFileName();

            using (var stream = System.IO.File.Create(filePath))
            {
                await formFile.CopyToAsync(stream);
            }
        }
    }

    // Process uploaded files
    // Don't rely on or trust the FileName property without validation.

    return Ok(new { count = files.Count, size });
}

Path.GetRandomFileName을 사용하여 경로 없이 파일 이름을 생성합니다. 다음 예제에서는 경로를 구성에서 가져옵니다.

foreach (var formFile in files)
{
    if (formFile.Length > 0)
    {
        var filePath = Path.Combine(_config["StoredFilesPath"], 
            Path.GetRandomFileName());

        using (var stream = System.IO.File.Create(filePath))
        {
            await formFile.CopyToAsync(stream);
        }
    }
}

FileStream에 전달된 경로는 반드시 파일 이름을 포함해야 합니다. 파일 이름을 제공하지 않으면 런타임에 UnauthorizedAccessException이 발생합니다.

IFormFile 기술을 사용하여 업로드된 파일은 처리 전에 서버의 메모리나 디스크에 버퍼링됩니다. 작업 메서드 내부에서 IFormFile 내용을 Stream으로 액세스할 수 있습니다. 로컬 파일 시스템 외에도 파일을 네트워크 공유 또는 파일 스토리지 서비스(예: Azure Blob Storage)에 저장할 수 있습니다.

업로드를 위해 여러 파일을 반복하고 안전한 파일 이름을 사용하는 또 다른 예제는 샘플 앱을 참조 Pages/BufferedMultipleFileUploadPhysical.cshtml.cs 하세요.

Warning

이전 임시 파일을 삭제하지 않고 65,535개를 초과하는 파일을 만들면 Path.GetTempFileNameIOException을 throw합니다. 65535개 파일 제한은 서버당 제한입니다. Windows OS에서 이 제한에 대한 자세한 내용은 다음 항목의 설명을 참조하세요.

버퍼링된 모델 바인딩을 사용하여 소용량 파일을 데이터베이스에 업로드

Entity Framework를 사용하여 데이터베이스에 이진 파일 데이터를 저장하려면 엔터티에서 Byte 배열 속성을 정의합니다.

public class AppFile
{
    public int Id { get; set; }
    public byte[] Content { get; set; }
}

IFormFile을 포함하는 클래스에 대한 페이지 모델 속성을 지정합니다.

public class BufferedSingleFileUploadDbModel : PageModel
{
    ...

    [BindProperty]
    public BufferedSingleFileUploadDb FileUpload { get; set; }

    ...
}

public class BufferedSingleFileUploadDb
{
    [Required]
    [Display(Name="File")]
    public IFormFile FormFile { get; set; }
}

참고 항목

IFormFile은 작업 메서드 매개 변수 또는 바운딩 모델 속성으로 직접 사용할 수 있습니다. 위 예제에서는 바인딩된 모델 속성을 사용합니다.

FileUpload는 Razor Pages 양식에 사용됩니다.

<form enctype="multipart/form-data" method="post">
    <dl>
        <dt>
            <label asp-for="FileUpload.FormFile"></label>
        </dt>
        <dd>
            <input asp-for="FileUpload.FormFile" type="file">
        </dd>
    </dl>
    <input asp-page-handler="Upload" class="btn" type="submit" value="Upload">
</form>

양식이 서버에 POST되면 IFormFile을 스트림으로 복사하여 데이터베이스에 바이트 배열로 저장합니다. 다음 예제에서 _dbContext는 앱의 데이터베이스 컨텍스트를 저장합니다.

public async Task<IActionResult> OnPostUploadAsync()
{
    using (var memoryStream = new MemoryStream())
    {
        await FileUpload.FormFile.CopyToAsync(memoryStream);

        // Upload the file if less than 2 MB
        if (memoryStream.Length < 2097152)
        {
            var file = new AppFile()
            {
                Content = memoryStream.ToArray()
            };

            _dbContext.File.Add(file);

            await _dbContext.SaveChangesAsync();
        }
        else
        {
            ModelState.AddModelError("File", "The file is too large.");
        }
    }

    return Page();
}

위의 예제는 샘플 앱에서 보여 주는 시나리오와 비슷합니다.

  • Pages/BufferedSingleFileUploadDb.cshtml
  • Pages/BufferedSingleFileUploadDb.cshtml.cs

Warning

관계형 데이터베이스에 이진 데이터를 저장할 경우 성능에 나쁜 영향을 줄 수 있으므로 주의하세요.

유효성 검사 없이 IFormFileFileName 속성을 의존하거나 신뢰하지 마세요. FileName 속성은 반드시 HTML 인코딩 후 표시 목적으로만 사용해야 합니다.

제시한 예제에서는 보안 고려 사항을 감안하지 않습니다. 추가 정보는 다음 섹션과 샘플 앱에서 제공합니다.

스트리밍을 사용하여 대용량 파일 업로드

다음 예제에서는 JavaScript를 사용하여 컨트롤러 작업에 파일을 스트리밍하는 방법을 보여 줍니다. 사용자 지정 필터 특성을 사용하여 파일의 위조 방지 토큰이 생성되고 요청 본문 대신 클라이언트 HTTP 헤더에 전달됩니다. 작업 메서드에서 업로드된 데이터를 직접 처리하므로 다른 사용자 지정 필터에서 형식 모델 바인딩을 사용할 수 없습니다. 작업 내에서 양식의 콘텐츠는 각 개별 MultipartSection을 읽고 적절하게 파일을 처리하거나 콘텐츠를 저장하는 MultipartReader를 사용하여 읽습니다. 다중 파트 섹션을 읽은 후 작업에서 자체 모델 바인딩을 수행합니다.

초기 페이지 응답에서는 양식을 로드하고 위조 방지 토큰을 cookie에 저장합니다(GenerateAntiforgeryTokenCookieAttribute 특성을 통해). 이 특성은 ASP.NET Core의 기본 제공 위조 방지 지원을 사용하여 요청 토큰으로 cookie를 설정합니다.

public class GenerateAntiforgeryTokenCookieAttribute : ResultFilterAttribute
{
    public override void OnResultExecuting(ResultExecutingContext context)
    {
        var antiforgery = context.HttpContext.RequestServices.GetService<IAntiforgery>();

        // Send the request token as a JavaScript-readable cookie
        var tokens = antiforgery.GetAndStoreTokens(context.HttpContext);

        context.HttpContext.Response.Cookies.Append(
            "RequestVerificationToken",
            tokens.RequestToken,
            new CookieOptions() { HttpOnly = false });
    }

    public override void OnResultExecuted(ResultExecutedContext context)
    {
    }
}

DisableFormValueModelBindingAttribute은 모델 바인딩을 사용하지 않도록 설정하는 데 사용됩니다.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        var factories = context.ValueProviderFactories;
        factories.RemoveType<FormValueProviderFactory>();
        factories.RemoveType<JQueryFormValueProviderFactory>();
    }

    public void OnResourceExecuted(ResourceExecutedContext context)
    {
    }
}

샘플 앱에서 GenerateAntiforgeryTokenCookieAttributeDisableFormValueModelBindingAttributeRazor Pages 규칙을 사용하여 Startup.ConfigureServices/StreamedSingleFileUploadDb/StreamedSingleFileUploadPhysical의 페이지 애플리케이션 모델에 필터로 적용됩니다.

services.AddMvc()
    .AddRazorPagesOptions(options =>
        {
            options.Conventions
                .AddPageApplicationModelConvention("/StreamedSingleFileUploadDb",
                    model =>
                    {
                        model.Filters.Add(
                            new GenerateAntiforgeryTokenCookieAttribute());
                        model.Filters.Add(
                            new DisableFormValueModelBindingAttribute());
                    });
            options.Conventions
                .AddPageApplicationModelConvention("/StreamedSingleFileUploadPhysical",
                    model =>
                    {
                        model.Filters.Add(
                            new GenerateAntiforgeryTokenCookieAttribute());
                        model.Filters.Add(
                            new DisableFormValueModelBindingAttribute());
                    });
        })
    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

모델 바인딩은 양식을 읽지 않으므로 양식에서 바인딩된 매개 변수가 바인딩되지 않습니다(쿼리, 경로 및 헤더는 계속 작동함). 작업 메서드는 Request 속성으로 직접 작동합니다. MultipartReader는 각 섹션을 읽는 데 사용됩니다. 키/값 데이터는 KeyValueAccumulator에 저장됩니다. 다중 파트 섹션을 읽은 후 KeyValueAccumulator의 내용이 양식 데이터를 모델 형식으로 바인딩하는 데 사용됩니다.

EF Core를 사용하여 데이터베이스에 스트리밍하기 위한 전체 StreamingController.UploadDatabase 메서드는 다음과 같습니다.

[HttpPost]
[DisableFormValueModelBinding]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadDatabase()
{
    if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
    {
        ModelState.AddModelError("File", 
            $"The request couldn't be processed (Error 1).");
        // Log error

        return BadRequest(ModelState);
    }

    // Accumulate the form data key-value pairs in the request (formAccumulator).
    var formAccumulator = new KeyValueAccumulator();
    var trustedFileNameForDisplay = string.Empty;
    var untrustedFileNameForStorage = string.Empty;
    var streamedFileContent = Array.Empty<byte>();

    var boundary = MultipartRequestHelper.GetBoundary(
        MediaTypeHeaderValue.Parse(Request.ContentType),
        _defaultFormOptions.MultipartBoundaryLengthLimit);
    var reader = new MultipartReader(boundary, HttpContext.Request.Body);

    var section = await reader.ReadNextSectionAsync();

    while (section != null)
    {
        var hasContentDispositionHeader = 
            ContentDispositionHeaderValue.TryParse(
                section.ContentDisposition, out var contentDisposition);

        if (hasContentDispositionHeader)
        {
            if (MultipartRequestHelper
                .HasFileContentDisposition(contentDisposition))
            {
                untrustedFileNameForStorage = contentDisposition.FileName.Value;
                // Don't trust the file name sent by the client. To display
                // the file name, HTML-encode the value.
                trustedFileNameForDisplay = WebUtility.HtmlEncode(
                        contentDisposition.FileName.Value);

                streamedFileContent = 
                    await FileHelpers.ProcessStreamedFile(section, contentDisposition, 
                        ModelState, _permittedExtensions, _fileSizeLimit);

                if (!ModelState.IsValid)
                {
                    return BadRequest(ModelState);
                }
            }
            else if (MultipartRequestHelper
                .HasFormDataContentDisposition(contentDisposition))
            {
                // Don't limit the key name length because the 
                // multipart headers length limit is already in effect.
                var key = HeaderUtilities
                    .RemoveQuotes(contentDisposition.Name).Value;
                var encoding = GetEncoding(section);

                if (encoding == null)
                {
                    ModelState.AddModelError("File", 
                        $"The request couldn't be processed (Error 2).");
                    // Log error

                    return BadRequest(ModelState);
                }

                using (var streamReader = new StreamReader(
                    section.Body,
                    encoding,
                    detectEncodingFromByteOrderMarks: true,
                    bufferSize: 1024,
                    leaveOpen: true))
                {
                    // The value length limit is enforced by 
                    // MultipartBodyLengthLimit
                    var value = await streamReader.ReadToEndAsync();

                    if (string.Equals(value, "undefined", 
                        StringComparison.OrdinalIgnoreCase))
                    {
                        value = string.Empty;
                    }

                    formAccumulator.Append(key, value);

                    if (formAccumulator.ValueCount > 
                        _defaultFormOptions.ValueCountLimit)
                    {
                        // Form key count limit of 
                        // _defaultFormOptions.ValueCountLimit 
                        // is exceeded.
                        ModelState.AddModelError("File", 
                            $"The request couldn't be processed (Error 3).");
                        // Log error

                        return BadRequest(ModelState);
                    }
                }
            }
        }

        // Drain any remaining section body that hasn't been consumed and
        // read the headers for the next section.
        section = await reader.ReadNextSectionAsync();
    }

    // Bind form data to the model
    var formData = new FormData();
    var formValueProvider = new FormValueProvider(
        BindingSource.Form,
        new FormCollection(formAccumulator.GetResults()),
        CultureInfo.CurrentCulture);
    var bindingSuccessful = await TryUpdateModelAsync(formData, prefix: "",
        valueProvider: formValueProvider);

    if (!bindingSuccessful)
    {
        ModelState.AddModelError("File", 
            "The request couldn't be processed (Error 5).");
        // Log error

        return BadRequest(ModelState);
    }

    // **WARNING!**
    // In the following example, the file is saved without
    // scanning the file's contents. In most production
    // scenarios, an anti-virus/anti-malware scanner API
    // is used on the file before making the file available
    // for download or for use by other systems. 
    // For more information, see the topic that accompanies 
    // this sample app.

    var file = new AppFile()
    {
        Content = streamedFileContent,
        UntrustedName = untrustedFileNameForStorage,
        Note = formData.Note,
        Size = streamedFileContent.Length, 
        UploadDT = DateTime.UtcNow
    };

    _context.File.Add(file);
    await _context.SaveChangesAsync();

    return Created(nameof(StreamingController), null);
}

MultipartRequestHelper (Utilities/MultipartRequestHelper.cs):

using System;
using System.IO;
using Microsoft.Net.Http.Headers;

namespace SampleApp.Utilities
{
    public static class MultipartRequestHelper
    {
        // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq"
        // The spec at https://tools.ietf.org/html/rfc2046#section-5.1 states that 70 characters is a reasonable limit.
        public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
        {
            var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value;

            if (string.IsNullOrWhiteSpace(boundary))
            {
                throw new InvalidDataException("Missing content-type boundary.");
            }

            if (boundary.Length > lengthLimit)
            {
                throw new InvalidDataException(
                    $"Multipart boundary length limit {lengthLimit} exceeded.");
            }

            return boundary;
        }

        public static bool IsMultipartContentType(string contentType)
        {
            return !string.IsNullOrEmpty(contentType)
                   && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition)
        {
            // Content-Disposition: form-data; name="key";
            return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && string.IsNullOrEmpty(contentDisposition.FileName.Value)
                && string.IsNullOrEmpty(contentDisposition.FileNameStar.Value);
        }

        public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition)
        {
            // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg"
            return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && (!string.IsNullOrEmpty(contentDisposition.FileName.Value)
                    || !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value));
        }
    }
}

실제 위치로 스트리밍하기 위한 전체 StreamingController.UploadPhysical 메서드는 다음과 같습니다.

[HttpPost]
[DisableFormValueModelBinding]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadPhysical()
{
    if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
    {
        ModelState.AddModelError("File", 
            $"The request couldn't be processed (Error 1).");
        // Log error

        return BadRequest(ModelState);
    }

    var boundary = MultipartRequestHelper.GetBoundary(
        MediaTypeHeaderValue.Parse(Request.ContentType),
        _defaultFormOptions.MultipartBoundaryLengthLimit);
    var reader = new MultipartReader(boundary, HttpContext.Request.Body);
    var section = await reader.ReadNextSectionAsync();

    while (section != null)
    {
        var hasContentDispositionHeader = 
            ContentDispositionHeaderValue.TryParse(
                section.ContentDisposition, out var contentDisposition);

        if (hasContentDispositionHeader)
        {
            // This check assumes that there's a file
            // present without form data. If form data
            // is present, this method immediately fails
            // and returns the model error.
            if (!MultipartRequestHelper
                .HasFileContentDisposition(contentDisposition))
            {
                ModelState.AddModelError("File", 
                    $"The request couldn't be processed (Error 2).");
                // Log error

                return BadRequest(ModelState);
            }
            else
            {
                // Don't trust the file name sent by the client. To display
                // the file name, HTML-encode the value.
                var trustedFileNameForDisplay = WebUtility.HtmlEncode(
                        contentDisposition.FileName.Value);
                var trustedFileNameForFileStorage = Path.GetRandomFileName();

                // **WARNING!**
                // In the following example, the file is saved without
                // scanning the file's contents. In most production
                // scenarios, an anti-virus/anti-malware scanner API
                // is used on the file before making the file available
                // for download or for use by other systems. 
                // For more information, see the topic that accompanies 
                // this sample.

                var streamedFileContent = await FileHelpers.ProcessStreamedFile(
                    section, contentDisposition, ModelState, 
                    _permittedExtensions, _fileSizeLimit);

                if (!ModelState.IsValid)
                {
                    return BadRequest(ModelState);
                }

                using (var targetStream = System.IO.File.Create(
                    Path.Combine(_targetFilePath, trustedFileNameForFileStorage)))
                {
                    await targetStream.WriteAsync(streamedFileContent);

                    _logger.LogInformation(
                        "Uploaded file '{TrustedFileNameForDisplay}' saved to " +
                        "'{TargetFilePath}' as {TrustedFileNameForFileStorage}", 
                        trustedFileNameForDisplay, _targetFilePath, 
                        trustedFileNameForFileStorage);
                }
            }
        }

        // Drain any remaining section body that hasn't been consumed and
        // read the headers for the next section.
        section = await reader.ReadNextSectionAsync();
    }

    return Created(nameof(StreamingController), null);
}

샘플 앱에서 유효성 검사는 FileHelpers.ProcessStreamedFile에 의해 처리됩니다.

유효성 검사

샘플 앱의 FileHelpers 클래스는 버퍼링된 IFormFile 및 스트리밍된 파일 업로드에 대한 여러 검사를 보여 줍니다. 샘플 앱에서 버퍼링된 파일 업로드를 처리하는 IFormFile 방법은 파일의 메서드를 ProcessFormFileUtilities/FileHelpers.cs 참조하세요. 스트리밍된 파일의 처리는 동일한 파일의 ProcessStreamedFile 메서드를 참조하세요.

Warning

샘플 앱에서 보여 주는 유효성 검사 처리 메서드는 업로드된 파일의 내용을 검사하지 않습니다. 대부분의 프로덕션 시나리오에서는 사용자 또는 다른 시스템에서 파일을 사용할 수 있도록 하기 전에 파일에 바이러스/맬웨어 스캐너 API를 사용합니다.

항목 샘플에서는 유효성 검사 기술에 대한 작업 예제를 제공하지만, 다음과 같은 경우가 아니면 프로덕션 앱에서 FileHelpers 클래스를 구현하지 마세요.

  • 구현을 완전히 이해합니다.
  • 앱의 환경 및 사양에 맞게 구현을 수정합니다.

이러한 요구 사항을 해결하지 않고 앱에서 보안 코드를 무분별하게 구현해서는 안 됩니다.

내용 유효성 검사

업로드된 콘텐츠에 타사 바이러스/맬웨어 검사 API를 사용합니다.

대용량 시나리오에서 파일 검사는 많은 서버 리소스를 요구합니다. 파일 검사로 인해 요청 처리 성능이 저하된 경우 검사 작업을 백그라운드 서비스로 오프로드하는 것이 좋습니다.이 경우 앱 서버와 다른 서버에서 실행되는 서비스가 있을 수 있습니다. 일반적으로 업로드된 파일은 백그라운드 바이러스 검사 프로그램에서 검사될 때까지 격리된 영역에 저장됩니다. 파일이 전달되면 파일이 일반 파일 스토리지 위치로 이동됩니다. 이러한 단계는 일반적으로 파일의 검사 상태를 나타내는 데이터베이스 레코드와 함께 수행됩니다. 이러한 방법을 사용하여 앱 및 앱 서버는 요청에 응답하는 데 집중합니다.

파일 확장명 유효성 검사

업로드된 파일의 확장명을 허용된 확장명 목록에 따라 확인해야 합니다. 예시:

private string[] permittedExtensions = { ".txt", ".pdf" };

var ext = Path.GetExtension(uploadedFileName).ToLowerInvariant();

if (string.IsNullOrEmpty(ext) || !permittedExtensions.Contains(ext))
{
    // The extension is invalid ... discontinue processing the file
}

파일 서명 유효성 검사

파일의 서명은 파일 시작 부분의 처음 몇 바이트에 의해 결정됩니다. 이러한 바이트는 확장명이 파일 내용과 일치하는지 여부를 나타내는 데 사용할 수 있습니다. 샘플 앱은 몇 가지 일반적인 파일 형식에 대한 파일 서명을 확인합니다. 다음 예제에서는 파일에서 JPEG 이미지 파일에 대한 서명을 확인합니다.

private static readonly Dictionary<string, List<byte[]>> _fileSignature = 
    new Dictionary<string, List<byte[]>>
{
    { ".jpeg", new List<byte[]>
        {
            new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
            new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 },
            new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 },
        }
    },
};

using (var reader = new BinaryReader(uploadedFileData))
{
    var signatures = _fileSignature[ext];
    var headerBytes = reader.ReadBytes(signatures.Max(m => m.Length));

    return signatures.Any(signature => 
        headerBytes.Take(signature.Length).SequenceEqual(signature));
}

추가 파일 서명을 얻으려면 파일 서명 데이터베이스(Google 검색 결과) 및 공식 파일 사양을 사용합니다. 공식 파일 사양을 참조하여 선택한 서명이 유효한지 확인할 수 있습니다.

파일 이름 보안

실제 스토리지에 파일을 저장하는 데 클라이언트에서 제공하는 파일 이름을 사용하지 마세요. Path.GetRandomFileName 또는 Path.GetTempFileName을 사용하여 임시 스토리지에 대한 전체 경로(파일 이름을 포함)를 만들어 파일에 대한 안전한 파일 이름을 만듭니다.

Razor는 표시를 위해 속성 값을 자동으로 HTML 인코딩합니다. 다음 코드는 안전하게 사용할 수 있습니다.

@foreach (var file in Model.DatabaseFiles) {
    <tr>
        <td>
            @file.UntrustedName
        </td>
    </tr>
}

Razor 외부에서는 항상 사용자 요청의 HtmlEncode 파일 이름 콘텐츠입니다.

많은 구현에서 파일 존재 여부에 대한 확인이 포함되어야 합니다. 그렇지 않으면 파일이 같은 이름의 파일을 덮어씁니다. 앱의 사양을 충족하는 추가 논리를 제공합니다.

크기 유효성 검사

업로드된 파일의 크기를 제한합니다.

샘플 앱에서 파일 크기는 2MB(바이트 단위로 표시)로 제한됩니다. 이 제한은 appsettings.json 파일의 구성을 통해 제공됩니다.

{
  "FileSizeLimit": 2097152
}

FileSizeLimitPageModel 클래스에 삽입됩니다.

public class BufferedSingleFileUploadPhysicalModel : PageModel
{
    private readonly long _fileSizeLimit;

    public BufferedSingleFileUploadPhysicalModel(IConfiguration config)
    {
        _fileSizeLimit = config.GetValue<long>("FileSizeLimit");
    }

    ...
}

파일 크기가 제한을 초과하는 파일은 거부됩니다.

if (formFile.Length > _fileSizeLimit)
{
    // The file is too large ... discontinue processing the file
}

이름 특성 값을 POST 메서드의 매개 변수 이름과 일치

양식 데이터를 POST하거나 JavaScript의 FormData를 직접 사용하는 비 Razor 양식에서 양식의 요소 또는 FormData에 지정된 이름은 컨트롤러의 작업에서 매개 변수 이름과 일치해야 합니다.

다음 예제에서

  • <input> 요소를 사용하는 경우 name 특성은 값 battlePlans로 설정됩니다.

    <input type="file" name="battlePlans" multiple>
    
  • JavaScript에서 FormData를 사용하는 경우 이름이 값 battlePlans로 설정됩니다.

    var formData = new FormData();
    
    for (var file in files) {
      formData.append("battlePlans", file, file.name);
    }
    

C# 메서드(battlePlans)의 매개 변수와 일치하는 이름을 사용합니다.

  • 이름이 Upload인 Razor Pages 페이지 처리기 메서드:

    public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> battlePlans)
    
  • MVC POST 컨트롤러 작업 메서드:

    public async Task<IActionResult> Post(List<IFormFile> battlePlans)
    

서버 및 앱 구성

다중 파트 본문 길이 제한

MultipartBodyLengthLimit에서 각 다중 파트 본문의 길이에 대한 제한을 설정합니다. 양식 섹션이 이 제한을 초과하면 구문 분석할 때 InvalidDataException이 throw됩니다. 기본값은 134,217,728(128MB)입니다. Startup.ConfigureServices에서 MultipartBodyLengthLimit 설정을 사용하여 제한을 사용자 지정합니다.

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<FormOptions>(options =>
    {
        // Set the limit to 256 MB
        options.MultipartBodyLengthLimit = 268435456;
    });
}

RequestFormLimitsAttribute는 단일 페이지 또는 작업에 대해 MultipartBodyLengthLimit을 설정하는 데 사용됩니다.

Razor Pages 앱에서 Startup.ConfigureServices규칙을 사용하여 필터를 적용합니다.

services.AddMvc()
    .AddRazorPagesOptions(options =>
    {
        options.Conventions
            .AddPageApplicationModelConvention("/FileUploadPage",
                model.Filters.Add(
                    new RequestFormLimitsAttribute()
                    {
                        // Set the limit to 256 MB
                        MultipartBodyLengthLimit = 268435456
                    });
    })
    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

Razor Pages 앱 또는 MVC 앱에서 페이지 모델 또는 작업 메서드에 필터를 적용합니다.

// Set the limit to 256 MB
[RequestFormLimits(MultipartBodyLengthLimit = 268435456)]
public class BufferedSingleFileUploadPhysicalModel : PageModel
{
    ...
}

Kestrel 최대 요청 본문 크기

Kestrel에서 호스트되는 앱의 경우 기본 최대 요청 본문 크기는 30,000,000바이트(약 28.6MB)입니다. MaxRequestBodySizeKestrel 서버 옵션을 사용하여 제한을 사용자 지정합니다.

public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
    WebHost.CreateDefaultBuilder(args)
        .UseStartup<Startup>()
        .ConfigureKestrel((context, options) =>
        {
            // Handle requests up to 50 MB
            options.Limits.MaxRequestBodySize = 52428800;
        });

RequestSizeLimitAttribute는 단일 페이지 또는 작업에 대해 MaxRequestBodySize를 설정하는 데 사용됩니다.

Razor Pages 앱에서 Startup.ConfigureServices규칙을 사용하여 필터를 적용합니다.

services.AddMvc()
    .AddRazorPagesOptions(options =>
    {
        options.Conventions
            .AddPageApplicationModelConvention("/FileUploadPage",
                model =>
                {
                    // Handle requests up to 50 MB
                    model.Filters.Add(
                        new RequestSizeLimitAttribute(52428800));
                });
    })
    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

Razor Pages 앱 또는 MVC 앱에서 페이지 처리기 모델 또는 작업 메서드에 필터를 적용합니다.

// Handle requests up to 50 MB
[RequestSizeLimit(52428800)]
public class BufferedSingleFileUploadPhysicalModel : PageModel
{
    ...
}

다른 Kestrel 제한

다른 Kestrel 제한이 Kestrel에서 호스트되는 앱에 적용될 수 있습니다.

IIS

기본 요청 제한(maxAllowedContentLength)은 30,000,000바이트(약 28.6MB)입니다. web.config 파일에서 제한을 사용자 지정합니다. 다음 예제에서 제한은 50MB(52,428,800바이트)로 설정됩니다.

<system.webServer>
  <security>
    <requestFiltering>
      <requestLimits maxAllowedContentLength="52428800" />
    </requestFiltering>
  </security>
</system.webServer>

maxAllowedContentLength 설정은 IIS에만 적용됩니다. 자세한 내용은 요청 제한<requestLimits>을 참조하세요.

Startup.ConfigureServices에서 IISServerOptions.MaxRequestBodySize을 설정하여 HTTP 요청에 대한 최대 요청 본문 크기를 늘립니다. 다음 예제에서 제한은 50MB(52,428,800바이트)로 설정됩니다.

services.Configure<IISServerOptions>(options =>
{
    options.MaxRequestBodySize = 52428800;
});

자세한 내용은 IIS가 있는 Windows에서 ASP.NET Core 호스팅을 참조하세요.

문제 해결

다음은 파일 업로드 및 가능한 솔루션을 사용하여 작업할 때 자주 발생하는 몇 가지 일반적인 문제입니다.

IIS 서버에 배포할 때 찾을 수 없음 오류

다음 오류는 업로드된 파일이 서버의 구성된 콘텐츠 길이를 초과했음을 나타냅니다.

HTTP 404.13 - Not Found
The request filtering module is configured to deny a request that exceeds the request content length.

자세한 내용은 IIS 섹션을 참조하세요.

연결 실패

연결 오류 및 서버 연결 다시 설정은 업로드된 파일이 Kestrel의 최대 요청 본문 크기를 초과함을 나타낼 수 있습니다. 자세한 내용은 Kestrel 최대 요청 본문 크기 섹션을 참조하세요. Kestrel 클라이언트 연결 제한을 조정해야 할 수도 있습니다.

IFormFile을 사용한 Null 참조 예외

컨트롤러에서 IFormFile을 사용하여 업로드된 파일을 수락하지만 값이 null이면 HTML 양식에서 multipart/form-dataenctype 값을 지정하는지 확인합니다. <form> 요소에서 이 특성이 설정되지 않으면 파일 업로드가 실행되지 않고 바인딩된 IFormFile 인수는 모두 null이 됩니다. 또한 양식 데이터의 업로드 이름 지정이 앱의 이름 지정과 일치하는지 확인합니다.

스트림이 너무 깁니다.

이 항목의 예제에서는 업로드된 파일의 콘텐츠를 저장하는 데 MemoryStream에 의존합니다. MemoryStream의 크기 제한은 int.MaxValue입니다. 앱의 파일 업로드 시나리오에서 50MB보다 큰 파일 콘텐츠를 보관해야 하는 경우 업로드된 파일의 콘텐츠를 보관할 때 단일 MemoryStream에 의존하지 않는 대체 방법을 사용하세요.

추가 리소스