在 ASP.NET Core 中上傳檔案

作者:Rutger Storm

ASP.NET Core 支援針對較小的檔案使用緩衝的模型繫結,以及針對較大的檔案使用未緩衝的串流來上傳一或多個檔案。

檢視或下載範例程式碼 \(英文\) (如何下載)

安全性考量

為使用者提供將檔案上傳到伺服器的能力時,請特別注意。 攻擊者可能會嘗試:

  • 執行拒絕服務的攻擊。
  • 上傳病毒或惡意程式碼。
  • 以其他方式危害網路和伺服器。

降低成功攻擊可能性的安全性步驟如下:

  • 將檔案上傳至專用的檔案上傳區,最好是非系統磁碟機。 專用的位置可讓您更輕鬆地對上傳的檔案強制實施安全性限制。 停用檔案上傳位置上的執行權限。†
  • 不要將上傳的檔案保存在與應用程式相同的目錄樹狀結構中。†
  • 使用由應用程式所決定的安全檔名。 請勿使用由使用者所提供的檔名或上傳檔案的不受信任檔名。† 顯示不受信任的檔名時,HTML 會對其進行編碼。 例如,記錄檔名或在 UI 中顯示 (Razor 會自動對輸出進行 HTML 編碼)。
  • 只允許應用程式設計規格的已核准副檔名。†
  • 驗證用戶端檢查是否已在伺服器上執行。† 用戶端檢查很容易規避。
  • 檢查上傳的檔案大小。 設定大小上限以防止大型上傳。†
  • 當檔案不應被上傳的同名檔案覆蓋時,請在上傳檔案之前先針對資料庫或實體儲存體檢查檔名。
  • 在儲存檔案之前,先對上傳的內容執行病毒/惡意程式碼掃描器。

†範例應用程式示會範符合準則的方法。

警告

將惡意程式碼上傳至系統經常是執行程式碼的第一步,該程式碼可能:

  • 完全取得對系統的控制權。
  • 讓系統過載,進而造成系統當機的結果。
  • 洩漏使用者或系統資料。
  • 在公用 UI 上塗鴉。

如需在接受來自使用者的檔案時減少攻擊介面區的資訊,請參閱下列資源:

如需實作安全性措施的詳細資訊 (包括範例應用程式的範例),請參閱驗證一節。

儲存體案例

檔案的常見儲存選項包括:

  • Database

    • 對於小型檔案上傳,資料庫通常比實體儲存體 (檔案系統或網路共用) 選項更快。
    • 資料庫通常比實體儲存體選項更方便,因為擷取使用者資料的資料庫記錄可以同時提供檔案內容 (例如頭像影像)。
    • 資料庫可能比使用雲端資料儲存體服務便宜。
  • 實體儲存體 (檔案系統或網路共用)

    • 對於大型檔案上傳:
      • 資料庫限制量可能會限制上傳的大小。
      • 實體儲存體通常比資料庫儲存體較貴。
    • 實體儲存體可能比使用雲端資料儲存體服務較便宜。
    • 應用程式的處理序必須具有對儲存體位置的讀取和寫入權限。 永不授與執行權限。
  • 雲端資料儲存體服務 (例如 Azure Blob 儲存體)。

    • 與通常容易出現單一失敗點的內部部署解決方案相比,這種服務通常可提供更高的可擴縮性和復原能力。
    • 在大型儲存體基礎結構案例中,這種服務的成本可能較低。

    如需詳細資訊,請參閱快速入門:使用 .NET 在物件儲存體中建立 blob

小型和大型檔案

小型和大型檔案的定義取決於可用的運算資源。 應用程式應對所使用的儲存方法進行基準測試,以確保它能夠處理預期的大小。 對記憶體、CPU、磁碟和資料庫效能進行基準測試。

雖然無法為您的部署提供小型與大型作業的具體界限,但以下是 AspNetCore 的 FormOptions 的一些相關預設值:

  • 預設情況下,HttpRequest.Form 不會緩衝整個要求本文 (BufferBody),但會緩衝所包含的任何多部分表單檔案。
  • MultipartBodyLengthLimit 是緩衝表單檔案的大小上限 (預設為 128MB)。
  • MemoryBufferThreshold 表示在將檔案從記憶體轉移到磁碟上的緩衝檔案之前,要在記憶體中緩衝多少檔案內容 (預設為 64KB)。 MemoryBufferThreshold 作為一個區分小型和大型檔案之間的界限 (這個界限可以根據應用程式的資源和場景來提高或降低)。

