🗺️ 程式互動式地圖
資安線・安全編碼

XSS 與輸出編碼:為什麼使用者輸入不能直接放進網頁

留言、評論、AI 聊天回覆,都是把別人寫的文字放進你的網頁。這一頁說明瀏覽器為什麼會把這些文字當成 HTML 標記解讀、輸出編碼怎麼讓它只當文字顯示、不同位置該用哪一種編碼,以及模板、前端框架和 LLM 輸出最容易出錯的地方。

留言板模擬 輸出位置配對 16 張程式判斷卡

💡 先搞懂問題

虛構的「北辰書店」有一個書評區,讀者留言後會顯示在書籍頁面上。工程師用最直覺的寫法: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),例如 < 變成 &lt;。瀏覽器看到實體會顯示原本的字元,但不會把它當成標籤的開頭,所以讀者打的 <b> 就原樣顯示成文字。Python 的 html.escape()、Jinja2 的自動逸出、瀏覽器端的 textContent,做的都是這件事。

直接拼接:留言變成 HTML 標記 <b>超推</b> <p><b>超推</b></p> 瀏覽器建立的節點 p b 「超推」 畫面上: 超推 讀者打的角括號被當成標籤; 如果是程式碼,一樣會被執行 輸出編碼:留言只當文字顯示 <b>超推</b> <p>&lt;b&gt;超推 &lt;/b&gt;</p> 瀏覽器建立的節點 p "<b>超推</b>" 畫面上: <b>超推</b> html.escape 先把 < > 換成實體, 瀏覽器只建立一個文字節點
同一則留言,兩種放法。上半直接拼接,瀏覽器把 <b> 解析成一個元素,畫面變成粗體;下半先做輸出編碼,送到瀏覽器的是 &lt;b&gt;,瀏覽器只建立一個文字節點,畫面上看到的就是讀者原本打的字。

生活比喻:公司公告欄上的便條

一家公司在大廳設了公告欄,正式公告由行政部門排版張貼,另外開放員工貼便條。如果行政人員收到便條後,直接把便條內容「印」進正式版面,用一樣的字體、一樣的格式,那麼有人在便條上寫一段看起來像總經理公告的文字,路過的人就分不出真假。比較好的做法,是每張便條都裝進印著「員工留言」的透明套再貼上去:內容一字不漏地看得到,但任何人都知道這是便條,不會把它當成正式公告。

回到程式:剛才的正式版面,對應的就是網頁裡工程師寫的 HTML 標記;便條是使用者輸入;透明套就是輸出編碼。html.escape() 或模板的自動逸出把 < 換成 &lt; 之後,瀏覽器看得到字,卻不會把它當成標籤。反過來,Jinja2 的 |safe、Markup()、前端的 innerHTML,等於把便條從套子裡拿出來直接貼上。
便條直接印進版面 【公告】年終尾牙日期 【公告】某員工寫的便條 格式一樣,分不出哪張是便條 便條裝進透明套 【公告】年終尾牙日期 員工留言便條內容原樣看得到 內容一字不漏,但不會被當成公告 正式版面 便條內容 透明套 把便條拿出套子 工程師寫的 HTML 標記 使用者輸入(不可信) 輸出編碼:html.escape、自動逸出 |safe、Markup()、innerHTML
上排是公告欄的兩種貼法,下排是對應的程式概念。透明套不改變便條的內容,只改變它被解讀的方式;輸出編碼也一樣,畫面上顯示的字和使用者打的完全相同。

這個比喻有兩個限制。第一,透明套只有一種,但 HTML 裡的「位置」有好幾種:同一個值放在標籤之間、放在屬性的引號裡、放進網址、放進 script 區塊,需要的編碼都不一樣,實驗室二會讓你逐一配對。第二,看公告的人會用常識判斷,瀏覽器不會,它只照 HTML 的解析規則走,所以「應該沒有人會這樣打」不能當成防線。

🎮 互動實驗室一:書評留言板模擬

