Django/Security 圖片上傳,隨意接收會導致伺服器崩潰:兼顧安全與效能的完整指南
在網路服務中,圖片上傳往往被視為「常見功能」,因此容易被粗心實作。然而,上傳端點是外部資料進入伺服器最直接的通道,也是攻擊者以最低成本造成最大破壞的關鍵點(如 WebShell 上傳、圖片解析器漏洞、DoS 等)。
本文將整理「安全要嚴格、資源要經濟」這看似矛盾的目標,並提供同時滿足兩者的實務方法。核心思路簡單:
- 驗證階段盡量不讀取內容(僅解碼標頭/元資料)
- 儲存階段則大膽「重新生成」(Transcoding 進行 Sanitizing)
上傳安全的本質:"不要相信,盡量延遲信任"
在上傳過程中,幾乎沒有什麼可以被信任:
- 檔名:使用者可自行更改
- 擴展名:可隨意改
- Content-Type:由客戶端自行填寫
- 檔案內容:由攻擊者製造
因此策略可歸納為兩點:
- 以低成本快速過濾(cheap checks)
- 最終儲存版本始終由伺服器生成(server‑generated artifact)
常見誤解:"要徹底檢查就必須讀完整檔"
許多開發者在驗證階段會加入以下程式碼:
img = Image.open(file)
img.verify() # 或 img.load()
問題在於,這等於把伺服器資源「預付」給攻擊者。
為何危險?
- 解壓炸彈(Decompression Bomb):表面上只有幾 MB,但解碼後可能達數十 GB。
load()會觸發實際像素解碼,瞬間耗盡記憶體/CPU,導致 DoS。 - 不必要的 I/O:
verify()會讀到檔案結尾,I/O 成本高;之後若要再次處理,通常需要seek(0)或重新開啟。
結論:在驗證階段不要解碼像素。僅靠標頭、元資料與解析度限制即可提供足夠防禦。
防禦不是單一措施,而是三層防禦:Defense in Depth
圖片上傳不是可以用一句 if 完成的問題。要達到實際平衡,需要分層防禦。
1️⃣ 擴展名是謊言 — 以 Magic Number 判斷 MIME
profile.png 這個檔名本身沒有任何意義。必須讀取檔案的簽名(Magic Number)來確認實際類型。
- 不要一次讀完整檔;前 1~2KB 就足夠。
- 典型工具:
python-magic(基於 libmagic)。
2️⃣ Pillow 只「開啟」還不安全 — Lazy Loading 進行解析度限制
Image.open() 只會解析標頭,並不立即解碼像素。利用這一特性,可在解碼前先檢查解析度。
- 檢查條件:
width * height <= MAX_PIXELS - 重點:不使用
load()/verify(),僅查看size。
3️⃣ 最佳消毒方式是「重新繪製」 — Transcoding 進行 Sanitizing
核心原則:不要直接保存原始檔。
圖片可能藏有 EXIF、ICC Profile、Slack 空間、解析器陷阱等非像素區域的惡意內容。若只保留像素資料,並由伺服器以新格式重新編碼,這些隱藏的危險大多會被自動清除。
- 推薦:使用 WebP(或 AVIF/JPEG) 重新編碼並保存。
- 效果:Sanitizing + 儲存優化 + 統一格式。
實作範例:DRF Serializer(安全 + 記憶體經濟)
以下程式碼體現了「驗證階段盡量不讀取」與「儲存階段重新生成」的理念。
f.size由 Django 事先取得,直接使用。- Magic Number 只讀前段。
- Pillow 只檢查解析度。
- 最終儲存使用 Transcoding(WebP)。
- 每一步都使用
seek(0)以確保檔案指標回到起點。
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「資源經濟」的平衡技巧
關鍵在於:
驗證階段不要貪心
驗證階段是流量最高、攻擊者最易重複利用的區域。若在此階段執行 load() 等昂貴運算,攻擊者可無限制消耗伺服器資源。
- ✅ 大小限制 / 標頭 MIME / 解析度限制
- ❌ 僅解碼像素 / 讀完整檔 / 多次重新開啟
真正安全在儲存階段
驗證是「過濾」,儲存是「標準化」。在儲存階段由伺服器產生新的位元流,可大幅提升安全與運營便利。
- 統一格式 → 簡化快取策略/縮圖流程
- 清除元資料 → 方便刪除隱私資訊(EXIF GPS)
- 降低惡意載荷注入風險
進一步完善的措施

- 檔名永遠不要信任,應由伺服器生成(建議使用 UUID)
- 考慮讓應用伺服器不直接接收上傳 (大規模服務可使用 presigned URL 直接上傳至物件儲存,並在後台進行檢查/轉碼)
- 限制處理時間 / 隔離工作者 圖片轉碼耗 CPU,建議將其放入工作者/佇列,避免在 HTTP 請求-回應路徑中長時間阻塞。
- 記錄/統計 追蹤拒絕原因(MIME 不符、解析度過高、大小超限)可快速發現攻擊或濫用模式。
檢查清單
- 不要將整個檔案載入記憶體。
read()只取前段,其他以串流或檔案物件處理。 - 不信任擴展名/Content-Type,改用 Magic Number 判斷 MIME。
- 驗證階段不執行
load()/verify(),僅檢查解析度。 - 不要直接保存原始檔,使用 Transcoding 產生新檔。
- 每一步都使用
seek(0)以重置檔案指標。
上傳安全始於「懷疑使用者」,效能則源於「瞭解系統運作」。兩者缺一,最終都會在運營中失敗。以此結構化流程,打造安全且高效的圖片上傳管道。
相關文章也請參考
目前沒有評論。