如需 FormOptions 的詳細資訊,請參閱原始程式碼

檔案上傳案例

上傳檔案的兩種一般方法是緩衝處理和串流傳輸。

緩衝處理

整個檔案會讀取到 IFormFileIFormFile 是用來處理或儲存檔案之檔案的 C# 標記法。

檔案上傳所使用的磁碟和記憶體取決於並行檔案上傳的次數和大小。 如果應用程式嘗試緩衝過多次的上傳,則網站會在記憶體或磁碟空間不足時損毀。 如果檔案上傳的大小或頻率耗盡了應用程式資源,請使用串流傳輸。

任何超過 64 KB 的單一緩衝檔案都會從記憶體移至磁碟上的暫存檔案中。

較大要求的暫存檔會寫入 ASPNETCORE_TEMP 環境變數中具名的位置。 如果未定義 ASPNETCORE_TEMP,則會將檔案寫入目前使用者的暫存資料夾中。

本主題的下列各節介紹了緩衝處理小型檔案:

串流

檔案會從多部分要求接收,並由應用程式直接處理或儲存。 串流傳輸不會顯著提高效能。 串流傳輸可減少上傳檔案時對記憶體或磁碟空間的需求。

使用串流傳輸上傳大型檔案一節中介紹了串流傳輸大型檔案。

使用緩衝模型繫結將小型檔案上傳至實體儲存體

若要上傳小型檔案,請使用多部分表單,或使用 JavaScript 建構 POST 要求。

下列範例示範如何使用 Razor Pages 表單來上傳單一檔案 (範例應用程式中的 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 透過模型繫結來存取上傳到伺服器的個別檔案。 範例應用程式示範資料庫和實體儲存體案例的多個緩衝檔上傳。

警告

除了用於顯示和記錄之外,請勿使用 IFormFileFileName 屬性。 顯示或記錄時,HTML 會對檔名進行編碼。 攻擊者會提供惡意的檔名 (包括完整路徑或相對路徑)。 應用程式應該:

  • 從使用者提供的檔名中移除路徑。
  • 儲存用於 UI 或記錄的 HTML 編碼、移除路徑的檔名。
  • 產生一個新的隨機檔名以用於儲存。

下列程式碼會從檔名中移除路徑:

string untrustedFileName = Path.GetFileName(pathName);

到目前為止所提供的範例沒有考慮安全性問題。 下列各節和範例應用程式會提供其他資訊:

使用模型繫結和 IFormFile 上傳檔案時,動作方法可以接受:

注意

繫結會依名稱比對表單檔案。 例如,<input type="file" name="formFile"> 中的 HTML name 值必須符合 C# 參數/屬性繫結 (FormFile)。 如需詳細資訊,請參閱讓 name 屬性值與 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 儲存體)。

如需循環上傳多個檔案並使用安全檔名的其他範例,請參閱範例應用程式中的 Pages/BufferedMultipleFileUploadPhysical.cshtml.cs

警告

如果在未刪除先前的暫存檔的情況下建立了超過 65,535 個檔案,則 Path.GetTempFileName 會擲回 IOException。 65,535 個檔案的限制是每部伺服器的限制。 有關 Windows 作業系統上此限制的詳細資訊,請參閱以下主題中的備註:

使用緩衝模型繫結將小型檔案上傳至資料庫

若要使用 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

警告

將二進位資料儲存至關聯式資料庫時請小心,因為它可能會對效能造成不良影響。

請勿在未經驗證的情況下依賴或信任 IFormFileFileName 屬性。 FileName 屬性只能用於顯示用途,而且只能在 HTML 編碼之後使用。

所提供的範例沒有考慮安全性問題。 下列各節和範例應用程式會提供其他資訊:

使用串流傳輸上傳大型檔案

3.1 範例示範如何使用 JavaScript 將檔案串流傳輸到控制器動作。 使用自訂篩選屬性並傳入用戶端 HTTP 標頭來產生檔案的 antiforgery 權杖,而不是傳入要求本文。 因為動作方法會直接處理已上傳的資料,所以另一個自訂篩選會停用表單模型繫結。 在動作內,會使用 MultipartReader 來讀取表單內容,以讀取每個個別 MultipartSection、處理檔案,或視需要儲存內容。 讀取多部分區段之後,動作會執行自己的模型繫結。

初始頁面回應會載入表單,並將 antiforgery 權杖儲存在 cookie 中 (透過 GenerateAntiforgeryTokenCookieAttribute 屬性)。 此屬性會使用 ASP.NET Core 的內建 Antiforgery 支援來設定含要求權杖的 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)
    {
    }
}

