logo

Go 中预防 CSRF 的现代方法

Published on

Go 1.25 引入了一个新的 http.CrossOriginProtection 中间件到标准库中——这让我想到:

我们是否终于到了可以在不依赖基于令牌的检查(如双重提交 cookie)的情况下防止 CSRF 攻击的时候?我们能否在不引入第三方包如 justinas/nosurfgorilla/csrf 的情况下构建安全的 web 应用程序?

我认为现在的答案可能是谨慎的"是"——只要满足几个重要条件。

如果你想跳过解释,直接看这些条件是什么,可以点击这里

http.CrossOriginProtection 中间件

新的 http.CrossOriginProtection 中间件通过检查请求的 Sec-Fetch-SiteOrigin 头部的值来判断请求来自哪里。它会自动拒绝任何来自不同源的非安全请求,并向客户端发送 403 Forbidden 响应。

http.CrossOriginProtection 中间件有一些限制,我们稍后会讨论,但它是强大且简单易用的,是标准库的一个很好的补充。

工作原理

现代浏览器会在请求中自动包含 Sec-Fetch-Site 头部。这个头部指示发起请求的页面源和被请求页面源之间的关系。如果两个页面的协议主机名端口(如果存在)完全匹配,则认为它们具有相同的源,在这种情况下,浏览器将在请求中包含 Sec-Fetch-Site: same-origin 头部。如果两个页面没有相同的源,Sec-Fetch-Site 头部将被设置为不同的值来表示这一点,http.CrossOriginProtection 将拒绝该请求。

如果没有 Sec-Fetch-Site 头部,http.CrossOriginProtection 将回退到检查 Origin 头部。具体来说,它将比较请求的 Origin 头部和 Host 头部,看它们是否匹配。如果不匹配,那么它认为请求不是来自同一源,将拒绝它。

如果 Sec-Fetch-SiteOrigin 头部都不存在,那么它假设请求不是来自 web 浏览器,总是允许请求继续。

上述检查只在具有非安全方法(POSTPUT 等)的请求上进行。具有安全 HTTP 方法(GETOPTIONS 等)的请求总是被允许继续。

如果你有兴趣了解更多关于 http.CrossOriginProtection 背后的设计和决策制定,Filippo Valsorda 的原始提案是一个很好的阅读材料。

在最简单的情况下,你可以这样使用它:

package main

import (
	"fmt"
	"log/slog"
	"net/http"
	"os"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", home)

	slog.Info("starting server on :4000")

	// 用 http.NewCrossOriginProtection 中间件包装 mux
	err := http.ListenAndServe(":4000", http.NewCrossOriginProtection().Handler(mux))
	if err != nil {
		slog.Error(err.Error())
		os.Exit(1)
	}
}

func home(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello!")
}

如果需要,还可以配置 http.CrossOriginProtection 的行为。配置选项包括能够添加受信任的源(允许来自这些源的跨源请求),以及使用自定义处理程序处理被拒绝的请求,而不是默认的 403 Forbidden 响应。

当我想要自定义行为时,我一直在使用这样的模式:

package main

import (
	"fmt"
	"log/slog"
	"net/http"
	"os"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", home)

	slog.Info("starting server on :4000")

	err := http.ListenAndServe(":4000", preventCSRF(mux))
	if err != nil {
		slog.Error(err.Error())
		os.Exit(1)
	}
}

func preventCSRF(next http.Handler) http.Handler {
	cop := http.NewCrossOriginProtection()

	cop.AddTrustedOrigin("https://foo.example.com")

	cop.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("CSRF check failed"))
	}))

	return cop.Handler(next)
}

func home(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello!")
}

限制

http.CrossOriginProtection 的主要限制是它只能有效阻止来自现代浏览器的请求。你的应用程序仍然容易受到来自旧版(通常是 2020 年之前)浏览器的 CSRF 攻击,这些浏览器在请求中不包含 Sec-Fetch-SiteOrigin 头部中的至少一个。

目前,浏览器对 Sec-Fetch-Site 头部的支持率为 92%,对 Origin 的支持率为 95%。所以——总的来说——仅仅依赖 http.CrossOriginProtection 作为你对 CSRF 的唯一防护是不够的

