本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 檔案上傳:檔案型別與安全性考量

簡介

在 Web 應用程式中,檔案上傳是最常見的功能之一,無論是使用者上傳大頭照、簡報檔,還是後台管理員匯入批次資料,都離不開 ExpressTypeScript 的協作。
然而,檔案上傳同時也是攻擊者的入口點:不受控的檔案類型、過大的檔案、或是惡意的檔名,都可能導致 檔案執行、路徑穿越、磁碟資源耗盡 等安全問題。
本文將從 檔案型別驗證儲存策略常見陷阱 四個面向,說明在 Express + TypeScript 環境下,如何安全且有效地處理檔案上傳。


核心概念

1️⃣ 為什麼要限制檔案類型?

  • 降低執行危險:上傳可執行檔(.exe.js)若被放置於可直接存取的路徑,可能被直接執行。
  • 避免資訊洩漏:某些檔案(如 .env.config)內含敏感設定,若被外部下載會造成重大風險。
  • 提升使用者體驗:前端可即時提示不支援的檔案類型,減少無效的上傳請求。

2️⃣ 檔案型別驗證的兩個層面

層面 說明 常用方法
副檔名檢查 只看檔名的文字部分(example.jpg path.extname()
MIME Type 檢查 依據檔案內容的 magic number 判斷真實類型 file.mimetype(由 Multer 提供)或 file-type 套件

最佳實踐同時檢查副檔名與 MIME Type,僅依賴副檔名容易被偽造。

3️⃣ Multer 與 TypeScript 的基本設定

// src/middleware/upload.ts
import multer, { FileFilterCallback } from 'multer';
import { Request } from 'express';
import path from 'path';
import { randomBytes } from 'crypto';

// 1. 設定暫存資料夾(建議放在專屬目錄,不在 public 之下)
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, path.resolve(__dirname, '../../uploads/tmp'));
  },
  // 2. 產生唯一檔名,避免覆寫與路徑穿越
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    const name = randomBytes(16).toString('hex');
    cb(null, `${name}${ext}`);
  },
});

// 3. 檔案類型白名單(以 MIME 為主)
const allowedMimes = new Set([
  'image/jpeg',
  'image/png',
  'application/pdf',
  'text/plain',
]);

// 4. 自訂 fileFilter
function fileFilter(
  req: Request,
  file: Express.Multer.File,
  cb: FileFilterCallback,
) {
  // 先檢查 MIME
  if (!allowedMimes.has(file.mimetype)) {
    return cb(new Error('不支援的檔案類型'), false);
  }
  // 再檢查副檔名是否符合預期
  const ext = path.extname(file.originalname).toLowerCase();
  if (!['.jpg', '.jpeg', '.png', '.pdf', '.txt'].includes(ext)) {
    return cb(new Error('副檔名不符合允許清單'), false);
  }
  cb(null, true);
}

// 5. 建立 Multer 實例,限制檔案大小(例:5 MB)
export const upload = multer({
  storage,
  fileFilter,
  limits: { fileSize: 5 * 1024 * 1024 },
});

4️⃣ 進階:使用 file-type 讀取檔案內容做二次驗證

// src/utils/validateFile.ts
import { fromFile } from 'file-type';
import fs from 'fs';

/**
 * 以檔案內容的 magic number 判斷真實 MIME
 * @param filePath 暫存檔案完整路徑
 * @param whitelist 允許的 MIME 列表
 */
export async function verifyFileContent(
  filePath: string,
  whitelist: Set<string>,
): Promise<boolean> {
  const type = await fromFile(filePath);
  if (!type) return false; // 無法判斷類型
  return whitelist.has(type.mime);
}

在路由中結合上述工具:

// src/routes/file.ts
import express from 'express';
import { upload } from '../middleware/upload';
import { verifyFileContent } from '../utils/validateFile';
import path from 'path';
import fs from 'fs';

const router = express.Router();
const allowedMimes = new Set(['image/jpeg', 'image/png', 'application/pdf']);