在範例應用程式中,GenerateAntiforgeryTokenCookieAttributeDisableFormValueModelBindingAttribute 會使用 Razor 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 緩衝檔案上傳,請參閱 Utilities/FileHelpers.cs 檔案中的 ProcessFormFile 方法。 如需處理串流檔案,請參閱相同檔案中的 ProcessStreamedFile 方法。

警告

範例應用程式中示範的驗證處理方法不會掃描已上傳檔案的內容。 在大部分的生產案例中,在讓使用者或其他系統可以使用檔案之前,會先對檔案使用病毒/惡意程式碼掃描器 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

許多實作必須包括檢查檔案是否存在;否則,該檔案將會被同名檔案覆蓋。 請提供其他邏輯以符合您的應用程式的規範。

大小驗證

限制上傳的檔案大小。

在範例應用程式中,檔案的大小限制為 2 MB (以位元組表示)。 此限制是透過 appsettings.json 檔案中的組態提供的:

{
  "FileSizeLimit": 2097152
}

FileSizeLimit 會插入 PageModel 類別:

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
}

讓 name 屬性值與 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。 預設值為 134,217,728 (128 MB)。 使用 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.6 MB)。 使用 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
{
    ...
}

也可以使用 @attributeRazor 指示詞來套用 RequestSizeLimitAttribute

@attribute [RequestSizeLimitAttribute(52428800)]

其他 Kestrel 限制

其他 Kestrel 限制可能適用於 Kestrel 託管的應用程式:

IIS

