Go 中预防 CSRF 的现代方法
- Published on
Go 1.25 引入了一个新的 http.CrossOriginProtection 中间件到标准库中——这让我想到:
我们是否终于到了可以在不依赖基于令牌的检查(如双重提交 cookie)的情况下防止 CSRF 攻击的时候?我们能否在不引入第三方包如 justinas/nosurf 或 gorilla/csrf 的情况下构建安全的 web 应用程序?
我认为现在的答案可能是谨慎的"是"——只要满足几个重要条件。
如果你想跳过解释,直接看这些条件是什么,可以点击这里。
http.CrossOriginProtection 中间件
新的 http.CrossOriginProtection 中间件通过检查请求的 Sec-Fetch-Site 和 Origin 头部的值来判断请求来自哪里。它会自动拒绝任何来自不同源的非安全请求,并向客户端发送 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-Site和Origin头部都不存在,那么它假设请求不是来自 web 浏览器,总是允许请求继续。上述检查只在具有非安全方法(
POST、PUT等)的请求上进行。具有安全 HTTP 方法(GET、OPTIONS等)的请求总是被允许继续。如果你有兴趣了解更多关于
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-Site 或 Origin 头部中的至少一个。
目前,浏览器对 Sec-Fetch-Site 头部的支持率为 92%,对 Origin 的支持率为 95%。所以——总的来说——仅仅依赖 http.CrossOriginProtection 作为你对 CSRF 的唯一防护是不够的。
还需要注意的是,Sec-Fetch-Site 头部只在你的应用程序具有"可信源"时才会发送——这基本上意味着你的应用程序需要在生产环境中使用 HTTPS(或在开发期间使用 localhost)才能让 http.CrossOriginProtection 发挥其全部潜力。
你还应该了解,当请求中没有 Sec-Fetch-Site 头部时,它会回退到比较 Origin 和 Host 头部,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-Site 或 Origin 头部吗?
据我从 MDN 兼容性数据和 Can I Use 的表格中了解,对于(几乎)所有主要浏览器,答案是"是"的。
如果你强制使用 TLS 1.3 作为最低版本:
- 不支持 TLS 1.3 的旧浏览器根本无法连接到你的应用程序。
- 对于支持 TLS 1.3 并能连接的现代主要浏览器,你可以确信至少支持
Sec-Fetch-Site或Origin头部中的一个——因此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.com 和 https://www.example.com 不是相同的源,因为主机名(example.com 和 www.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.com、https://www.example.com 和 https://login.admin.example.com 都被认为是同一站点,因为协议(https)和可注册域名(example.com)是相同的。这些之间的请求不会被认为是跨站点的,但会是跨源的。
注意: 一些浏览器版本使用不同的同站点定义,不要求相同的协议,只要求相同的可注册域名。对于这些浏览器版本,
https://admin.example.com和http://blog.example.com也会被认为是同站点的。现在,这通常被称为无协议同站点,但在历史版本或文档中,它可能只是被称为同站点。
那么我在这里要说明的要点是什么?
Go 的
http.CrossOriginProtection中间件的命名是准确和恰当的。它阻止跨源请求。它比仅阻止跨站点请求更严格,因为它还阻止来自同一站点下其他源的请求(即可注册域名)。这很有用,因为它有助于防止你在
https://blog.example.com的破旧的十年未更新的 WordPress 博客被攻击并用于向你重要的https://admin.example.com网站发起请求伪造攻击的情况。当大多数人——包括我自己——随意谈论"CSRF 攻击"时,我们大部分时间实际上指的是跨源请求伪造,而不仅仅是跨站点请求伪造。很遗憾 CSRF 是用来描述这类攻击的常用和已知缩写,因为大多数时候 CORF 会更准确和恰当。但是,嘿!这就是我们生活的混乱世界。
不过,在这篇文章的其余部分,当我确切指的是这个意思时,我将使用术语 CORF 而不是 CSRF。
SameSite Cookie
SameSite cookie 属性通常自 2017 年以来就得到 web 浏览器的支持,Go 自 v1.11 以来就支持它。如果你在 cookie 上设置 SameSite=Lax 或 SameSite=Strict 属性,该 cookie 将只包含在对设置它的同一站点的请求中。反过来,这防止了跨站点请求伪造攻击(但不防止同一站点内的跨源攻击)。
这里有一些好消息——所有支持 TLS 1.3 的主要浏览器也完全支持 SameSite cookie,我看不到任何例外。所以如果你强制使用 TLS 1.3,你可以确信所有使用你应用程序的主要浏览器都会尊重 SameSite 属性。
这意味着通过在你的 cookie 上使用 SameSite=Lax 或 SameSite=Strict,你覆盖了我们之前讨论的来自 Firefox v60-69 的跨站点请求伪造风险。
综合起来
如果你结合使用 HTTPS,强制使用 TLS 1.3 作为最低版本,恰当地使用 SameSite=Lax 或 SameSite=Strict cookie,以及在你的应用程序中使用 http.CrossOriginProtection 中间件,据我所知,来自主要浏览器的未缓解 CSRF/CORF 风险只有两个:
- Firefox v60-69 中来自同一站点内的 CORF 攻击(即来自你可注册域名下的另一个子域名)。
- 来自不支持
Sec-Fetch-Site头部的浏览器的你源的 HTTP 版本的 CORF 攻击。
对于第一个风险,如果你在可注册域名下没有其他网站,或者你确信这些网站是安全且未被攻击的,那么考虑到 Firefox v60-69 的使用率极低,这可能是你愿意接受的风险。
对于第二个,如果你的源完全不支持 HTTP(包括重定向),那么这不是你需要担心的事情。否则,你可以通过在你的 HTTPS 响应中包含 HSTS 头部来减轻风险。
在这篇文章的开头,我说不使用基于令牌的 CSRF 检查在某些条件下可能是可以的。所以让我们回顾一下这些条件是什么:
- 你的应用程序使用 HTTPS 并强制使用 TLS 1.3 作为最低版本。你接受使用旧浏览器的用户根本无法连接到你的应用程序。
- 你遵循良好实践,永远不会响应使用安全方法
GET、HEAD、OPTIONS或TRACE的请求来更改重要的应用程序状态。 - 你同时使用
http.CrossOriginProtection中间件和SameSite=Lax或SameSite=Strictcookie。继续使用SameSitecookie 对于一般的深度防御很重要,但更具体地是为了减轻来自 Firefox v60-69 的 CSRF 攻击。 - 由于来自 Firefox v60-69 的同站点 CORF 攻击的未防护风险,你要么在可注册域名下没有其他网站,要么你确信它们是安全且未被攻击的。
- 要么你的应用程序源完全没有 HTTP 版本,要么你在你的 HTTPS 响应中包含 HSTS 头部。
- 最后,你愿意接受来自支持 TLS 1.3 但不支持
Origin或Sec-Fetch-Site头部或SameSitecookie 的非主要浏览器的难以量化的 CSRF/CORF 攻击风险。这样的浏览器存在吗?我不知道,而且我不确定有办法 100% 确信地回答这个问题。所以你需要在这里进行自己的风险评估,如果你的应用程序是低价值目标且成功的 CSRF/CORF 攻击的影响既孤立又轻微,你可能只想接受这种风险。