按下方任一則留言,看它經過兩種放法後的樣子。左邊把留言當成 HTML 放進頁面(伺服器端字串拼接,或前端的 innerHTML),右邊先做輸出編碼(伺服器端 html.escape,或前端的 textContent)。兩邊的「預覽」都是瀏覽器真的渲染出來的結果,下方的節點樹則是瀏覽器解析後實際建立的結構。這幾則留言都只含無害的格式標籤,但請記得:左邊的放法對任何標籤都一視同仁,如果留言裡含有程式碼,瀏覽器同樣會把它執行。

❌ 直接當成 HTML

html = f"<p>{comment}</p>"          # 伺服器端拼接
box.innerHTML = comment;               // 前端寫法
送進頁面的 HTML
畫面預覽(瀏覽器實際渲染)
瀏覽器建立的節點

    ✅ 輸出編碼後再放

    html = f"<p>{html.escape(comment)}</p>"   # 伺服器端
    box.textContent = comment;             // 前端寫法
    送進頁面的 HTML(html.escape 之後)
    畫面預覽(瀏覽器實際渲染)
    瀏覽器建立的節點
      元素節點:被當成標記文字節點:只是字
      畫面說明:載入中。

      🎮 互動實驗室二:輸出位置配對

      同一個暱稱 Tom & "Jerry" <3,要放進頁面上 6 個不同位置(黃色框是放值的地方)。先點一個位置,再點下方你認為正確的處理方式;也可以先點處理方式再點位置,電腦上可以直接拖曳。同一種處理方式可以用在不只一個位置,下方也混了 2 個常見但不可靠的做法。配對成功後,卡片會顯示套用後實際送進頁面的內容,這些字串都和 Python 的 html.escape、urllib.parse.quote 與 Jinja2 |tojson 的實際輸出一致。

      已配對 0 / 6
      一次就對 0
      配錯次數 0
      畫面說明:載入中。

      🎮 互動實驗室三:程式判斷卡

      每張卡是一段把資料放進網頁的程式。user_input、userInput、user_url 來自使用者,llm_reply 是語言模型的回覆,全部都視為不可信。請判斷這段程式是安全的,還是有 XSS 風險。判斷時問一件事:資料最後是被當成文字(經過編碼或淨化),還是被當成 HTML 標記放進頁面?答完會說明原因,Python 卡片附上用無害輸入 <b>粗體</b> 實際執行的結果。

      答對 0 / 0
      連續答對 0
      畫面說明:載入中。

      📘 原理補完

      實驗室的直覺可以濃縮成一句話:資料要放進網頁時,必須依它所在的位置轉成「只會被當成文字」的形式。下面把這句話接回正式用語:XSS 有哪幾型、輸出編碼的細節、不同位置的規則、模板與前端框架的預設行為,以及第二道防線與 LLM 輸出的處理。

      1. 三種 XSS:不可信資料從哪裡來、在哪裡被放進頁面

      OWASP Top 10:2025 把 XSS 和 SQL 注入一起歸在 A05 Injection,對應的弱點編號是 CWE-79。依資料的來源和被放進頁面的位置,通常分成三型。三型的修法核心相同,都是在放進頁面的那一刻做對應的編碼,差別在於「那一刻」是在伺服器還是瀏覽器裡。

      類型不可信資料從哪裡來在哪裡被放進頁面影響範圍修在哪裡
      儲存型 Stored先存進資料庫的留言、暱稱、商品評論伺服器產生頁面時每個看到這筆資料的人伺服器端模板的輸出編碼
      反射型 Reflected網址參數、表單欄位,當下的請求伺服器把它回顯在回應裡,例如「查無『…』的結果」點了那個網址的人伺服器端模板的輸出編碼
      DOM 型 DOM-based網址的 # 片段、查詢字串、localStorage、API 回應前端 JavaScript 自己寫進頁面執行那段前端程式的人前端改用 textContent 等安全寫法
      儲存型反射型DOM 型 讀者送出留言 資料庫 伺服器組頁面在這裡編碼 每位讀者的瀏覽器 網址參數 ?q=… 伺服器回顯在這裡編碼 點連結的人 網址 #片段、API 前端 JavaScript 寫進頁面在這裡用 textContent 同一個瀏覽器 伺服器可能完全沒看到這筆資料
      三條路的起點不同,但綠框都是「資料被放進頁面的那一刻」,也就是該做編碼的位置。DOM 型的資料可能從頭到尾沒經過伺服器,所以伺服器端的模板設定得再好也管不到,要靠前端程式自己用安全的寫法。

      2. 輸出編碼的細節:html.escape 與 markupsafe

      HTML 裡有五個字元需要處理:& 是實體的開頭,< 與 > 決定標籤的範圍," 與 ' 決定屬性值的範圍。Python 標準函式庫的 html.escape(s, quote=True) 預設五個都會轉換;Jinja2 使用的 markupsafe.escape() 也轉換這五個,只是引號採用數字寫法,兩者在瀏覽器裡的效果完全相同。markupsafe 會回傳 Markup 物件,代表「這段已經安全」,之後再遇到模板的自動逸出就不會被重複處理。

      原本的字元html.escape()markupsafe.escape()畫面顯示 &&amp;&amp;& <&lt;&lt;< >&gt;&gt;> "&quot;&#34;" '&#x27;&#39;' 橘色是兩個函式寫法不同的地方:都是數字或名稱實體,瀏覽器顯示的結果一樣
      兩個函式都經過實際執行核對。重點不是背實體寫法,而是理解:編碼後的字串送到瀏覽器,畫面上看到的仍是原本的字元,只是它們不再有「開始一個標籤」或「結束一個屬性」的作用。

      編碼要在出口做,資料庫裡存的是原文。如果在存進資料庫前就先編碼,輸出時模板又自動逸出一次,畫面上就會出現 A&amp;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 裡不會被還原,規則完全不同
      <p>暱稱:①</p> <input value="②"> <a href="/s?q=③"> <a href="④">網站</a> <script>const x =⑤; </script> ① HTML 實體編碼 ② 屬性加引號+實體編碼 ③ urllib.parse.quote ④ 只允許 http/https+編碼 ⑤ |tojson 或改放 data- 屬性
      左邊是一份頁面裡常見的五個位置,右邊是各自的處理方式。綠色三個只靠編碼就夠;橘色兩個光編碼不夠:④ 要先決定哪些網址協定可以接受,⑤ 最好的做法是根本不要把資料寫進程式碼裡,而是當成 JSON 資料或 data- 屬性讀取。

      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 用法。

      user_input不可信資料 自動逸出{{ c }}、Markup.format() 頁面的 HTML 只會是文字 |safe Markup(f"…{輸入}…") render_template_string(f"…") 繞過逸出:被當成標記
      綠色是正常路徑:變數經過自動逸出才進入 HTML。紅色虛線是三種繞過方式,資料沒有經過逸出就直接成為頁面的一部分。程式碼審查時,搜尋 |safe、Markup(、mark_safe 與 render_template_string,就能很快找到需要細看的地方。

      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 一樣。

      box.innerHTML = "<u>底線</u>" box.textContent = "<u>底線</u>" div u "底線" div "<u>底線</u>" 啟動 HTML 解析器:建立新元素 不解析:整串就是一段文字
      這是實驗室一節點樹的簡化版。左邊多出一個 u 元素,代表字串裡的角括號被當成了標記;右邊永遠只有一個文字節點。用無害的格式標籤就能看出差別,換成任何其他標籤,左邊的行為也一樣。

      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 則依瀏覽器支援與設定方式而異,舊系統也常因為大量內嵌腳本而無法嚴格設定。

      外層:萬一漏了,限制損害並及早發現 CSP 限制腳本來源、HttpOnly/Secure/SameSite Cookie、Bandit 與測試 中層:減少不可信 HTML 進入頁面 需要保留格式時用 nh3/DOMPurify 淨化、入口的輸入驗證 核心:輸出編碼 模板自動逸出、html.escape、textContent 依 HTML 內文、屬性、網址、script 位置選對方法
      核心讓資料沒有機會變成標記;中層處理必須保留部分 HTML 的情境;外層在前兩層有漏洞時限制能造成的影響。只設定 CSP 或 HttpOnly 而沒有做輸出編碼,等於只裝了警報器卻沒有鎖門。

      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,也是基於同樣的考量。

      LLM 回覆不可信 Markdown轉成 HTML 淨化nh3/DOMPurify 放進模板淨化後才能 |safe 跳過淨化直接 |safe 不需要格式:直接逸出成文字
      上排是需要保留格式時的完整流程,淨化一定在 Markdown 轉換之後、放進頁面之前。紅色虛線是最常見的錯誤寫法;底下的綠線則是最簡單的安全做法:不需要格式的地方,就把回覆當成普通文字交給自動逸出。

      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,逐一確認資料來源。最後把「含有角括號、引號、& 的正常文字」寫進測試,斷言輸出裡出現的是 &lt; 而不是原本的標籤,範例二的最後一行就是這種測試。

      寫法比較與判斷步驟

      寫法資料被當成什麼判斷
      f-string 拼 HTML、Environment() 未開逸出HTML 標記❌ 有風險
      |safe、Markup(f"…")、mark_safeHTML 標記(手動關掉逸出)❌ 有風險
      innerHTML、dangerouslySetInnerHTML、v-htmlHTML 標記❌ 有風險(除非先淨化)
      html.escape、markupsafe.escape、模板 {{ }} 自動逸出文字✅ 安全(HTML 內文與加引號的屬性)
      Markup("…{}…").format(v)外框是標記,參數是文字✅ 安全
      textContent、JSX 的 {v}文字✅ 安全
      nh3.clean()、DOMPurify.sanitize() 後再輸出 HTML只剩允許清單內的標記✅ 安全(需要保留格式時)
      沒有引號的屬性、完整網址、script 區塊依位置而定⚠️ 只做 HTML 編碼不夠,見第 3 節
      1. 先找出所有不可信資料:使用者輸入、資料庫裡別人寫的內容、網址參數、API 回應,以及 LLM 的輸出。
      2. 追到它被放進頁面的位置:伺服器端模板,還是前端 JavaScript。
      3. 確認那個位置的處理:模板有沒有開自動逸出、前端是不是用 textContent。
      4. 確認沒有手動關掉逸出:|safe、Markup、mark_safe、innerHTML、dangerouslySetInnerHTML 旁邊的資料,必須是程式自己產生的,或已經淨化過。
      5. 檢查特殊位置:屬性有沒有加引號、網址有沒有檢查協定、script 區塊裡有沒有直接寫入資料。
      6. 最後補上第二道防線(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")))                               # 重複逸出:畫面會出現 &amp;
      <p>&lt;b&gt;粗體&lt;/b&gt;</p> &lt;b&gt;粗體&lt;/b&gt; | &lt;b&gt;粗體&lt;/b&gt; &quot;引號&quot; | &#34;引號&#34; <b>&lt;b&gt;粗體&lt;/b&gt;</b> <input value="Tom &amp; &#34;Jerry&#34;"> const nick = "Tom <3"; A&amp;amp;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 "&lt;b&gt;" in r.get_data(as_text=True)         # 測試:標籤必須被逸出
      <h1>留言板</h1><p>&lt;b&gt;粗體&lt;/b&gt;</p> sid=demo-token; Secure; HttpOnly; Path=/; SameSite=Lax default-src 'self'; object-src 'none'; base-uri 'none'

      完整範例三: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))       # 不需要格式時:直接當文字逸出
      <p><strong>重點</strong>:記得 <span style="color:red">回覆時間</span></p> <p><strong>重點</strong>:記得 <span>回覆時間</span></p> <div><p><strong>重點</strong>:記得 <span>回覆時間</span></p></div> <div>**重點**:記得 &lt;span style=&#34;color:red&#34;&gt;回覆時間&lt;/span&gt;</div>

      前端寫法:用 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 元素

      容易寫錯或考錯的地方

      以為 Jinja2 預設會逸出:Flask 的 render_template 與 render_template_string 預設會,但自己 Environment() 時預設不會,要加 autoescape=select_autoescape()。
      Markup(f"…") 與 Markup("…").format():前者先把輸入接進去再整段標成安全,是破口;後者會先逸出參數,是正確寫法。程式題常把這兩個並列。
      render_template_string 的兩種用法:值當成變數傳入是安全的;用 f-string 把值寫進模板字串本身,自動逸出完全沒有作用。
      做了 html.escape 就以為哪裡都安全:沒有引號的屬性、使用者提供的完整網址、script 區塊,都需要額外處理。
      在輸入端刪掉 script 字樣:黑名單擋不完各種標籤與寫法,還會誤刪合法內容。正確做法是輸出時依位置編碼,需要保留格式時用維護中的淨化函式庫。
      把 HttpOnly 或 CSP 當成 XSS 的修正:它們是第二道防線。HttpOnly 只讓 JavaScript 讀不到 Cookie,注入的程式仍然能在頁面上操作。
      XSS 與 CSRF 混淆:XSS 是讓瀏覽器執行不該執行的內容,修法是輸出編碼;CSRF 是誘使已登入的瀏覽器送出使用者沒打算送的請求,修法是 CSRF token 與 SameSite Cookie。

      ✅ 自我檢測

      6 題原創程式閱讀題,所有 Python 輸出都實際執行確認過,前端題在瀏覽器裡實測。選完會立即顯示對錯與解析,全部作答後出現總分。目前得分:0 / 6

      Q1.執行後印出什麼?

      import html
      print(html.escape('<b>"Hi" & \'yo\'</b>'))
      html.escape 的 quote 參數預設是 True,所以五個特殊字元都會轉換,單引號寫成 &#x27;。A 是 quote=False 的結果,引號沒有被處理,放進屬性值時不安全;D 是 markupsafe.escape 的寫法,引號改用 &#34; 與 &#39;,效果相同但不是這個函式的輸出;C 沒有轉換角括號。

      Q2.執行後印出什麼?

      from jinja2 import Environment
      env = Environment()
      print(env.from_string("<p>{{ c }}</p>").render(c="<i>x</i>"))
      直接建立的 Environment,autoescape 預設是 False,變數原樣輸出,所以印出 <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__)
      markupsafe.escape 把單引號寫成 &#39;、小於寫成 &lt;,並回傳 Markup 物件,代表「已經安全」,之後的自動逸出不會再處理一次。A 是 html.escape 的引號寫法,而且型別是 str。

      Q4.下列前端程式中,哪一行會讓 userInput 被瀏覽器當成 HTML 標記解析?

      innerHTML 會把整個字串交給 HTML 解析器,userInput 裡的角括號會變成元素(C)。textContent 只放文字;alt 屬性與 setAttribute 設定的是屬性值,值本身不會再被解析成標記。在瀏覽器實測:同一個含 <u> 的字串,只有 C 會建立出 u 元素。

      Q5.執行後印出什麼?

      from markupsafe import Markup
      print(Markup("<b>{}</b>").format("<i>"))
      Markup 包住的外框是程式自己寫的可信 HTML,保持原樣;format() 的參數則會先被逸出,所以得到 <b>&lt;i&gt;</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=/
      HttpOnly 讓 document.cookie 讀不到這個 Cookie,降低登入憑證被偷走的風險,但它不阻止 XSS 本身,所以是第二道防線(A)。D 是 Secure 屬性的效果,這裡沒有設定;HttpOnly 也不會加密內容。

      🎯 重點整理

      1. XSS 的成因是資料和 HTML 標記混在同一份文件:使用者輸入被直接放進頁面時,瀏覽器會把其中的標籤當真,輸入裡若含有程式碼也一樣會被執行。
      2. 根本修正是輸出編碼:在放進頁面的那一刻把 & < > " ' 換成實體,伺服器用 html.escape 或模板自動逸出,前端用 textContent。資料庫存原文,出口才編碼。
      3. 位置不同規則不同:屬性一律加引號,網址參數用 urllib.parse.quote,完整網址先檢查只允許 http/https,script 區塊用 |tojson 或改放 data- 屬性。
      4. Jinja2 的 Environment() 預設不逸出;|safe、Markup(f"…")、mark_safe、innerHTML、dangerouslySetInnerHTML 都是手動關掉保護,只能用在程式自己產生或已淨化的內容。
      5. 需要保留格式(富文字、Markdown、LLM 回覆)時用 nh3 或 DOMPurify 淨化;CSP 與 HttpOnly Cookie 是第二道防線,不能取代編碼。