National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 72 24°C PM2.5 13
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 08

跨站腳本(XSS)

反射型與儲存型,輸出轉義與內容安全政策
Interaction 40 minute(s)

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)把有特殊意義的字元換成對應的字元參照,例如 < 換成 &lt;,瀏覽器會將它顯示成文字,不會解析為標籤。

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

本區塊為模擬環境。 網站是真的 Flask,頁面在瀏覽器的沙箱 iframe 中執行,不連線到任何外部系統,Cookie 與資料皆為虛構。未經授權對真實網站進行相同操作可能觸犯法律(第 1 課)。

按「啟動伺服器」後依任務操作。修改模板後要按「存檔並重新啟動」。

儲存型 XSS 與不完整的修補

留言板的情況更嚴重:內容存進資料庫之後,每一位瀏覽頁面的使用者都會執行這段腳本,受害者不需要點擊任何連結。

常見的不完整作法如下:

作法 問題
移除字串 <script> HTML 還有其他能執行程式的寫法,例如事件屬性;也會改動合法內容
移除或禁止所有 < 學生寫 A < B 的留言會被破壞;資料庫裡的舊資料仍未處理
只在前端檢查輸入 請求可以略過瀏覽器直接送出(第 7 課)
只在寫入資料庫時處理 同一份資料可能用在不同位置,舊資料也不受影響

輸出轉義放在顯示的時候,依輸出的位置處理,內容本身保持原樣。下方任務的最後一步以 A < B 確認這一點。

互動練習:儲存型 XSS

本區塊為模擬環境。 網站是真的 Flask,頁面在瀏覽器的沙箱 iframe 中執行,不連線到任何外部系統,Cookie 與資料皆為虛構。未經授權對真實網站進行相同操作可能觸犯法律(第 1 課)。

留言內容會存入下方「資料庫」分頁的 comments 資料表。

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。

互動練習:HttpOnly 與 CSP

本區塊為模擬環境。 網站是真的 Flask,頁面在瀏覽器的沙箱 iframe 中執行,不連線到任何外部系統,Cookie 與資料皆為虛構。未經授權對真實網站進行相同操作可能觸犯法律(第 1 課)。

資料庫已存入一則含有腳本的留言:它會讀取 Cookie,並以目前使用者的身分送出一則新留言。每次載入頁面都會多一則留言,可以在「資料庫」分頁觀察。

不同位置的轉義規則

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: 並不可靠:通訊協定的名稱不分大小寫,黑名單也可能遺漏其他不應接受的通訊協定。下方任務以大寫的寫法確認修補的方向。

互動練習:javascript: 連結與允許清單

本區塊為模擬環境。 網站是真的 Flask,頁面在瀏覽器的沙箱 iframe 中執行,不連線到任何外部系統,Cookie 與資料皆為虛構。未經授權對真實網站進行相同操作可能觸犯法律(第 1 課)。

攻擊語句:javascript:console.log('cookie=' + document.cookie)。

練習的頁面放在沙箱 iframe 中,Chromium 在這種 iframe 內不執行 javascript: 連結,一般網頁則會執行。模板 profile.html 因此附了一小段輔助程式,重現一般網頁點擊連結的結果。

需要接受 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 這類成熟的函式庫依允許清單清理
  • 只在自己擁有或獲得明確授權的系統上進行測試

下一課討論另一種利用瀏覽器行為的攻擊:跨站請求偽造。

小測驗

1.

模板寫成 {{ comment|safe }} 的作用是什麼?

2.

工作階段 Cookie 已設為 HttpOnly,頁面仍有 XSS 漏洞。下列敘述何者正確?

3.

網站已設定含 script-src 的 CSP,模板仍使用 |safe 輸出留言。這樣的狀態應如何評價?

4.

模板為 <a href="{{ url }}">,url 來自使用者,Jinja 自動轉義已啟用。輸入 javascript:… 會如何?

5.

為什麼移除字串 <script> 不足以修補 XSS?

6.

討論區需要讓使用者發表含粗體與連結的內容,應該採取哪一種作法?

第 1 次作答
這一課完成了!
Next lesson:跨站請求偽造(CSRF)
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。