Learning Objectives
- 說明跨站腳本與 SQL 注入共同的根源,並區分反射型、儲存型與 DOM 型
- 在模擬網站上觀察腳本執行,並以輸出轉義修補,同時確認正常內容仍可顯示
- 說明不同位置(HTML、屬性、JavaScript、網址)需要不同的處理,並修補 javascript: 連結
- 說明 HttpOnly 與 CSP 能解決與不能解決的問題,以及需要接受 HTML 時的清理方式
資料與指令的混合:瀏覽器端
課程留言板允許學生留言,伺服器把留言內容放進 HTML 後回傳。瀏覽器收到的是一整段文字,無從分辨哪些標籤來自網站、哪些來自留言。如果留言內容含有 <script>,瀏覽器會把它當成這個網站的程式執行。這類漏洞稱為跨站腳本(cross-site scripting,XSS)。它與第 7 課的 SQL 注入有相同的根源:資料與指令混在同一段文字,差別在於解析這段文字的是瀏覽器,受影響的是瀏覽這個頁面的使用者。
被注入的腳本與網站本身的程式碼擁有相同的權限。它可以讀取與修改頁面內容,也可以用目前使用者的身分送出請求。
常見的分類如下:
| 類型 | 惡意內容的來源 | 影響範圍 |
|---|---|---|
| 反射型(reflected) | 請求本身,通常是網址參數;伺服器把它放進回應 | 開啟特製連結的使用者 |
| 儲存型(stored) | 伺服器的資料庫,例如留言 | 每一位瀏覽該頁面的使用者 |
| DOM 型(DOM-based) | 由頁面上的 JavaScript 處理,例如把網址的內容指定給 innerHTML |
開啟特製連結的使用者;問題在前端程式碼,伺服器可能完全沒有經手 |
DOM 型的修補方式是選用不會解析 HTML 的介面:顯示文字時使用 element.textContent,不使用 innerHTML。
Jinja 的自動轉義與 |safe
輸出轉義(output encoding)把有特殊意義的字元換成對應的字元參照,例如 < 換成 <,瀏覽器會將它顯示成文字,不會解析為標籤。
Flask 對副檔名為 .html 的模板預設啟用 Jinja 的自動轉義,所以 {{ text }} 通常是安全的。漏洞多半發生在開發者明確關閉轉義的地方,例如為了讓留言支援粗體而寫成 {{ text|safe }},或在程式中使用 Markup()。|safe 的意思是「開發者保證這段內容安全」,不是「請讓它變安全」。
下方的查詢頁把關鍵字寫成 {{ q|safe }}。任務使用的語句如下,它讀取 Cookie 並寫到頁面上:
<script>console.log('cookie=' + document.cookie);document.write('頁面讀到的 Cookie:' + document.cookie)</script>
語句中的 console.log 會寫入下方「伺服器紀錄」分頁,方便確認腳本是否執行。
儲存型 XSS 與不完整的修補
留言板的情況更嚴重:內容存進資料庫之後,每一位瀏覽頁面的使用者都會執行這段腳本,受害者不需要點擊任何連結。
常見的不完整作法如下:
| 作法 | 問題 |
|---|---|
移除字串 <script> |
HTML 還有其他能執行程式的寫法,例如事件屬性;也會改動合法內容 |
移除或禁止所有 < |
學生寫 A < B 的留言會被破壞;資料庫裡的舊資料仍未處理 |
| 只在前端檢查輸入 | 請求可以略過瀏覽器直接送出(第 7 課) |
| 只在寫入資料庫時處理 | 同一份資料可能用在不同位置,舊資料也不受影響 |
輸出轉義放在顯示的時候,依輸出的位置處理,內容本身保持原樣。下方任務的最後一步以 A < B 確認這一點。
HttpOnly 與 CSP 的作用範圍
修補漏洞之外,另有兩項機制可以在防線失效時降低影響,兩者都不能取代輸出轉義。
HttpOnly 讓頁面的 JavaScript 讀不到該 Cookie,可以降低憑據被直接讀走的風險。瀏覽器仍會在請求中帶上這個 Cookie,所以被注入的腳本依然能以使用者的身分送出請求、讀取頁面內容。腳本一樣會執行。
內容安全政策(Content Security Policy,CSP)告訴瀏覽器這個頁面可以執行哪些腳本。政策含有 script-src 或 default-src 時,行內腳本、事件屬性與 javascript: 網址預設不會執行。CSP 屬於縱深防禦:MDN 明確說明它不能取代輸入的清理,OWASP 也不建議把它當成主要防線。注入的 HTML 標記依然會出現在頁面上,例如下方練習中的粗體文字。
CSP 通常以回應標頭設定:
@app.after_request
def add_csp(resp):
resp.headers['Content-Security-Policy'] = "script-src 'self'"
return resp
模擬瀏覽器把回應放進 iframe,不會套用回應標頭,所以下方練習改用 <meta> 標籤,讓瀏覽器真正執行這條 script-src:
<meta http-equiv="Content-Security-Policy" content="script-src 'self'">
實際部署仍應優先使用回應標頭:<meta> 必須放在 <head> 的最前面,它之前的內容不受保護,也不支援 frame-ancestors、sandbox 與 report-uri。
不同位置的轉義規則
HTML 轉義只在 HTML 文字與加上引號的屬性值中足夠。同一個值放在不同位置,需要的處理不同:
| 輸出位置 | 範例 | 正確的處理 |
|---|---|---|
| HTML 文字 | <p>{{ x }}</p> |
HTML 轉義,Jinja 預設已處理 |
| 屬性值 | <input value="{{ x }}"> |
HTML 轉義,且一定要加引號。未加引號時,空白就能結束屬性值,再加入其他屬性 |
| JavaScript | <script>var x = {{ x|tojson }};</script> |
使用 tojson 過濾器產生合法的 JavaScript 值,不要直接放入變數 |
| 網址 | <a href="{{ x }}"> |
HTML 轉義不足以處理,必須檢查網址的通訊協定 |
| CSS、事件屬性、註解 | <div onclick="{{ x }}"> |
不要放入不可信的資料,OWASP 列為編碼也無法可靠保護的位置 |
javascript: 連結是常見的例子。這段文字不含角括號;即使其中的引號被轉義,瀏覽器解析屬性值時仍會還原成相同的網址,使用者點擊連結時便執行其中的程式。修補方式是允許清單:只接受 http 與 https。
from urllib.parse import urlparse
@app.route('/profile', methods=['POST'])
def profile():
url = request.form['url'].strip()
stored = url if urlparse(url).scheme in ('http', 'https') else ''
db = get_db()
db.execute('UPDATE profile SET url = ? WHERE id = 1', (stored,))
db.commit()
return render_template('profile.html', url=current_url())
以黑名單排除 javascript: 並不可靠:通訊協定的名稱不分大小寫,黑名單也可能遺漏其他不應接受的通訊協定。下方任務以大寫的寫法確認修補的方向。
需要接受 HTML 時的清理
有些功能確實需要接受 HTML,例如討論區的粗體與連結。這時轉義會破壞格式,應該使用成熟的函式庫做清理(sanitization),依允許清單保留安全的標籤與屬性,移除其他內容。MDN 與 OWASP 都建議使用 DOMPurify。
Uedu 的做法可以作為範例。static/js/uedu-sanitize.js 定義了共用設定,渲染 Markdown 或使用者內容時一律帶上設定:
const html = DOMPurify.sanitize(rawHtml, UEDU_SAFE_CONFIG);
這項設定禁止 <form>、<iframe>、<style>、<script> 等標籤與 inline style 屬性,討論區另有允許特定來源 iframe 的設定。使用清理函式庫時要注意兩點:清理之後不可再修改內容,否則可能使清理失效;函式庫本身要定期更新。
HTML 的解析規則相當複雜,MDN 建議使用信譽良好的第三方函式庫,不建議自行撰寫清理程式。
重點整理
- XSS 的根源是資料與 HTML 或 JavaScript 混在一起,瀏覽器無法分辨,注入的腳本擁有與網站程式碼相同的權限
- 反射型來自請求本身,儲存型來自資料庫,DOM 型由前端程式處理
- 主要防護是在輸出時依位置轉義。Jinja 的自動轉義處理 HTML 文字與加引號的屬性值,
|safe會關閉這項保護 - 網址要以允許清單檢查通訊協定,JavaScript 位置使用
tojson - HttpOnly 防止腳本讀取 Cookie,不能阻止腳本執行;CSP 屬於縱深防禦,不能取代修補
- 需要接受 HTML 時,以 DOMPurify 這類成熟的函式庫依允許清單清理
- 只在自己擁有或獲得明確授權的系統上進行測試
下一課討論另一種利用瀏覽器行為的攻擊:跨站請求偽造。
小測驗
模板寫成 {{ comment|safe }} 的作用是什麼?
工作階段 Cookie 已設為 HttpOnly,頁面仍有 XSS 漏洞。下列敘述何者正確?
網站已設定含 script-src 的 CSP,模板仍使用 |safe 輸出留言。這樣的狀態應如何評價?
模板為 <a href="{{ url }}">,url 來自使用者,Jinja 自動轉義已啟用。輸入 javascript:… 會如何?
為什麼移除字串 <script> 不足以修補 XSS?
討論區需要讓使用者發表含粗體與連結的內容,應該採取哪一種作法?