💡 先搞懂問題
虛構的「北辰書店」有一個書評區,讀者留言後會顯示在書籍頁面上。工程師用最直覺的寫法:html = f"<p>{comment}</p>",把留言接進頁面送給瀏覽器。測試時打了幾句中文都正常,於是上線。沒多久出現兩件怪事:一位讀者在留言裡打了 <b>超推</b>,其他人看到的不是這幾個字,而是一個粗體的「超推」;另一位讀者討論書裡的數學,寫了「如果 a<b 且 b>c」,結果後半句在頁面上整段不見了。
兩件事的原因相同:留言被直接放進 HTML 文件,瀏覽器在解析時分不出哪些角括號是工程師寫的、哪些是讀者打的,只要長得像標籤就當成標籤處理。粗體只是無害的例子,HTML 標記還能載入圖片、嵌入框架、執行程式;如果輸入裡含有程式碼,瀏覽器同樣會照 HTML 的規則把它執行,而且是在其他讀者的瀏覽器裡、帶著他們的登入狀態執行。這類弱點叫做跨站腳本(Cross-Site Scripting,XSS):網站把不可信的資料放進頁面時沒有處理,讓瀏覽器把資料當成標記或程式。
新手最常卡在三個想法。「我的網站沒有存什麼機密」:XSS 影響的是看頁面的人,他們的登入身分、在頁面上輸入的資料都在範圍內。「我把 script 這幾個字刪掉了」:黑名單擋不完各種標籤與寫法,而且會誤刪合法內容。「前端已經檢查過格式」:請求可以不經過你的表單直接送到伺服器。真正的成因只有一個:資料和 HTML 標記被混在同一份文件裡。
解法叫輸出編碼(output encoding,也常說「逸出」escaping):在資料要放進 HTML 的那一刻,把 < > & " ' 這幾個對 HTML 有特殊意義的字元,換成 HTML 實體(entity),例如 < 變成 <。瀏覽器看到實體會顯示原本的字元,但不會把它當成標籤的開頭,所以讀者打的 <b> 就原樣顯示成文字。Python 的 html.escape()、Jinja2 的自動逸出、瀏覽器端的 textContent,做的都是這件事。
生活比喻:公司公告欄上的便條
一家公司在大廳設了公告欄,正式公告由行政部門排版張貼,另外開放員工貼便條。如果行政人員收到便條後,直接把便條內容「印」進正式版面,用一樣的字體、一樣的格式,那麼有人在便條上寫一段看起來像總經理公告的文字,路過的人就分不出真假。比較好的做法,是每張便條都裝進印著「員工留言」的透明套再貼上去:內容一字不漏地看得到,但任何人都知道這是便條,不會把它當成正式公告。
html.escape() 或模板的自動逸出把 < 換成 < 之後,瀏覽器看得到字,卻不會把它當成標籤。反過來,Jinja2 的 |safe、Markup()、前端的 innerHTML,等於把便條從套子裡拿出來直接貼上。
這個比喻有兩個限制。第一,透明套只有一種,但 HTML 裡的「位置」有好幾種:同一個值放在標籤之間、放在屬性的引號裡、放進網址、放進 script 區塊,需要的編碼都不一樣,實驗室二會讓你逐一配對。第二,看公告的人會用常識判斷,瀏覽器不會,它只照 HTML 的解析規則走,所以「應該沒有人會這樣打」不能當成防線。
🎮 互動實驗室一:書評留言板模擬
按下方任一則留言,看它經過兩種放法後的樣子。左邊把留言當成 HTML 放進頁面(伺服器端字串拼接,或前端的 innerHTML),右邊先做輸出編碼(伺服器端 html.escape,或前端的 textContent)。兩邊的「預覽」都是瀏覽器真的渲染出來的結果,下方的節點樹則是瀏覽器解析後實際建立的結構。這幾則留言都只含無害的格式標籤,但請記得:左邊的放法對任何標籤都一視同仁,如果留言裡含有程式碼,瀏覽器同樣會把它執行。
❌ 直接當成 HTML
html = f"<p>{comment}</p>" # 伺服器端拼接
box.innerHTML = comment; // 前端寫法
✅ 輸出編碼後再放
html = f"<p>{html.escape(comment)}</p>" # 伺服器端
box.textContent = comment; // 前端寫法
🎮 互動實驗室二:輸出位置配對
同一個暱稱 Tom & "Jerry" <3,要放進頁面上 6 個不同位置(黃色框是放值的地方)。先點一個位置,再點下方你認為正確的處理方式;也可以先點處理方式再點位置,電腦上可以直接拖曳。同一種處理方式可以用在不只一個位置,下方也混了 2 個常見但不可靠的做法。配對成功後,卡片會顯示套用後實際送進頁面的內容,這些字串都和 Python 的 html.escape、urllib.parse.quote 與 Jinja2 |tojson 的實際輸出一致。
🎮 互動實驗室三:程式判斷卡
每張卡是一段把資料放進網頁的程式。user_input、userInput、user_url 來自使用者,llm_reply 是語言模型的回覆,全部都視為不可信。請判斷這段程式是安全的,還是有 XSS 風險。判斷時問一件事:資料最後是被當成文字(經過編碼或淨化),還是被當成 HTML 標記放進頁面?答完會說明原因,Python 卡片附上用無害輸入 <b>粗體</b> 實際執行的結果。
from jinja2 import Environment
env = Environment()
html = env.from_string("<p>{{ c }}</p>").render(c=user_input)from jinja2 import Environment, select_autoescape
env = Environment(autoescape=select_autoescape())
html = env.from_string("<p>{{ c }}</p>").render(c=user_input)from flask import render_template_string
return render_template_string("<p>{{ c }}</p>", c=user_input)from flask import render_template_string
return render_template_string(f"<p>{user_input}</p>")env = Environment(autoescape=select_autoescape())
html = env.from_string("<p>{{ c|safe }}</p>").render(c=user_input)from markupsafe import Markup
badge = Markup(f"<b>{user_input}</b>")from markupsafe import Markup
badge = Markup("<b>{}</b>").format(user_input)import html
page = f"<p>{html.escape(user_input)}</p>"const box = document.querySelector("#comment");
box.innerHTML = userInput;const box = document.querySelector("#comment");
box.textContent = userInput;function Comment({ userInput }) {
return <div dangerouslySetInnerHTML={{ __html: userInput }} />;
}function Comment({ userInput }) {
return <div>{userInput}</div>;
}import html
link = f'<a href="{html.escape(user_url)}">個人網站</a>'env = Environment(autoescape=select_autoescape())
html = env.from_string("<input value={{ c }}>").render(c=user_input)import markdown
body = markdown.markdown(llm_reply) # 模型回覆的 Markdown 轉成 HTML
page = env.from_string("<div>{{ b|safe }}</div>").render(b=body)import markdown, nh3
body = nh3.clean(markdown.markdown(llm_reply)) # 轉成 HTML 後再淨化
page = env.from_string("<div>{{ b|safe }}</div>").render(b=body)📘 原理補完
實驗室的直覺可以濃縮成一句話:資料要放進網頁時,必須依它所在的位置轉成「只會被當成文字」的形式。下面把這句話接回正式用語:XSS 有哪幾型、輸出編碼的細節、不同位置的規則、模板與前端框架的預設行為,以及第二道防線與 LLM 輸出的處理。
1. 三種 XSS:不可信資料從哪裡來、在哪裡被放進頁面
OWASP Top 10:2025 把 XSS 和 SQL 注入一起歸在 A05 Injection,對應的弱點編號是 CWE-79。依資料的來源和被放進頁面的位置,通常分成三型。三型的修法核心相同,都是在放進頁面的那一刻做對應的編碼,差別在於「那一刻」是在伺服器還是瀏覽器裡。
| 類型 | 不可信資料從哪裡來 | 在哪裡被放進頁面 | 影響範圍 | 修在哪裡 |
|---|---|---|---|---|
| 儲存型 Stored | 先存進資料庫的留言、暱稱、商品評論 | 伺服器產生頁面時 | 每個看到這筆資料的人 | 伺服器端模板的輸出編碼 |
| 反射型 Reflected | 網址參數、表單欄位,當下的請求 | 伺服器把它回顯在回應裡,例如「查無『…』的結果」 | 點了那個網址的人 | 伺服器端模板的輸出編碼 |
| DOM 型 DOM-based | 網址的 # 片段、查詢字串、localStorage、API 回應 | 前端 JavaScript 自己寫進頁面 | 執行那段前端程式的人 | 前端改用 textContent 等安全寫法 |
2. 輸出編碼的細節:html.escape 與 markupsafe
HTML 裡有五個字元需要處理:& 是實體的開頭,< 與 > 決定標籤的範圍," 與 ' 決定屬性值的範圍。Python 標準函式庫的 html.escape(s, quote=True) 預設五個都會轉換;Jinja2 使用的 markupsafe.escape() 也轉換這五個,只是引號採用數字寫法,兩者在瀏覽器裡的效果完全相同。markupsafe 會回傳 Markup 物件,代表「這段已經安全」,之後再遇到模板的自動逸出就不會被重複處理。
編碼要在出口做,資料庫裡存的是原文。如果在存進資料庫前就先編碼,輸出時模板又自動逸出一次,畫面上就會出現 A&B 這種重複編碼的字串;而且同一筆資料若還要輸出成 JSON、CSV 或寄出 email,提前編碼的版本在那些地方都是錯的。地圖上「輸入驗證與輸出編碼」節點講的「入口驗證、出口編碼」就是這個分工。
3. 依輸出位置選擇編碼方式
HTML 文件裡不同位置的解析規則不同,所以「做了 html.escape」不代表任何位置都安全。實驗室二的六個位置可以整理成下表。OWASP 的 XSS 防範指南也強調:屬性值一律加上引號,script 區塊與事件處理屬性裡不要放不可信的資料。
| 輸出位置 | 例子 | 正確處理 | 只做 html.escape 會怎樣 |
|---|---|---|---|
| HTML 內文 | <p>{v}</p> | HTML 實體編碼(自動逸出) | 正確 |
| 加了引號的屬性 | <input value="{v}"> | HTML 實體編碼,引號也要轉 | 正確(quote=True 是預設) |
| 沒有引號的屬性 | <input value={v}> | 先改模板加上引號 | 空白不會被編碼,值在第一個空白就被截斷,後面的字變成別的屬性 |
| 網址的參數 | <a href="/s?q={v}"> | urllib.parse.quote 編碼參數值 | & 與空白會破壞網址結構,參數被切錯 |
| 使用者提供的完整網址 | <a href="{url}"> | 先檢查開頭只允許 http:// 或 https://,再做屬性編碼 | 編碼不檢查網址協定,有些協定會讓瀏覽器執行程式 |
| script 區塊 | const x = {v}; | Jinja2 |tojson,或改放 data- 屬性再用 JS 讀 | HTML 實體在 script 裡不會被還原,規則完全不同 |
4. 模板的自動逸出,以及關掉它的三種寫法
手動在每個輸出點呼叫 html.escape 很容易漏,所以實務上交給模板引擎的自動逸出(autoescape):模板裡的 {{ 變數 }} 一律先編碼再輸出。各框架的預設值不同,這是程式題與程式碼審查最常出錯的地方:
| 寫法 | 自動逸出預設 | 說明 |
|---|---|---|
Jinja2 Environment() | ❌ 關閉 | 直接建立 Environment 時 autoescape 預設是 False,Bandit 會以 B701 標出 |
Jinja2 Environment(autoescape=select_autoescape()) | ✅ 依副檔名開啟 | 預設對 .html、.htm、.xml 開啟;from_string 建立的模板沒有檔名,default_for_string 預設為 True,所以也會開啟 |
Flask render_template("x.html") | ✅ 開啟 | Flask 對 .html、.htm、.xml、.xhtml、.svg 模板開啟自動逸出 |
Flask render_template_string(tpl, c=v) | ✅ 開啟 | 值當成變數傳入時會被逸出 |
Flask render_template_string(f"...{v}...") | ⚠️ 沒有作用 | 值在 f-string 裡已經變成模板原始碼,不是變數,逸出管不到;連 {{ }} 都會被當成模板語法執行 |
| Django 模板 | ✅ 開啟 | 用 mark_safe 或 |safe 才會關閉 |
自動逸出開著之後,破口幾乎都出在「手動關掉」的地方:Jinja2 的 |safe 過濾器、markupsafe.Markup()、Django 的 mark_safe。它們的意思都是「這段我保證是安全的 HTML,請不要逸出」,所以只能用在完全由程式產生、不含任何使用者資料的片段。Markup(f"<b>{user_input}</b>") 是常見錯誤:f-string 先把使用者輸入接進去,Markup 再把整段標成安全。正確寫法是 Markup("<b>{}</b>").format(user_input),外框是可信的 HTML,format() 的參數會先被逸出。Bandit 1.9 的 B704 會標出可疑的 Markup 用法。
5. 前端:textContent 與 innerHTML
DOM 型 XSS 的修法在瀏覽器端。把文字放進元素時,用 textContent(或 innerText);設定屬性時,用 setAttribute 或直接設定屬性值,例如 input.value = …。這些寫法本來就只處理文字,不會啟動 HTML 解析器。相反地,innerHTML、outerHTML、insertAdjacentHTML、document.write 會把字串當成 HTML 解析,OWASP 稱之為不安全的「sink」,建議能改就改。React 的 JSX 會自動逸出 {userInput},Vue 的 {{ }} 也是;它們關掉逸出的開關分別是 dangerouslySetInnerHTML 和 v-html,角色和 Jinja2 的 |safe 一樣。
6. 真的需要顯示使用者的 HTML 時:淨化
富文字編輯器、Markdown 留言、LLM 回覆的格式,都是「需要保留部分 HTML」的情況,這時不能整段逸出,要改用 HTML 淨化(sanitization):依允許清單只保留安全的標籤與屬性,例如段落、粗體、清單、連結,其餘全部移除。Python 可用 nh3(Rust 函式庫 ammonia 的 Python 介面),瀏覽器端 OWASP 推薦 DOMPurify。淨化要在輸出前、伺服器或前端的最後一步做,並使用維護中的函式庫,不要自己用正規表示式刪標籤。
7. 第二道防線:CSP 與 HttpOnly
內容安全政策(Content Security Policy,CSP)是伺服器送出的回應標頭,告訴瀏覽器這個頁面可以從哪些來源載入與執行腳本,例如只允許自己網域的檔案、不允許頁面內嵌的腳本。即使某處漏了編碼,瀏覽器也可能因為 CSP 而拒絕執行注入的內容。HttpOnly 是 Cookie 的屬性,設定後頁面上的 JavaScript 讀不到這個 Cookie,可以降低登入憑證被偷走的風險;通常和 Secure(只走 HTTPS)、SameSite 一起設定。兩者都是縱深防禦:OWASP 明確表示它們是輔助控制,不能取代輸出編碼。HttpOnly 不會阻止 XSS 發生,注入的程式仍然可以用使用者的身分在頁面上操作;CSP 則依瀏覽器支援與設定方式而異,舊系統也常因為大量內嵌腳本而無法嚴格設定。
8. LLM 的輸出也是不可信輸入
AI 聊天介面最常見的寫法,是把模型的回覆用 Markdown 轉成 HTML 再顯示。問題在於模型的輸出會受到使用者提問、檢索到的網頁與文件內容影響,它可能包含任何字串,包括 HTML 標記。OWASP Top 10 for LLM Applications 2025 把這類問題列為 LLM05 輸出處理不當(Improper Output Handling)。Python-Markdown 預設會把文字裡的原始 HTML 原樣保留,所以「轉成 HTML 後直接用 |safe 顯示」和直接用 innerHTML 沒有差別。正確流程是:不需要格式時,直接把回覆當成文字逸出;需要格式時,Markdown 轉換之後一定要再經過 nh3 或 DOMPurify 淨化。Streamlit 的 st.markdown 預設 unsafe_allow_html=False,也是基於同樣的考量。
9. 自我檢查:Bandit、搜尋與測試
Bandit 有幾條和 XSS 有關的規則:B701 jinja2_autoescape_false(建立 Environment 沒開自動逸出,嚴重度 High)、B704 markupsafe_markup_xss(Markup 可能包住不可信資料)、B703 django_mark_safe。前端程式可以用 Semgrep 或 ESLint 的規則找出 innerHTML 與 dangerouslySetInnerHTML。工具之外,程式碼審查時直接搜尋 |safe、Markup(、mark_safe、render_template_string、innerHTML,逐一確認資料來源。最後把「含有角括號、引號、& 的正常文字」寫進測試,斷言輸出裡出現的是 < 而不是原本的標籤,範例二的最後一行就是這種測試。
寫法比較與判斷步驟
| 寫法 | 資料被當成什麼 | 判斷 |
|---|---|---|
f-string 拼 HTML、Environment() 未開逸出 | HTML 標記 | ❌ 有風險 |
|safe、Markup(f"…")、mark_safe | HTML 標記(手動關掉逸出) | ❌ 有風險 |
innerHTML、dangerouslySetInnerHTML、v-html | HTML 標記 | ❌ 有風險(除非先淨化) |
html.escape、markupsafe.escape、模板 {{ }} 自動逸出 | 文字 | ✅ 安全(HTML 內文與加引號的屬性) |
Markup("…{}…").format(v) | 外框是標記,參數是文字 | ✅ 安全 |
textContent、JSX 的 {v} | 文字 | ✅ 安全 |
nh3.clean()、DOMPurify.sanitize() 後再輸出 HTML | 只剩允許清單內的標記 | ✅ 安全(需要保留格式時) |
| 沒有引號的屬性、完整網址、script 區塊 | 依位置而定 | ⚠️ 只做 HTML 編碼不夠,見第 3 節 |
- 先找出所有不可信資料:使用者輸入、資料庫裡別人寫的內容、網址參數、API 回應,以及 LLM 的輸出。
- 追到它被放進頁面的位置:伺服器端模板,還是前端 JavaScript。
- 確認那個位置的處理:模板有沒有開自動逸出、前端是不是用 textContent。
- 確認沒有手動關掉逸出:|safe、Markup、mark_safe、innerHTML、dangerouslySetInnerHTML 旁邊的資料,必須是程式自己產生的,或已經淨化過。
- 檢查特殊位置:屬性有沒有加引號、網址有沒有檢查協定、script 區塊裡有沒有直接寫入資料。
- 最後補上第二道防線(CSP、HttpOnly Cookie)與含特殊字元的測試。
完整範例一:Jinja2 的正確設定與各位置的處理
這段程式用無害的格式標籤示範自動逸出、兩種 escape 函式、Markup.format、屬性與 script 區塊的處理,以及重複逸出的結果。需要 pip install jinja2。
import html
from jinja2 import Environment, select_autoescape
from markupsafe import Markup, escape
env = Environment(autoescape=select_autoescape()) # 直接建 Environment 時一定要明確開啟
user_input = "<b>粗體</b>" # 無害的示範輸入
print(env.from_string("<p>{{ c }}</p>").render(c=user_input)) # 自動逸出
print(html.escape(user_input), "|", escape(user_input)) # 標準函式庫與 markupsafe 結果相同
print(html.escape('"引號"'), "|", escape('"引號"')) # 引號的寫法不同,但都安全
print(Markup("<b>{}</b>").format(user_input)) # 外框是可信 HTML,參數會被逸出
print(env.from_string("<input value=\"{{ c }}\">").render(c='Tom & "Jerry"')) # 屬性:加引號再逸出
print(env.from_string("const nick = {{ c|tojson }};").render(c="Tom <3")) # script 區塊:輸出成 JSON
print(html.escape(html.escape("A&B"))) # 重複逸出:畫面會出現 &
完整範例二:Flask 的自動逸出、CSP 與 HttpOnly Cookie,加上測試
用 Flask 的測試用戶端(test client)直接發請求,不必真的開伺服器,就能檢查回應內容與標頭。需要 pip install flask。
from flask import Flask, render_template_string, make_response, request
app = Flask(__name__)
PAGE = "<h1>留言板</h1><p>{{ comment }}</p>" # {{ }} 會自動逸出
@app.get("/post")
def post():
comment = request.args.get("c", "") # 來自網址參數:不可信
resp = make_response(render_template_string(PAGE, comment=comment))
resp.set_cookie("sid", "demo-token", httponly=True, secure=True, samesite="Lax")
return resp
@app.after_request
def add_csp(resp): # 第二道防線:限制頁面能載入與執行的腳本來源
resp.headers["Content-Security-Policy"] = "default-src 'self'; object-src 'none'; base-uri 'none'"
return resp
client = app.test_client() # 不用開伺服器就能測
r = client.get("/post", query_string={"c": "<b>粗體</b>"})
print(r.get_data(as_text=True))
print(r.headers["Set-Cookie"])
print(r.headers["Content-Security-Policy"])
assert "<b>" in r.get_data(as_text=True) # 測試:標籤必須被逸出
完整範例三:LLM 回覆先轉 Markdown,再淨化
需要 pip install markdown nh3 jinja2。示範的回覆只含無害的樣式屬性,可以看到 Markdown 轉換會原樣保留它,nh3 則依允許清單把它移除;同樣的機制也會移除事件處理屬性與 script 這類標籤。
import markdown, nh3
from jinja2 import Environment, select_autoescape
env = Environment(autoescape=select_autoescape())
llm_reply = '**重點**:記得 <span style="color:red">回覆時間</span>' # 模型回覆,視為不可信
raw_html = markdown.markdown(llm_reply) # Python-Markdown 預設會保留原始 HTML
safe_html = nh3.clean(raw_html) # 淨化:只留允許清單內的標籤與屬性
print(raw_html)
print(safe_html)
print(env.from_string("<div>{{ b|safe }}</div>").render(b=safe_html)) # 淨化後才用 |safe
print(env.from_string("<div>{{ b }}</div>").render(b=llm_reply)) # 不需要格式時:直接當文字逸出
前端寫法:用 textContent 組畫面
需要組出較複雜的結構時,用 createElement 建立元素,再用 textContent 放文字,資料就不會經過 HTML 解析器。
function renderComment(list, comment) {
const li = document.createElement("li"); // 結構由程式建立
const name = document.createElement("b");
name.textContent = comment.author; // 文字一律用 textContent
const body = document.createElement("span");
body.textContent = comment.text;
li.append(name, ":", body);
list.append(li);
}
// renderComment(ul, { author: "讀者 A", text: "<u>底線</u>" })
// → 畫面顯示「讀者 A:<u>底線</u>」,角括號原樣出現,沒有建立 u 元素
容易寫錯或考錯的地方
Environment() 時預設不會,要加 autoescape=select_autoescape()。✅ 自我檢測
6 題原創程式閱讀題,所有 Python 輸出都實際執行確認過,前端題在瀏覽器裡實測。選完會立即顯示對錯與解析,全部作答後出現總分。目前得分:0 / 6
Q1.執行後印出什麼?
import html
print(html.escape('<b>"Hi" & \'yo\'</b>'))
'。A 是 quote=False 的結果,引號沒有被處理,放進屬性值時不安全;D 是 markupsafe.escape 的寫法,引號改用 " 與 ',效果相同但不是這個函式的輸出;C 沒有轉換角括號。Q2.執行後印出什麼?
from jinja2 import Environment
env = Environment()
print(env.from_string("<p>{{ c }}</p>").render(c="<i>x</i>"))
<p><i>x</i></p>,放進網頁後 i 會被當成標籤。改成 Environment(autoescape=select_autoescape()) 才會得到 A。Bandit 會用 B701 標出這種寫法。Q3.執行後印出什麼?
from markupsafe import escape
s = escape("O'Neil <3")
print(s, type(s).__name__)
'、小於寫成 <,並回傳 Markup 物件,代表「已經安全」,之後的自動逸出不會再處理一次。A 是 html.escape 的引號寫法,而且型別是 str。Q4.下列前端程式中,哪一行會讓 userInput 被瀏覽器當成 HTML 標記解析?
Q5.執行後印出什麼?
from markupsafe import Markup
print(Markup("<b>{}</b>").format("<i>"))
format() 的參數則會先被逸出,所以得到 <b><i></b>。如果寫成 Markup(f"<b>{x}</b>"),參數在 f-string 階段就接進去了,結果會是 A。Q6.Flask 程式這樣設定 Cookie,實際送出的標頭如註解所示。關於它的效果,哪一個敘述正確?
resp.set_cookie("sid", "demo-token", httponly=True)
print(resp.headers["Set-Cookie"]) # sid=demo-token; HttpOnly; Path=/
🎯 重點整理
- XSS 的成因是資料和 HTML 標記混在同一份文件:使用者輸入被直接放進頁面時,瀏覽器會把其中的標籤當真,輸入裡若含有程式碼也一樣會被執行。
- 根本修正是輸出編碼:在放進頁面的那一刻把 & < > " ' 換成實體,伺服器用 html.escape 或模板自動逸出,前端用 textContent。資料庫存原文,出口才編碼。
- 位置不同規則不同:屬性一律加引號,網址參數用 urllib.parse.quote,完整網址先檢查只允許 http/https,script 區塊用 |tojson 或改放 data- 屬性。
- Jinja2 的 Environment() 預設不逸出;|safe、Markup(f"…")、mark_safe、innerHTML、dangerouslySetInnerHTML 都是手動關掉保護,只能用在程式自己產生或已淨化的內容。
- 需要保留格式(富文字、Markdown、LLM 回覆)時用 nh3 或 DOMPurify 淨化;CSP 與 HttpOnly Cookie 是第二道防線,不能取代編碼。