router.post(
  '/upload',
  upload.single('file'), // 前端欄位名稱必須是 file
  async (req, res) => {
    if (!req.file) return res.status(400).json({ error: '未收到檔案' });

    const tmpPath = req.file.path;
    const isValid = await verifyFileContent(tmpPath, allowedMimes);
    if (!isValid) {
      // 刪除不合法檔案
      fs.unlinkSync(tmpPath);
      return res.status(400).json({ error: '檔案內容與類型不符' });
    }

    // 移動到正式儲存目錄(可依需求分類)
    const finalDir = path.resolve(__dirname, '../../uploads/images');
    const finalPath = path.join(finalDir, req.file.filename);
    fs.renameSync(tmpPath, finalPath);

    res.json({ message: '上傳成功', filename: req.file.filename });
  },
);

export default router;

5️⃣ 防止路徑穿越與檔名注入

  • 使用 path.resolvepath.join:保證最終路徑一定在預期目錄下。
  • 不要直接使用使用者提供的檔名:即使使用 path.basename,仍可能因 Unicode 同形字或控制字元造成問題。
  • 產生隨機檔名(如上例 randomBytes)是最安全的做法。

常見陷阱與最佳實踐

陷阱 為何危險 推薦解法
只檢查副檔名 攻擊者可改名為 .jpg 但內容是執行檔 同時檢查 MIMEmagic number
未限制檔案大小 大檔案會耗盡磁碟或記憶體 使用 limits.fileSize,並在前端提前檢查
將上傳目錄放在 public 直接可透過 URL 下載,若檔案可執行會被執行 儲存於 非公開目錄,透過 API 控制下載權限
使用原始檔名作為儲存名稱 可能導致檔名衝突、路徑穿越或 XSS 產生 UUID / randomHex,或使用 multerfilename 方式
未處理錯誤回傳 前端無法得知失敗原因,使用者體驗差 fileFilter、上傳失敗、驗證失敗時都回傳清晰的錯誤訊息
缺乏病毒/惡意程式掃描 上傳的檔案可能包含木馬 結合 ClamAV、Microsoft Defender API 或第三方掃描服務

最佳實踐清單

  1. 白名單:僅允許業務需要的 MIME/副檔名。
  2. 雙重驗證:MIME + magic number。
  3. 隨機檔名 + 分類目錄:避免衝突與資訊洩漏。
  4. 儲存於非公開目錄,使用授權的下載 API。
  5. 設定檔案大小與數量限制,保護資源。
  6. 使用 HTTPS,防止檔案在傳輸途中被竊取或篡改。
  7. 定期清理過期檔案,避免磁碟被填滿。
  8. 日誌紀錄:上傳成功/失敗、使用者 ID、IP,方便事後追蹤。

實際應用場景

📸 使用者大頭照上傳

  • 只接受 image/jpegimage/png,最大 2 MB。
  • 上傳後即時產生 縮圖(Sharp)並存入 uploads/avatars/{userId}
  • 下載時透過 /users/:id/avatar API,檢查使用者是否有權限讀取。

📄 文件中心(PDF、Word)

  • 白名單 application/pdfapplication/vnd.openxmlformats-officedocument.wordprocessingml.document
  • 使用 file-type 再次驗證,並在上傳完成後呼叫 ClamAV 進行病毒掃描。
  • 檔案儲存於 uploads/documents/{projectId},只有加入該專案的成員可透過授權 API 下載。

🛠 後台批次匯入(CSV)

  • 只允許 text/csv,大小上限 10 MB。
  • 上傳後立即使用 fast-csv 解析,若發現不符合欄位規則則直接回滾並刪除檔案。
  • 為防止惡意腳本注入,所有欄位皆做 資料類型驗證(數字、日期、枚舉)。

總結

檔案上傳是前後端互動的關鍵功能,但若缺乏嚴謹的 型別驗證安全防護,極易成為系統漏洞的入口。透過本文的 Multer + TypeScript 範例,我們示範了:

  1. 雙重型別驗證(副檔名 + MIME + magic number)
  2. 安全儲存策略(隨機檔名、非公開目錄、大小限制)
  3. 常見陷阱 的避免方式與 最佳實踐 清單

在實務開發中,請根據業務需求調整白名單與儲存結構,並結合病毒掃描、日誌追蹤等額外措施,才能打造既 友善安全 的檔案上傳體驗。祝開發順利,讓你的 Express + TypeScript 應用在檔案處理上更上一層樓!