預設的要求限制 (maxAllowedContentLength) 為 30,000,000 個位元組 (約為 28.6 MB)。 自訂 web.config 檔案中的限制。 在下列範例中,限制設定為 50 MB (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。 如果應用程式的檔案上傳場景需要保存大於 50 MB 的檔案內容,請使用不依賴單一 MemoryStream 來保存上傳檔案內容的替代方法。

ASP.NET Core 支援針對較小的檔案使用緩衝的模型繫結,以及針對較大的檔案使用未緩衝的串流來上傳一或多個檔案。

檢視或下載範例程式碼 \(英文\) (如何下載)

安全性考量

為使用者提供將檔案上傳到伺服器的能力時,請特別注意。 攻擊者可能會嘗試:

  • 執行拒絕服務的攻擊。
  • 上傳病毒或惡意程式碼。
  • 以其他方式危害網路和伺服器。

降低成功攻擊可能性的安全性步驟如下:

  • 將檔案上傳至專用的檔案上傳區,最好是非系統磁碟機。 專用的位置可讓您更輕鬆地對上傳的檔案強制實施安全性限制。 停用檔案上傳位置上的執行權限。†
  • 不要將上傳的檔案保存在與應用程式相同的目錄樹狀結構中。†
  • 使用由應用程式所決定的安全檔名。 請勿使用由使用者所提供的檔名或上傳檔案的不受信任檔名。† 顯示不受信任的檔名時,HTML 會對其進行編碼。 例如,記錄檔名或在 UI 中顯示 (Razor 會自動對輸出進行 HTML 編碼)。
  • 只允許應用程式設計規格的已核准副檔名。†
  • 驗證用戶端檢查是否已在伺服器上執行。† 用戶端檢查很容易規避。
  • 檢查上傳的檔案大小。 設定大小上限以防止大型上傳。†
  • 當檔案不應被上傳的同名檔案覆蓋時,請在上傳檔案之前先針對資料庫或實體儲存體檢查檔名。
  • 在儲存檔案之前,先對上傳的內容執行病毒/惡意程式碼掃描器。

†範例應用程式示會範符合準則的方法。

警告

將惡意程式碼上傳至系統經常是執行程式碼的第一步,該程式碼可能:

  • 完全取得對系統的控制權。
  • 讓系統過載,進而造成系統當機的結果。
  • 洩漏使用者或系統資料。
  • 在公用 UI 上塗鴉。

如需在接受來自使用者的檔案時減少攻擊介面區的資訊,請參閱下列資源:

如需實作安全性措施的詳細資訊 (包括範例應用程式的範例),請參閱驗證一節。

儲存體案例

檔案的常見儲存選項包括:

  • Database

    • 對於小型檔案上傳,資料庫通常比實體儲存體 (檔案系統或網路共用) 選項更快。
    • 資料庫通常比實體儲存體選項更方便,因為擷取使用者資料的資料庫記錄可以同時提供檔案內容 (例如頭像影像)。
    • 資料庫可能比使用資料儲存體服務便宜。
  • 實體儲存體 (檔案系統或網路共用)

    • 對於大型檔案上傳:
      • 資料庫限制量可能會限制上傳的大小。
      • 實體儲存體通常比資料庫儲存體較貴。
    • 實體儲存體可能比使用資料儲存體服務較便宜。
    • 應用程式的處理序必須具有對儲存體位置的讀取和寫入權限。 永不授與執行權限。
  • 資料儲存體服務 (例如 Azure Blob 儲存體)

    • 與通常容易出現單一失敗點的內部部署解決方案相比,這種服務通常可提供更高的可擴縮性和復原能力。
    • 在大型儲存體基礎結構案例中,這種服務的成本可能較低。

    如需詳細資訊,請參閱快速入門:使用 .NET 在物件儲存體中建立 blob

檔案上傳案例

上傳檔案的兩種一般方法是緩衝處理和串流傳輸。

緩衝處理

整個檔案會讀取到 IFormFile (這是用來處理或儲存檔案之檔案的 C# 標記法)。

檔案上傳所使用的資源 (磁碟、記憶體) 取決於並行檔案上傳次數和大小。 如果應用程式嘗試緩衝過多次的上傳,則網站會在記憶體或磁碟空間不足時損毀。 如果檔案上傳的大小或頻率耗盡了應用程式資源,請使用串流傳輸。

注意

任何超過 64 KB 的單一緩衝檔案都會從記憶體移至磁碟上的暫存檔案中。

本主題的下列各節介紹了緩衝處理小型檔案:

串流

檔案會從多部分要求接收,並由應用程式直接處理或儲存。 串流傳輸不會顯著提高效能。 串流傳輸可減少上傳檔案時對記憶體或磁碟空間的需求。

使用串流傳輸上傳大型檔案一節中介紹了串流傳輸大型檔案。

使用緩衝模型繫結將小型檔案上傳至實體儲存體

若要上傳小型檔案,請使用多部分表單,或使用 JavaScript 建構 POST 要求。

下列範例示範如何使用 Razor Pages 表單來上傳單一檔案 (範例應用程式中的 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 透過模型繫結來存取上傳到伺服器的個別檔案。 範例應用程式示範資料庫和實體儲存體案例的多個緩衝檔上傳。

警告

除了用於顯示和記錄之外,請勿使用 IFormFileFileName 屬性。 顯示或記錄時,HTML 會對檔名進行編碼。 攻擊者會提供惡意的檔名 (包括完整路徑或相對路徑)。 應用程式應該:

  • 從使用者提供的檔名中移除路徑。
  • 儲存用於 UI 或記錄的 HTML 編碼、移除路徑的檔名。
  • 產生一個新的隨機檔名以用於儲存。

下列程式碼會從檔名中移除路徑:

string untrustedFileName = Path.GetFileName(pathName);

到目前為止所提供的範例沒有考慮安全性問題。 下列各節和範例應用程式會提供其他資訊:

使用模型繫結和 IFormFile 上傳檔案時,動作方法可以接受:

注意

繫結會依名稱比對表單檔案。 例如,<input type="file" name="formFile"> 中的 HTML name 值必須符合 C# 參數/屬性繫結 (FormFile)。 如需詳細資訊,請參閱讓 name 屬性值與 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 儲存體)。

如需循環上傳多個檔案並使用安全檔名的其他範例,請參閱範例應用程式中的 Pages/BufferedMultipleFileUploadPhysical.cshtml.cs

警告

如果在未刪除先前的暫存檔的情況下建立了超過 65,535 個檔案,則 Path.GetTempFileName 會擲回 IOException。 65,535 個檔案的限制是每部伺服器的限制。 有關 Windows 作業系統上此限制的詳細資訊,請參閱以下主題中的備註:

使用緩衝模型繫結將小型檔案上傳至資料庫

若要使用 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

警告

將二進位資料儲存至關聯式資料庫時請小心,因為它可能會對效能造成不良影響。

請勿在未經驗證的情況下依賴或信任 IFormFileFileName 屬性。 FileName 屬性只能用於顯示用途,而且只能在 HTML 編碼之後使用。

所提供的範例沒有考慮安全性問題。 下列各節和範例應用程式會提供其他資訊:

使用串流傳輸上傳大型檔案

下列範例示範如何使用 JavaScript 將檔案串流傳輸到控制器動作。 使用自訂篩選屬性並傳入用戶端 HTTP 標頭來產生檔案的 antiforgery 權杖,而不是傳入要求本文。 因為動作方法會直接處理已上傳的資料,所以另一個自訂篩選會停用表單模型繫結。 在動作內,會使用 MultipartReader 來讀取表單內容,以讀取每個個別 MultipartSection、處理檔案,或視需要儲存內容。 讀取多部分區段之後,動作會執行自己的模型繫結。

初始頁面回應會載入表單,並將 antiforgery 權杖儲存在 cookie 中 (透過 GenerateAntiforgeryTokenCookieAttribute 屬性)。 此屬性會使用 ASP.NET Core 的內建 Antiforgery 支援來設定含要求權杖的 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)
    {
    }
}

在範例應用程式中,GenerateAntiforgeryTokenCookieAttributeDisableFormValueModelBindingAttribute 會使用 Razor 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 緩衝檔案上傳,請參閱 Utilities/FileHelpers.cs 檔案中的 ProcessFormFile 方法。 如需處理串流檔案,請參閱相同檔案中的 ProcessStreamedFile 方法。

警告

範例應用程式中示範的驗證處理方法不會掃描已上傳檔案的內容。 在大部分的生產案例中,在讓使用者或其他系統可以使用檔案之前,會先對檔案使用病毒/惡意程式碼掃描器 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

許多實作必須包括檢查檔案是否存在;否則,該檔案將會被同名檔案覆蓋。 請提供其他邏輯以符合您的應用程式的規範。

大小驗證

限制上傳的檔案大小。

在範例應用程式中,檔案的大小限制為 2 MB (以位元組表示)。 此限制是透過 appsettings.json 檔案中的組態提供的:

{
  "FileSizeLimit": 2097152
}

FileSizeLimit 會插入 PageModel 類別:

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
}

