本文 AI 產出,尚未審核

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

簡介

在 Web 應用程式中,檔案上傳是最常見的功能之一,無論是使用者上傳大頭照、簡報檔,還是後台管理員匯入批次資料,都離不開 Express 與 TypeScript 的協作。
然而,檔案上傳同時也是攻擊者的入口點:不受控的檔案類型、過大的檔案、或是惡意的檔名,都可能導致 檔案執行、路徑穿越、磁碟資源耗盡 等安全問題。
本文將從 檔案型別驗證、儲存策略、常見陷阱 四個面向,說明在 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.resolve 與 path.join:保證最終路徑一定在預期目錄下。
  • 不要直接使用使用者提供的檔名:即使使用 path.basename,仍可能因 Unicode 同形字或控制字元造成問題。
  • 產生隨機檔名(如上例 randomBytes)是最安全的做法。

常見陷阱與最佳實踐

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

最佳實踐清單

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

實際應用場景

📸 使用者大頭照上傳

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

📄 文件中心(PDF、Word)

  • 白名單 application/pdf、application/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 應用在檔案處理上更上一層樓!