Learning Objectives
- 說明伺服器代為取用網址時,為什麼可能碰到使用者原本連不到的內部服務
- 以協定與主機的允許清單修補 SSRF,並處理重新導向
- 說明除錯模式與詳細錯誤訊息造成的風險,改為通用訊息與伺服器端日誌
- 處理預設帳密與安全標頭,並說明不必要的服務為何要關閉
伺服器代為取用網址的功能
許多網站提供由伺服器代為取用網址的功能,例如貼上連結後顯示預覽、由網址匯入圖片、把網頁轉成 PDF、Webhook 通知。這類功能的流程是使用者提供網址,伺服器發出請求,再把結果交給使用者。
問題在於發出請求的是伺服器。伺服器通常位於內部網路中,能夠連到使用者連不到的服務:內部管理介面、資料庫的 HTTP 介面、只綁定本機的監控頁面。這些服務常因為「只有內部連得到」而沒有另外要求登入。如果伺服器願意取用任何網址,使用者便能透過它接觸這些服務。這類弱點稱為伺服器端請求偽造(Server-Side Request Forgery,SSRF),OWASP Top 10:2025 將它(CWE-918)對應到 A01 存取控制失效。
雲端環境是常被討論的情境。雲端主機通常可以連到平台提供的中繼資料服務(instance metadata service),裡面可能有臨時憑證。OWASP 指出 SSRF 常被用來竊取這類憑證,並建議在 AWS 改用要求權杖的 IMDSv2、停用 IMDSv1。這項設定是縱深防禦,僅能緩解部分 SSRF,不能取代應用程式端的檢查。
本課的模擬環境。 瀏覽器裡的 Python 不能連網,
sim_fetch()只查一張虛構的網路表,功能相當於真實程式的requests.get(),內部服務與資料都是虛構的。對真實系統進行相同操作可能觸犯法律(第 1 課)。
允許清單、重新導向與 DNS
OWASP 建議優先使用允許清單,因為封鎖清單容易被繞過,內部位址的多種表示方式也難以窮舉。若業務上只需要連到特定幾個網站,就只允許這幾個網站。
try:
parts = urlsplit(url)
port = parts.port
except ValueError: # 無法解析的網址
return render_template('preview.html', url=url, result=None, error='不允許的網址'), 400
if parts.scheme not in ALLOWED_SCHEMES or parts.hostname not in ALLOWED_HOSTS or port not in (None, 443):
return render_template('preview.html', url=url, result=None, error='不允許的網址'), 400
# 以驗證過的主機重組網址,不把使用者的原始字串交給 HTTP 用戶端
safe_url = urlunsplit((parts.scheme, parts.hostname, parts.path, parts.query, ''))
res = sim_fetch(safe_url, follow_redirects=False)
比對時要注意下列幾點:
| 項目 | 說明 |
|---|---|
| 解析後比對 | 應比對解析出的主機名稱,字串開頭比對會讓 news.campus.example.other.example 通過 |
| 協定 | SSRF 不限於 HTTP,只允許業務需要的協定,通常是 HTTPS |
| 連接埠 | 允許的主機上可能還有其他服務,連接埠也要固定 |
| 驗證與送出一致 | 不同的網址解析器可能讀出不同的主機。驗證後以解析結果重組網址再送出;OWASP 建議比對允許清單後由程式自行組成請求,解析結果不一致時一律拒絕。練習裡的 sim_fetch() 與驗證使用同一個解析器,看不到這個差異,真實的 HTTP 用戶端不一定如此 |
| 重新導向 | 通過驗證的主機可以把請求轉址到內部位址,OWASP 建議在 HTTP 用戶端停用自動跟隨;確實需要時,每一次轉址都要重新驗證 |
| DNS | 網域允許清單不能防止 DNS rebinding:驗證時與連線時查到的位址可能不同。應驗證解析出的 IP,並只連線到驗證過的位址 |
| 回應內容 | 不把原始回應或詳細錯誤直接交給使用者,可以減少資訊洩漏 |
網路層也應有對應的限制,例如應用程式伺服器只能連到需要的對外目的地(第 15 課)。若業務上無法使用允許清單,例如使用者自訂的 Webhook,OWASP 將封鎖清單列為最後手段:除了封鎖私有位址、本機位址與中繼資料服務,也要驗證解析結果,並只連線到驗證過的位址,這類情況應特別謹慎評估。
除錯模式與錯誤訊息
程式本身沒有弱點,設定仍可能造成風險。OWASP Top 10:2025 將安全設定缺陷(Security Misconfiguration)列為第二名(A02),內容包含:預設帳號與密碼仍然可用、啟用了不必要的功能、錯誤處理向使用者揭露堆疊追蹤、沒有送出安全標頭。
除錯模式。 Flask 的文件明確警告,不可在正式環境執行開發伺服器或啟用內建除錯器。除錯器允許從瀏覽器執行任意 Python 程式碼,雖然有 PIN 保護,但不應依賴它。真實專案中常見的錯誤是把 app.run(debug=True) 帶到正式環境。
錯誤訊息。 堆疊追蹤會揭露檔案路徑、程式碼片段、套件與設定。OWASP 建議的作法是對使用者回應通用的訊息,把細節記錄在伺服器端供調查:
@app.errorhandler(Exception)
def on_error(exc):
if isinstance(exc, HTTPException):
return exc
app.logger.exception('處理請求時發生錯誤') # 細節留在日誌
return render_template('error.html'), 500 # 使用者只看到通用訊息
下方的除錯頁面是模擬的,只顯示堆疊與設定值,沒有實作真實除錯器的互動功能。
預設帳密。 許多系統安裝後附帶預設帳號,名單也常公開在手冊裡。系統上線前應全部更換或停用;機密資訊不應以預設值的形式寫在程式碼裡(第 14 課)。環境變數沒有設定時,程式應該拒絕該項功能,不應退回一組已知的密碼。要留意 os.environ.get() 這時回傳 None,而表單沒有對應欄位時 request.form.get() 同樣回傳 None,兩者直接比較會相等,所以比對之前要先確認密碼已設定:
if ADMIN_PASSWORD and request.form.get('password') == ADMIN_PASSWORD:
...
不必要的服務。 每多開一個連接埠、管理介面或範例頁面,就多一個需要維護的項目。關閉用不到的服務可以縮小受攻擊面。
安全標頭。 回應標頭可以要求瀏覽器採取額外保護,它們屬於縱深防禦,不能取代修補:
| 標頭 | 作用 |
|---|---|
X-Content-Type-Options: nosniff |
瀏覽器依宣告的類型處理,不自行猜測 |
Content-Security-Policy |
限制頁面可以載入與執行的來源;frame-ancestors 限制誰可以用框架嵌入頁面 |
Strict-Transport-Security |
要求瀏覽器之後一律使用 HTTPS(第 4 課);設定錯誤可能在有效期間內讓使用者無法連線 |
@app.after_request
def add_headers(resp):
resp.headers['X-Content-Type-Options'] = 'nosniff'
resp.headers['Content-Security-Policy'] = "default-src 'self'; frame-ancestors 'none'"
return resp
重點整理
- SSRF 利用伺服器的網路位置接觸內部服務,「只有內部連得到」不等於安全
- 修補以允許清單為主:解析網址後比對協定、主機與連接埠,不以字串開頭比對,並以解析結果重組網址再送出
- 重新導向要停用或逐次驗證;網域允許清單不能防止 DNS rebinding,高風險情境要驗證解析出的位址,並讓 HTTP 用戶端只連線到驗證過的位址
- 雲端中繼資料服務的設定(例如 IMDSv2)與網路層限制是縱深防禦,不能取代應用程式端的檢查
- 正式環境不可啟用除錯模式或除錯器;使用者只看到通用訊息,細節留在伺服器端日誌
- 預設帳密上線前必須更換或停用,不必要的服務要關閉
- 安全標頭屬於縱深防禦,不能取代漏洞的修補
下一課離開網頁應用程式,討論程式如何使用記憶體,以及緩衝區溢位的原理與防護。
小測驗
公司的內部報表服務沒有登入機制,理由是它只能從內部網路連線。網站新增「輸入網址產生預覽」的功能後,為什麼可能出現風險?
修補時以 url.startswith("https://news.campus.example") 判斷。這樣寫的問題是什麼?
允許清單已正確比對主機,但 HTTP 用戶端預設會自動跟隨轉址。這會造成什麼問題?
下列關於正式環境錯誤處理的敘述,何者正確?
網站已加上 Content-Security-Policy 與 X-Content-Type-Options。下列敘述何者正確?