讓 name 屬性值與 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。 預設值為 134,217,728 (128 MB)。 使用 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.6 MB)。 使用 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
{
    ...
}

也可以使用 @attributeRazor 指示詞來套用 RequestSizeLimitAttribute

@attribute [RequestSizeLimitAttribute(52428800)]

其他 Kestrel 限制

其他 Kestrel 限制可能適用於 Kestrel 託管的應用程式:

IIS

預設的要求限制 (maxAllowedContentLength) 為 30,000,000 個位元組 (約為 28.6 MB)。 自訂 web.config 檔案中的限制。 在下列範例中,限制設定為 50 MB (52,428,800 個位元組):

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

maxAllowedContentLength 設定僅適用於 IIS。 如需詳細資訊,請參閱要求限制 <requestLimits>

Startup.ConfigureServices 中設定 IISServerOptions.MaxRequestBodySize,以增加 HTTP 要求的最大要求本文大小。 在下列範例中,限制設定為 50 MB (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。 如果應用程式的檔案上傳場景需要保存大於 50 MB 的檔案內容,請使用不依賴單一 MemoryStream 來保存上傳檔案內容的替代方法。

ASP.NET Core 支援針對較小的檔案使用緩衝的模型繫結,以及針對較大的檔案使用未緩衝的串流來上傳一或多個檔案。

檢視或下載範例程式碼 \(英文\) (如何下載)

安全性考量

為使用者提供將檔案上傳到伺服器的能力時,請特別注意。 攻擊者可能會嘗試:

  • 執行拒絕服務的攻擊。
  • 上傳病毒或惡意程式碼。
  • 以其他方式危害網路和伺服器。

降低成功攻擊可能性的安全性步驟如下:

  • 將檔案上傳至專用的檔案上傳區,最好是非系統磁碟機。 專用的位置可讓您更輕鬆地對上傳的檔案強制實施安全性限制。 停用檔案上傳位置上的執行權限。†
  • 不要將上傳的檔案保存在與應用程式相同的目錄樹狀結構中。†
  • 使用由應用程式所決定的安全檔名。 請勿使用由使用者所提供的檔名或上傳檔案的不受信任檔名。† 顯示不受信任的檔名時,HTML 會對其進行編碼。 例如,記錄檔名或在 UI 中顯示 (Razor 會自動對輸出進行 HTML 編碼)。
  • 只允許應用程式設計規格的已核准副檔名。†
  • 驗證用戶端檢查是否已在伺服器上執行。† 用戶端檢查很容易規避。
  • 檢查上傳的檔案大小。 設定大小上限以防止大型上傳。†
  • 當檔案不應被上傳的同名檔案覆蓋時,請在上傳檔案之前先針對資料庫或實體儲存體檢查檔名。
  • 在儲存檔案之前,先對上傳的內容執行病毒/惡意程式碼掃描器。

†範例應用程式示會範符合準則的方法。

警告

將惡意程式碼上傳至系統經常是執行程式碼的第一步,該程式碼可能:

  • 完全取得對系統的控制權。
  • 讓系統過載,進而造成系統當機的結果。
  • 洩漏使用者或系統資料。
  • 在公用 UI 上塗鴉。

如需在接受來自使用者的檔案時減少攻擊介面區的資訊,請參閱下列資源:

如需實作安全性措施的詳細資訊 (包括範例應用程式的範例),請參閱驗證一節。

儲存體案例

檔案的常見儲存選項包括:

  • Database

    • 對於小型檔案上傳,資料庫通常比實體儲存體 (檔案系統或網路共用) 選項更快。
    • 資料庫通常比實體儲存體選項更方便,因為擷取使用者資料的資料庫記錄可以同時提供檔案內容 (例如頭像影像)。
    • 資料庫可能比使用資料儲存體服務便宜。
  • 實體儲存體 (檔案系統或網路共用)

    • 對於大型檔案上傳:
      • 資料庫限制量可能會限制上傳的大小。
      • 實體儲存體通常比資料庫儲存體較貴。
    • 實體儲存體可能比使用資料儲存體服務較便宜。
    • 應用程式的處理序必須具有對儲存體位置的讀取和寫入權限。 永不授與執行權限。
  • 資料儲存體服務 (例如 Azure Blob 儲存體)

    • 與通常容易出現單一失敗點的內部部署解決方案相比,這種服務通常可提供更高的可擴縮性和復原能力。
    • 在大型儲存體基礎結構案例中,這種服務的成本可能較低。

    如需詳細資訊,請參閱快速入門:使用 .NET 在物件儲存體中建立 blob。 本主題示範了 UploadFromFileAsync,但在使用 StreamUploadFromStreamAsync 可用來將 FileStream 儲存到 blob 儲存體中。

檔案上傳案例

上傳檔案的兩種一般方法是緩衝處理和串流傳輸。

緩衝處理

整個檔案會讀取到 IFormFile (這是用來處理或儲存檔案之檔案的 C# 標記法)。

檔案上傳所使用的資源 (磁碟、記憶體) 取決於並行檔案上傳次數和大小。 如果應用程式嘗試緩衝過多次的上傳,則網站會在記憶體或磁碟空間不足時損毀。 如果檔案上傳的大小或頻率耗盡了應用程式資源,請使用串流傳輸。

注意

任何超過 64 KB 的單一緩衝檔案都會從記憶體移至磁碟上的暫存檔案中。

本主題的下列各節介紹了緩衝處理小型檔案:

串流

檔案會從多部分要求接收,並由應用程式直接處理或儲存。 串流傳輸不會顯著提高效能。 串流傳輸可減少上傳檔案時對記憶體或磁碟空間的需求。

使用串流傳輸上傳大型檔案一節中介紹了串流傳輸大型檔案。

使用緩衝模型繫結將小型檔案上傳至實體儲存體

若要上傳小型檔案,請使用多部分表單,或使用 JavaScript 建構 POST 要求。

下列範例示範如何使用 Razor Pages 表單來上傳單一檔案 (範例應用程式中的 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 透過模型繫結來存取上傳到伺服器的個別檔案。 範例應用程式示範資料庫和實體儲存體案例的多個緩衝檔上傳。

警告

除了用於顯示和記錄之外,請勿使用 IFormFileFileName 屬性。 顯示或記錄時,HTML 會對檔名進行編碼。 攻擊者會提供惡意的檔名 (包括完整路徑或相對路徑)。 應用程式應該:

  • 從使用者提供的檔名中移除路徑。
  • 儲存用於 UI 或記錄的 HTML 編碼、移除路徑的檔名。
  • 產生一個新的隨機檔名以用於儲存。

下列程式碼會從檔名中移除路徑:

string untrustedFileName = Path.GetFileName(pathName);

到目前為止所提供的範例沒有考慮安全性問題。 下列各節和範例應用程式會提供其他資訊:

使用模型繫結和 IFormFile 上傳檔案時,動作方法可以接受:

注意

繫結會依名稱比對表單檔案。 例如,<input type="file" name="formFile"> 中的 HTML name 值必須符合 C# 參數/屬性繫結 (FormFile)。 如需詳細資訊,請參閱讓 name 屬性值與 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 儲存體)。

如需循環上傳多個檔案並使用安全檔名的其他範例,請參閱範例應用程式中的 Pages/BufferedMultipleFileUploadPhysical.cshtml.cs

警告

如果在未刪除先前的暫存檔的情況下建立了超過 65,535 個檔案,則 Path.GetTempFileName 會擲回 IOException。 65,535 個檔案的限制是每部伺服器的限制。 有關 Windows 作業系統上此限制的詳細資訊,請參閱以下主題中的備註:

使用緩衝模型繫結將小型檔案上傳至資料庫

若要使用 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

警告

將二進位資料儲存至關聯式資料庫時請小心,因為它可能會對效能造成不良影響。

請勿在未經驗證的情況下依賴或信任 IFormFileFileName 屬性。 FileName 屬性只能用於顯示用途,而且只能在 HTML 編碼之後使用。

所提供的範例沒有考慮安全性問題。 下列各節和範例應用程式會提供其他資訊:

使用串流傳輸上傳大型檔案

下列範例示範如何使用 JavaScript 將檔案串流傳輸到控制器動作。 使用自訂篩選屬性並傳入用戶端 HTTP 標頭來產生檔案的 antiforgery 權杖,而不是傳入要求本文。 因為動作方法會直接處理已上傳的資料,所以另一個自訂篩選會停用表單模型繫結。 在動作內,會使用 MultipartReader 來讀取表單內容,以讀取每個個別 MultipartSection、處理檔案,或視需要儲存內容。 讀取多部分區段之後,動作會執行自己的模型繫結。

初始頁面回應會載入表單,並將 antiforgery 權杖儲存在 cookie 中 (透過 GenerateAntiforgeryTokenCookieAttribute 屬性)。 此屬性會使用 ASP.NET Core 的內建 Antiforgery 支援來設定含要求權杖的 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)
    {
    }
}

在範例應用程式中,GenerateAntiforgeryTokenCookieAttributeDisableFormValueModelBindingAttribute 會使用 Razor 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 緩衝檔案上傳,請參閱 Utilities/FileHelpers.cs 檔案中的 ProcessFormFile 方法。 如需處理串流檔案,請參閱相同檔案中的 ProcessStreamedFile 方法。

警告

範例應用程式中示範的驗證處理方法不會掃描已上傳檔案的內容。 在大部分的生產案例中,在讓使用者或其他系統可以使用檔案之前,會先對檔案使用病毒/惡意程式碼掃描器 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

許多實作必須包括檢查檔案是否存在;否則,該檔案將會被同名檔案覆蓋。 請提供其他邏輯以符合您的應用程式的規範。

大小驗證

限制上傳的檔案大小。

在範例應用程式中,檔案的大小限制為 2 MB (以位元組表示)。 此限制是透過 appsettings.json 檔案中的組態提供的:

{
  "FileSizeLimit": 2097152
}

FileSizeLimit 會插入 PageModel 類別:

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
}

讓 name 屬性值與 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。 預設值為 134,217,728 (128 MB)。 使用 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.6 MB)。 使用 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.6 MB)。 自訂 web.config 檔案中的限制。 在下列範例中,限制設定為 50 MB (52,428,800 個位元組):

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

maxAllowedContentLength 設定僅適用於 IIS。 如需詳細資訊,請參閱要求限制 <requestLimits>

Startup.ConfigureServices 中設定 IISServerOptions.MaxRequestBodySize,以增加 HTTP 要求的最大要求本文大小。 在下列範例中,限制設定為 50 MB (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。 如果應用程式的檔案上傳場景需要保存大於 50 MB 的檔案內容,請使用不依賴單一 MemoryStream 來保存上傳檔案內容的替代方法。

其他資源