还需要注意的是,Sec-Fetch-Site 头部只在你的应用程序具有"可信源"时才会发送——这基本上意味着你的应用程序需要在生产环境中使用 HTTPS(或在开发期间使用 localhost)才能让 http.CrossOriginProtection 发挥其全部潜力。

你还应该了解,当请求中没有 Sec-Fetch-Site 头部时,它会回退到比较 OriginHost 头部,Host 头部不包含协议。这个限制意味着当没有 Sec-Fetch-Site 头部但有 Origin 头部时,http.CrossOriginProtection 会错误地允许从 http://{host}https://{host} 的跨源请求。为了减轻这种风险,你应该理想地配置你的应用程序使用 HTTP 严格传输安全 (HSTS)

强制使用 TLS 1.3

探索这个问题让我想到...如果你已经计划使用 HTTPS 并强制使用 TLS 1.3 作为最低支持的 TLS 版本会怎样?你能够确信所有支持 TLS 1.3 的 web 浏览器也支持 Sec-Fetch-SiteOrigin 头部吗?

据我从 MDN 兼容性数据Can I Use 的表格中了解,对于(几乎)所有主要浏览器,答案是"是"的。

如果你强制使用 TLS 1.3 作为最低版本:

  • 不支持 TLS 1.3 的旧浏览器根本无法连接到你的应用程序。
  • 对于支持 TLS 1.3 并能连接的现代主要浏览器,你可以确信至少支持 Sec-Fetch-SiteOrigin 头部中的一个——因此 http.CrossOriginProtection 将有效工作。

我能看到的唯一例外是 Firefox v60-69(2018-2019),它不支持 Sec-Fetch-Site 头部,并且不为 POST 请求发送 Origin 头部。这意味着 http.CrossOriginProtection 无法有效阻止来自该浏览器的请求。Can I Use 将 Firefox v60-69 的使用率标记为 0%,所以这里的风险似乎很低——但世界上某些地方可能仍有一些计算机在运行它。

另外,我们只有主要浏览器的信息——Chrome/Chromium、Firefox、Edge、Safari、Opera 和 Internet Explorer。但当然,其他浏览器也存在。它们大多数是 Chromium 或 Firefox 的分支,因此可能没问题,但这里没有保证,很难量化风险。

所以如果你使用 HTTPS 并强制使用 TLS 1.3,这是确保 http.CrossOriginProtection 能够有效工作的一大步。但是,来自 Firefox v60-69 和非主要浏览器的风险仍然存在,所以你可能想要添加一些深度防御并同时利用 SameSite cookie。

我们稍后会讨论更多关于 SameSite cookie 的内容,但首先我们需要快速讨论一下术语站点之间的区别。

跨站点与跨源

在 web 规范和 web 浏览器的世界中,跨站点跨源是微妙不同的事物,在像这样的安全上下文中,理解差异并准确了解我们的意思是重要的。

我会快速解释。

如果两个网站共享完全相同的协议、主机名和端口(如果存在),则它们具有相同的源。所以 https://example.comhttps://www.example.com 不是相同的源,因为主机名(example.comwww.example.com)不同。它们之间的请求将是跨源的。

如果两个网站共享相同的协议和可注册域名,则它们是"同站点"的。

注意: 可注册域名是主机名中位于(并包括)有效顶级域名之前的部分。以下是一些例子:

  • 对于 https://www.google.com/,TLD 是 com,可注册域名是 google.com
  • 对于 https://login.mail.ucla.edu,TLD 是 edu,可注册域名是 ucla.edu
  • 对于 https://www.gov.uk,TLD 是 gov.uk,可注册域名是 www.gov.uk

你可以在这里找到有效 TLD 的完整列表。

所以 https://example.comhttps://www.example.comhttps://login.admin.example.com 都被认为是同一站点,因为协议(https)和可注册域名(example.com)是相同的。这些之间的请求不会被认为是跨站点的,但会是跨源的。

注意: 一些浏览器版本使用不同的同站点定义,不要求相同的协议,只要求相同的可注册域名。对于这些浏览器版本,https://admin.example.comhttp://blog.example.com 也会被认为是同站点的。

现在,这通常被称为无协议同站点,但在历史版本或文档中,它可能只是被称为同站点

