Learning Objectives
- 說明為什麼檔名與 Content-Type 都不能作為檔案類型的依據
- 以允許清單與檔案簽章驗證上傳內容,並說明檔案簽章的限制
- 將上傳檔案改為隨機檔名、存放於網站目錄之外,並以下載方式提供
- 在模擬網站上觀察路徑穿越,並以正確的方式限制檔案路徑
上傳功能的風險
課程網站常提供照片或作業上傳。上傳功能會把使用者提供的三項資訊帶進伺服器:檔名、內容類型與檔案內容,三者都由用戶端決定。瀏覽器送出的 multipart 請求大致如下:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=x
--x
Content-Disposition: form-data; name="file"; filename="notice.html"
Content-Type: image/png
<!doctype html><script>...</script>
--x--
請求裡的 Content-Type: image/png 只是一段文字,任何人都可以改寫。OWASP 明確指出這個標頭可以被偽造,不能作為安全控制。CWE-434 也提醒不應僅依賴內容類型或檔名屬性。
上傳檔案可能造成的後果取決於伺服器如何處理它:
| 處理方式 | 可能的後果 |
|---|---|
| 以網站來源直接顯示 HTML 或 SVG | 檔案裡的腳本以網站的來源執行,形成儲存型 XSS(第 8 課) |
| 存放在伺服器會執行的目錄 | 上傳的程式可能被伺服器當成程式執行 |
| 沿用使用者提供的檔名 | 檔名裡的 ../ 可能讓檔案寫到其他位置,覆蓋現有檔案 |
| 沒有大小限制 | 磁碟空間與記憶體被耗盡 |
本課的模擬環境。 下方網站是真的 Flask,在瀏覽器內執行,不連線到任何外部系統,帳號與檔案皆為虛構。在未獲授權的情況下對真實網站進行相同操作可能觸犯法律(第 1 課)。
模擬的瀏覽器無法傳送檔案選擇器選取的檔案,所以表單以三個欄位表示一次上傳。upload_sim.py 將它們組成 Werkzeug 的 FileStorage,與真實網站由 request.files['file'] 取得的物件相同,修補的寫法可以直接用於真實程式。
副檔名允許清單與檔案簽章
檔案驗證應該同時檢查副檔名與內容。許多檔案格式的開頭有固定的位元組,稱為檔案簽章(magic bytes),例如 PNG 固定以 89 50 4E 47 0D 0A 1A 0A 開始。
ext = f.filename.rsplit('.', 1)[-1].lower()
head = f.stream.read(8)
f.stream.seek(0) # 讀完開頭後回到起點,之後存檔才完整
if ext not in ALLOWED or not head.startswith(SIGNATURES[ext]):
return page(error='不接受這個檔案'), 400
兩項檢查各有不同的作用,只做其中一項都不足:
| 只做哪一項 | 仍然放行 |
|---|---|
| 只看 Content-Type | 任何宣告成 image/png 的檔案 |
| 只看副檔名 | 檔名是 photo.png、內容是 HTML 的檔案 |
| 只看檔案簽章 | 內容是 PNG、檔名是 notice.html 的檔案,之後可能依副檔名被當成 HTML 提供 |
檔案簽章本身也不足以證明檔案安全。OWASP 指出它不應單獨使用:攻擊者可以讓檔案以合法的簽章開頭,後面接上其他內容。本站的 utils/file_validation.py 因此採用兩層驗證,先比對簽章,再以 Pillow 實際解析圖片,並確認解析出的格式與副檔名一致。這類驗證提高了門檻,但仍只是多層防護的其中一層,下一段的儲存方式同樣重要。
驗證應使用允許清單。封鎖清單(例如禁止 .php、.exe)無法列舉所有危險的類型,新格式出現時也不會自動涵蓋。
儲存位置與提供方式
通過驗證的檔案仍應以不信任的態度處理。隨機檔名與存放於網站根目錄之外是 OWASP 與 CWE-434 都列出的作法,大小限制見 OWASP;以下載方式提供則是搭配 HTTP 標頭的作法(見 MDN):
- 由應用程式產生檔名:以隨機值(例如 UUID)作為儲存檔名,使用者提供的名稱只保存在資料庫供顯示。這樣檔名裡的路徑、特殊字元與重複名稱都不再是問題
- 存放於網站根目錄之外:Flask 的
static/會被直接提供,放在這裡的檔案不經過任何權限檢查。應存放在其他目錄,經由程式檢查後再提供 - 以下載方式提供:回應標頭
Content-Disposition: attachment告知瀏覽器下載,不要在頁面中顯示;X-Content-Type-Options: nosniff要求瀏覽器依宣告的類型處理,不自行猜測 - 限制大小:Flask 可設定
app.config['MAX_CONTENT_LENGTH'],超過時會產生 413 錯誤
修補的寫法:
UPLOAD_DIR = os.path.join(app.root_path, 'private_uploads') # 不在 static 之下
name = uuid.uuid4().hex + '.' + ext # 儲存檔名由程式產生
f.save(os.path.join(UPLOAD_DIR, name))
# 提供檔案時
resp = send_from_directory(UPLOAD_DIR, name, as_attachment=True)
resp.headers['X-Content-Type-Options'] = 'nosniff'
return resp
目錄建議寫成絕對路徑:f.save() 的相對路徑依程式的工作目錄計算,send_from_directory() 的相對路徑依 app.root_path 計算,部署環境裡兩者可能不同,造成檔案存得進去卻讀不到。
也可以使用 Werkzeug 的 secure_filename(),它只保留 ASCII 英數字與 ._-,例如 ../../../../home/username/.bashrc 會變成 home_username_.bashrc。使用時要注意兩點:中文檔名的字元會被移除,作業.png 會變成 png,作業 會變成空字串;兩個使用者上傳同名檔案時仍會互相覆蓋。因此儲存檔名以隨機值為佳。
路徑穿越
下載功能也會把使用者的輸入帶進檔案系統。若程式以字串組合路徑:
path = os.path.join('public_files', name)
當 name 是 ../secret_config.py,路徑會指向上一層目錄的檔案。這類弱點稱為路徑穿越(path traversal)。OWASP 指出攻擊者會利用 ../ 序列或絕對路徑,存取原始碼、設定檔與系統檔案。
絕對路徑是容易被忽略的情形。Python 的 os.path.join 遇到絕對路徑時會捨棄前面的部分:
os.path.join('public_files', '/tmp/server_secret.txt') # 結果是 '/tmp/server_secret.txt'
因此只移除字串 ../ 的作法並不完整。正確的作法依優先順序如下:
- 不使用檔名作為輸入:改用編號或識別碼,由程式對應到檔案。OWASP 建議以索引值取代檔名片段
- 使用允許清單:只接受已知的檔名
- 使用經過驗證的函式:Flask 的
send_from_directory()內部會檢查正規化後的路徑沒有離開指定目錄,否則回傳 404;這項檢查不解析符號連結,指定目錄內不應放指向外部的連結。Werkzeug 的safe_join()在路徑不安全時回傳None - 自行檢查時先正規化路徑:以
os.path.realpath()取得實際路徑,再確認它位於允許的目錄之內
@app.route('/download')
def download():
return send_from_directory(BASE, request.args.get('name', ''), mimetype='text/plain')
必須自行檢查時,不要以字串開頭比對,因為 public_files_old 也以 public_files 開頭。可以改用 os.path.commonpath():
base = os.path.realpath(BASE)
path = os.path.realpath(os.path.join(base, name))
if os.path.commonpath([base, path]) != base:
abort(404)
敏感的設定檔不應放在網站能夠讀取的路徑附近,服務帳號也不應具有讀取它們的權限(第 14 課)。這些措施可以在路徑檢查失效時限制影響範圍。
重點整理
- 上傳請求的檔名、Content-Type 與內容都由用戶端決定,Content-Type 不能作為安全控制
- 驗證要同時使用副檔名允許清單與內容檢查;檔案簽章不足以證明檔案安全,只是多層防護的一層
- 儲存檔名由程式產生,檔案存放於網站根目錄之外,以
attachment與nosniff提供,並限制大小 - 上傳的 HTML 或 SVG 若以網站的來源顯示,會形成儲存型 XSS
- 路徑穿越可以透過
../或絕對路徑發生,僅移除特定字串無法完整防範 - 優先以編號或允許清單取代檔名輸入,或使用
send_from_directory()這類已驗證的函式 - 只在自己擁有或獲得明確授權的系統上進行測試(第 1 課)
下一課討論伺服器代為發出請求時的風險,以及伺服器設定造成的資訊洩漏。
小測驗
程式以 file.content_type.startswith("image/") 判斷上傳的是否為圖片。這項檢查的問題是什麼?
網站已比對檔案簽章,確認上傳的檔案以 PNG 簽章開頭。下列敘述何者正確?
為什麼儲存上傳檔案時建議以隨機值作為檔名,而不是使用 secure_filename() 處理後的原檔名?
為了防止路徑穿越,程式以 name.replace("../", "") 處理輸入。這項修補的主要缺陷是什麼?
回應標頭 Content-Disposition: attachment 的作用是什麼?