# Django/Security 圖片上傳,隨意接收會導致伺服器崩潰:兼顧安全與效能的完整指南 在網路服務中,圖片上傳往往被視為「常見功能」,因此容易被粗心實作。然而,上傳端點是外部資料進入伺服器最直接的通道,也是攻擊者以最低成本造成最大破壞的關鍵點(如 WebShell 上傳、圖片解析器漏洞、DoS 等)。 本文將整理「安全要嚴格、資源要經濟」這看似矛盾的目標,並提供同時滿足兩者的實務方法。核心思路簡單: * **驗證階段盡量不讀取內容**(僅解碼標頭/元資料) * **儲存階段則大膽「重新生成」**(Transcoding 進行 Sanitizing) --- ## 上傳安全的本質:"不要相信,盡量延遲信任" {#sec-98341e6a33e1} 在上傳過程中,幾乎沒有什麼可以被信任: * 檔名:使用者可自行更改 * 擴展名:可隨意改 * Content-Type:由客戶端自行填寫 * 檔案內容:由攻擊者製造 因此策略可歸納為兩點: 1. **以低成本快速過濾**(cheap checks) 2. **最終儲存版本始終由伺服器生成**(server‑generated artifact) --- ## 常見誤解:"要徹底檢查就必須讀完整檔" {#sec-f9b6b6b99eb2} 許多開發者在驗證階段會加入以下程式碼: ```python img = Image.open(file) img.verify() # 或 img.load() ``` 問題在於,這等於把伺服器資源「預付」給攻擊者。 ### 為何危險? {#sec-7ed56ee48a1d} * **解壓炸彈(Decompression Bomb)**:表面上只有幾 MB,但解碼後可能達數十 GB。`load()` 會觸發實際像素解碼,瞬間耗盡記憶體/CPU,導致 DoS。 * **不必要的 I/O**:`verify()` 會讀到檔案結尾,I/O 成本高;之後若要再次處理,通常需要 `seek(0)` 或重新開啟。 **結論**:在驗證階段不要解碼像素。僅靠標頭、元資料與解析度限制即可提供足夠防禦。 --- ## 防禦不是單一措施,而是三層防禦:Defense in Depth {#sec-5756153c302b} 圖片上傳不是可以用一句 `if` 完成的問題。要達到實際平衡,需要分層防禦。 ### 1️⃣ 擴展名是謊言 — 以 Magic Number 判斷 MIME {#sec-bbad131e1332} `profile.png` 這個檔名本身沒有任何意義。必須讀取檔案的**簽名(Magic Number)**來確認實際類型。 * 不要一次讀完整檔;**前 1~2KB 就足夠**。 * 典型工具:`python-magic`(基於 libmagic)。 ### 2️⃣ Pillow 只「開啟」還不安全 — Lazy Loading 進行解析度限制 {#sec-f0a8fb4bda8a} `Image.open()` 只會解析標頭,並不立即解碼像素。利用這一特性,可在解碼前先檢查解析度。 * 檢查條件:`width * height <= MAX_PIXELS` * 重點:**不使用 `load()` / `verify()`,僅查看 `size`**。 ### 3️⃣ 最佳消毒方式是「重新繪製」 — Transcoding 進行 Sanitizing {#sec-398d6ae888a9} 核心原則:**不要直接保存原始檔**。 圖片可能藏有 EXIF、ICC Profile、Slack 空間、解析器陷阱等非像素區域的惡意內容。若只保留像素資料,並由伺服器以新格式重新編碼,這些隱藏的危險大多會被自動清除。 * 推薦:使用 **WebP(或 AVIF/JPEG)** 重新編碼並保存。 * 效果:**Sanitizing + 儲存優化 + 統一格式**。 --- ## 實作範例:DRF Serializer(安全 + 記憶體經濟) {#sec-7b99e0ec685d} 以下程式碼體現了「驗證階段盡量不讀取」與「儲存階段重新生成」的理念。 * `f.size` 由 Django 事先取得,直接使用。 * Magic Number 只讀前段。 * Pillow 只檢查解析度。 * 最終儲存使用 Transcoding(WebP)。 * 每一步都使用 `seek(0)` 以確保檔案指標回到起點。 ```python from io import BytesIO import magic from PIL import Image, ImageOps, UnidentifiedImageError from rest_framework import serializers # 政策(依服務調整) MAX_SIZE = 5 * 1024 * 1024 # 5MB MAX_PIXELS = 4_194_304 # 2048 * 2048 ≈ 4MP ALLOWED_MIME = {"image/png", "image/jpeg", "image/webp"} class SecureImageUploadSerializer(serializers.Serializer): file = serializers.ImageField() def validate_file(self, f): # [1] 大小限制:最快、最便宜的過濾 if f.size > MAX_SIZE: raise serializers.ValidationError("檔案大小過大。") # [2] Magic Number 判斷 MIME:不信任擴展名/Content-Type f.seek(0) head = f.read(2048) # 只讀前段 f.seek(0) mime = magic.from_buffer(head, mime=True) if mime not in ALLOWED_MIME: raise serializers.ValidationError("不支援的檔案格式。") # [3] 解析度限制:不使用 load/verify,僅檢查 size try: with Image.open(f) as img: w, h = img.size if (w * h) > MAX_PIXELS: raise serializers.ValidationError("圖片解析度過高。") except UnidentifiedImageError: raise serializers.ValidationError("無效的圖片。") except Exception: raise serializers.ValidationError("圖片驗證時發生錯誤。") finally: f.seek(0) return f def create(self, validated_data): f = validated_data["file"] # 最終儲存版本始終由伺服器生成(Sanitizing) try: with Image.open(f) as img: # EXIF 旋轉校正(手機上傳尤為重要) img = ImageOps.exif_transpose(img) # 轉換到安全且一致的色彩空間 if img.mode not in ("RGB", "RGBA"): img = img.convert("RGB") out = BytesIO() img.save(out, format="WEBP", quality=85, method=6) out.seek(0) safe_bytes = out.getvalue() # 在此處將 safe_bytes 存入儲存層 # - 檔名隨機化(UUID) # - 目錄分層(如 ab/cd/uuid.webp) # - 資料庫僅存儲伺服器生成的鍵 return safe_bytes except Exception: raise serializers.ValidationError("圖片處理時發生錯誤。") ``` --- ## 「過度安全」vs「資源經濟」的平衡技巧 {#sec-7ec5087de2e9} 關鍵在於: ### 驗證階段不要貪心 {#sec-5c32bd66af4d} 驗證階段是流量最高、攻擊者最易重複利用的區域。若在此階段執行 `load()` 等昂貴運算,攻擊者可無限制消耗伺服器資源。 * ✅ 大小限制 / 標頭 MIME / 解析度限制 * ❌ 僅解碼像素 / 讀完整檔 / 多次重新開啟 ### 真正安全在儲存階段 {#sec-a49dab56fd86} 驗證是「過濾」,儲存是「標準化」。在儲存階段由伺服器產生新的位元流,可大幅提升安全與運營便利。 * 統一格式 → 簡化快取策略/縮圖流程 * 清除元資料 → 方便刪除隱私資訊(EXIF GPS) * 降低惡意載荷注入風險 --- ## 進一步完善的措施 {#sec-733fb04d797a} ![防禦惡意檔案上傳的圖片](/media/editor_temp/6/60e76b29-7657-4042-8e5b-3b7b2670a4c7.png) * **檔名永遠不要信任,應由伺服器生成**(建議使用 UUID) * **考慮讓應用伺服器不直接接收上傳** (大規模服務可使用 presigned URL 直接上傳至物件儲存,並在後台進行檢查/轉碼) * **限制處理時間 / 隔離工作者** 圖片轉碼耗 CPU,建議將其放入工作者/佇列,避免在 HTTP 請求-回應路徑中長時間阻塞。 * **記錄/統計** 追蹤拒絕原因(MIME 不符、解析度過高、大小超限)可快速發現攻擊或濫用模式。 --- ## 檢查清單 {#sec-ab5708035e92} 1. **不要將整個檔案載入記憶體**。`read()` 只取前段,其他以串流或檔案物件處理。 2. **不信任擴展名/Content-Type,改用 Magic Number 判斷 MIME**。 3. **驗證階段不執行 `load()`/`verify()`,僅檢查解析度**。 4. **不要直接保存原始檔,使用 Transcoding 產生新檔**。 5. **每一步都使用 `seek(0)` 以重置檔案指標**。 上傳安全始於「懷疑使用者」,效能則源於「瞭解系統運作」。兩者缺一,最終都會在運營中失敗。以此結構化流程,打造安全且高效的圖片上傳管道。 --- **相關文章也請參考** - [[網路上傳檔案簡易化] Dropzone.js 完全攻略](/ko/whitedec/2025/12/24/dropzone-js-complete-guide/)