Learning Objectives
- 區分認證與授權,並區分水平與垂直權限問題
- 在模擬網站上觀察不安全的直接物件參照,以擁有者檢查修補,並確認本人仍可讀取
- 說明隱藏按鈕與難以猜測的識別碼為何不能取代伺服器端的權限檢查
- 以裝飾器與預設拒絕集中管理權限檢查
認證與授權的差別
學生登入成績查詢系統後,網址列顯示 /grades/102。把網址改成 /grades/103,頁面顯示的可能是另一位學生的成績。系統已經確認了這位使用者是誰,卻沒有確認他能不能讀這筆資料。
這裡涉及兩個不同的問題:
- 認證(authentication):確認使用者是誰,第 5 課的主題
- 授權(authorization):確認這位使用者可以對這項資源做什麼,也稱為存取控制(access control)
通過認證不代表通過授權。OWASP Top 10:2025 將存取控制失效(Broken Access Control)列為第一名(A01:2025)。權限問題通常分成兩類:
| 類型 | 意義 | 例子 |
|---|---|---|
| 水平權限 | 相同角色的使用者,存取到彼此的資料 | 學生甲讀到學生乙的成績 |
| 垂直權限 | 使用者執行較高角色才能做的操作 | 學生開啟管理頁面、修改成績 |
上面的情形稱為不安全的直接物件參照(insecure direct object reference,IDOR):程式直接以使用者提供的識別碼取用資料,沒有檢查這筆資料是否屬於他。
識別碼與擁有者檢查
將流水號換成 UUID 這類難以猜測的識別碼,可以提高列舉的難度,OWASP 將它視為縱深防禦。它不能取代權限檢查:識別碼可能透過網址分享、瀏覽紀錄、日誌或其他 API 回應外流,一旦外流,沒有檢查的程式依然會回傳資料。OWASP 的 IDOR 指引明確指出,即使識別碼複雜,存取控制檢查仍然不可少。
較穩健的寫法是讓查詢本身包含擁有者條件:
row = db.execute('SELECT * FROM grades WHERE id = ? AND owner_id = ?',
(grade_id, session['user_id'])).fetchone()
要特別注意 session['user_id'] 的來源。使用者身分應該取自伺服器確認過的工作階段,不可以取自用戶端送來的參數,例如隱藏欄位的 user_id 或查詢字串。用戶端送來的任何內容都可以被修改。
預設拒絕
裝飾器仍然依賴開發者記得加上去。新增路由時若遺漏,這個路由就對所有人開放。預設拒絕(deny by default)反過來設計:除了明確列出的公開路由,其他路由一律需要登入。OWASP 建議除了公開資源之外都預設拒絕,並在整個應用程式重複使用同一套機制。
Flask 可以用 before_request 實作:
PUBLIC_ENDPOINTS = {'index', 'login'}
@app.before_request
def require_login():
if request.endpoint not in PUBLIC_ENDPOINTS and 'user_id' not in session:
abort(401)
Flask 的靜態檔案也是一個端點(static)。登入頁需要的 CSS 與 JavaScript 若放在這裡,要把它加進清單,並確認這個目錄沒有不該公開的檔案。
這段程式只確認「已登入」,屬於第一層。下方練習的 /export 加上登入檢查之後,一般學生登入仍然讀得到所有人的成績,還需要 @admin_required 這類角色檢查;每筆資料是否屬於這位使用者,也要在各自的檢查中處理。存取控制的測試也應該納入單元測試與整合測試,檢查項目包括其他使用者、一般角色與未登入三種情況。
重點整理
- 認證確認使用者是誰,授權確認他能做什麼;通過認證不代表通過授權
- OWASP Top 10:2025 將存取控制失效列為第一名
- IDOR 是直接以使用者提供的識別碼取用資料而沒有檢查擁有者;修補方式是在伺服器端檢查,或把擁有者條件放進查詢
- UUID 可以增加列舉的難度,但不能取代權限檢查
- 隱藏按鈕只改善介面。權限檢查必須在伺服器端,GET 與 POST 等每個端點都要檢查
- 使用者身分取自伺服器確認過的工作階段,不取自用戶端的參數
- 以裝飾器集中檢查,以預設拒絕防止遺漏,並將存取控制納入自動測試
- 只在自己擁有或獲得明確授權的系統上進行測試
下一課討論檔案上傳與路徑穿越。
小測驗
學生登入後把網址的 /grades/102 改成 /grades/103,讀到另一位學生的成績。這屬於哪一種問題?
開發者把成績紀錄的識別碼從流水號改成 UUID,但沒有加入擁有者檢查。這樣的修改如何評價?
一般使用者的頁面上沒有「成績管理」連結,後端路由沒有檢查角色。下列敘述何者正確?
開發者保護了 GET /admin,但處理更新的 POST /admin/grade 沒有檢查。會有什麼後果?
為了判斷使用者身分,下列哪一個來源最適當?
「預設拒絕」與「逐一在路由加上檢查」相比,主要的好處是什麼?