那么我在这里要说明的要点是什么?

  1. Go 的 http.CrossOriginProtection 中间件的命名是准确和恰当的。它阻止跨源请求。它比仅阻止跨站点请求更严格,因为它还阻止来自同一站点下其他源的请求(即可注册域名)。

    这很有用,因为它有助于防止你在 https://blog.example.com 的破旧的十年未更新的 WordPress 博客被攻击并用于向你重要的 https://admin.example.com 网站发起请求伪造攻击的情况。

  2. 当大多数人——包括我自己——随意谈论"CSRF 攻击"时,我们大部分时间实际上指的是跨源请求伪造,而不仅仅是跨站点请求伪造。很遗憾 CSRF 是用来描述这类攻击的常用和已知缩写,因为大多数时候 CORF 会更准确和恰当。但是,嘿!这就是我们生活的混乱世界。

    不过,在这篇文章的其余部分,当我确切指的是这个意思时,我将使用术语 CORF 而不是 CSRF。

SameSite cookie 属性通常自 2017 年以来就得到 web 浏览器的支持,Go 自 v1.11 以来就支持它。如果你在 cookie 上设置 SameSite=LaxSameSite=Strict 属性,该 cookie 将只包含在对设置它的同一站点的请求中。反过来,这防止了跨站点请求伪造攻击(但不防止同一站点内的跨源攻击)。

这里有一些好消息——所有支持 TLS 1.3 的主要浏览器也完全支持 SameSite cookie,我看不到任何例外。所以如果你强制使用 TLS 1.3,你可以确信所有使用你应用程序的主要浏览器都会尊重 SameSite 属性。

这意味着通过在你的 cookie 上使用 SameSite=LaxSameSite=Strict,你覆盖了我们之前讨论的来自 Firefox v60-69 的跨站点请求伪造风险。

综合起来

如果你结合使用 HTTPS,强制使用 TLS 1.3 作为最低版本,恰当地使用 SameSite=LaxSameSite=Strict cookie,以及在你的应用程序中使用 http.CrossOriginProtection 中间件,据我所知,来自主要浏览器的未缓解 CSRF/CORF 风险只有两个:

  1. Firefox v60-69 中来自同一站点内的 CORF 攻击(即来自你可注册域名下的另一个子域名)。
  2. 来自不支持 Sec-Fetch-Site 头部的浏览器的你源的 HTTP 版本的 CORF 攻击。

对于第一个风险,如果你在可注册域名下没有其他网站,或者你确信这些网站是安全且未被攻击的,那么考虑到 Firefox v60-69 的使用率极低,这可能是你愿意接受的风险。

对于第二个,如果你的源完全不支持 HTTP(包括重定向),那么这不是你需要担心的事情。否则,你可以通过在你的 HTTPS 响应中包含 HSTS 头部来减轻风险。

在这篇文章的开头,我说不使用基于令牌的 CSRF 检查在某些条件下可能是可以的。所以让我们回顾一下这些条件是什么:

  1. 你的应用程序使用 HTTPS 并强制使用 TLS 1.3 作为最低版本。你接受使用旧浏览器的用户根本无法连接到你的应用程序。
  2. 你遵循良好实践,永远不会响应使用安全方法 GETHEADOPTIONSTRACE 的请求来更改重要的应用程序状态。
  3. 同时使用 http.CrossOriginProtection 中间件和 SameSite=LaxSameSite=Strict cookie。继续使用 SameSite cookie 对于一般的深度防御很重要,但更具体地是为了减轻来自 Firefox v60-69 的 CSRF 攻击。
  4. 由于来自 Firefox v60-69 的同站点 CORF 攻击的未防护风险,你要么在可注册域名下没有其他网站,要么你确信它们是安全且未被攻击的。
  5. 要么你的应用程序源完全没有 HTTP 版本,要么你在你的 HTTPS 响应中包含 HSTS 头部。
  6. 最后,你愿意接受来自支持 TLS 1.3 但不支持 OriginSec-Fetch-Site 头部或 SameSite cookie 的非主要浏览器的难以量化的 CSRF/CORF 攻击风险。这样的浏览器存在吗?我不知道,而且我不确定有办法 100% 确信地回答这个问题。所以你需要在这里进行自己的风险评估,如果你的应用程序是低价值目标且成功的 CSRF/CORF 攻击的影响既孤立又轻微,你可能只想接受这种风险。