今天给大家推荐的是web应用安全防护方面的一个包:csrf。该包为Go web应用中常见的跨站请求伪造(CSRF)攻击提供预防功能。
「csrf小档案」 | |||
---|---|---|---|
star | 837 | used by | - |
contributors | 25 | 作者 | Gorilla |
功能简介 | 为Go web应用程序和服务提供跨站点请求伪造(csrf)预防功能。可作为gin、echo等主流框架的中间件使用。 | ||
项目地址 | https://github.com/gorilla/csrf | ||
相关知识 | 跨站请求伪造(CSRF)、contex.Contex、异或操作 |
CSRF是CROSS Site Request Forgy的缩写,即跨站请求伪造。我们看下他的攻击原理。如下图:
当用户访问一个网站的时候,第一次登录完成后,网站会将验证的相关信息保存在浏览器的cookie中。在对该网站的后续访问中,浏览器会自动携带该站点下的cookie信息,以便服务器校验认证信息。
因此,当服务器经过用户认证之后,服务器对后续的请求就只认cookie中的认证信息,不再区分请求的来源了。那么,攻击者就可以模拟一个正常的请求来做一些影响正常用户利益的事情(比如对于银行来说可以把用户的钱转账到攻击者账户中。或获取用户的敏感、重要的信息等)
❝相关知识:因为登录信息是基于session-cookie的。浏览器在访问网站时会自动发送该网站的cookie信息,网站只要能识别cookie中的信息,就会认为是认证已通过,而不会区分该请求的来源的。所以给攻击者创造了攻击的机会。
❞
假设有一个银行网站A,下面的是一个转给账户5000元的请求,使用Get方法
GET https://abank.com/transfer.do?account=RandPerson&amount=$5000 HTTP/1.1
然后,攻击者修改了该请求中的参数,将收款账户更改成了自己的,如下:
GET https://abank.com/transfer.do?account=SomeAttacker&amount=$5000 HTTP/1.1
然后,攻击者将该请求地址放入到一个标签中:
<a href="https://abank.com/transfer.do?account=SomeAttacker&amount=$5000">Click for more information</a>
最后,攻击者会以各种方式(放到自己的网站中、email、社交通讯工具等)引诱用户点击该链接。只要是用户点击了该链接,并且在之前已经登录了该网站,那么浏览器就会将带认证信息的cookie自动发送给该网站,网站认为这是一个正常的请求,由此,将给黑客转账5000元。造成合法用户的损失。
当然,如果是post表单形式,那么攻击者会将伪造的链接放到form表达中,并用js的方法让表单自动发送:
<body onload="document.forms[0].submit()>
<form id=”csrf” action="https://abank.com/transfer.do" method="POST">
<input type="hidden" name="account" value="SomeAttacker"/>
<input type="hidden" name="amount" value="$5000"/>
</form>
</body><script>
document.getElementById('csrf').submit();
</script>
常见的有3种方法:
其中使用Token信息这种是三种方法中最安全的一种。接下来我们就看看今天要推荐的CSRF包是如何利用token进行预防的。
go get github.com/gorilla/csrf
该包主要包括三个功能:
该包的使用很简单。首先通过csrf.Protect函数生成一个中间件或请求处理器,然后在启动web server时对真实的请求处理器进行包装。
我们来看下该包和主流web框架结合使用的实例。
package mainimport (
"fmt"
"github.com/gorilla/csrf"
"net/http"
)
func main() {
muxServer := http.NewServeMux()
muxServer.HandleFunc("/", IndexHandler)
CSRF := csrf.Protect([]byte("32-byte-long-auth-key"))
http.ListenAndServe(":8000", CSRF(muxServer))
}
func IndexHandler(w http.ResponseWriter, r *http.Request) {
// 获取token值
token := csrf.Token(r)
// 将token写入到header中
w.Header().Set("X-CSRF-Token", token)
fmt.Fprintln(w, "hello world.Go")
}
package mainimport (
"github.com/gorilla/csrf"
"net/http"
"github.com/labstack/echo"
)
func main() {
e := echo.New()
e.POST("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Hello, World!")
})
// 使用自定义的CSRF中间件
e.Use(CSRFMiddle())
e.Logger.Fatal(e.Start(":8080"))
}
// 自定义CSRF中间件
func CSRFMiddle() echo.MiddlewareFunc {
csrfMiddleware := csrf.Protect([]byte("32-byte-long-auth-key"))
// 这里使用echo的WrapMiddleware函数将csrfMiddleware转换成echo的中间件返回值
return echo.WrapMiddleware(csrfMiddleware)
}
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gorilla/csrf"
adapter "github.com/gwatts/gin-adapter"
)// 定义中间件
func CSRFMiddle() gin.HandlerFunc {
csrfMiddleware := csrf.Protect([]byte("32-byte-long-auth-key"))
// 这里使用adpater包将csrfMiddleware转换成gin的中间件返回值
return adapter.Wrap(csrfMiddleware)
}
func main() {
r := gin.New()
// 在路由中使用中间件
r.Use(CSRFMiddle())
// 定义路由
r.POST("/", IndexHandler)
// 启动http服务
r.Run(":8080")
}
func IndexHandler(ctx *gin.Context) {
ctx.String(200, "hello world")
}
package mainimport (
"github.com/beego/beego"
"github.com/gorilla/csrf"
)
func main() {
beego.Router("/", &MainController{})
beego.RunWithMiddleWares(":8080", CSRFMiddle())
}
type MainController struct {
beego.Controller
}
func (this *MainController) Get() {
this.Ctx.Output.Body([]byte("Hello World"))
}
func CSRFMiddle() beego.MiddleWare {
csrfMiddleware := csrf.Protect([]byte("32-byte-long-auth-key"))
// 这里使用adpater包将csrfMiddleware转换成gin的中间件返回值
return csrfMiddleware
}
实际上,要通过token预防CSRF主要做以下3件事情:每次生成一个唯一的token、将token写入到cookie同时下发给客户端、校验token。接下来我们就来看看csrf包是如何实现如上步骤的。
该包的实现是基于csrf这样一个结构体:
type csrf struct {
h http.Handler
sc *securecookie.SecureCookie
st store
opts options
}
该结构体同时实现了一个ServeHTTP方法:
func (cs *csrf) ServeHTTP(w http.ResponseWriter, r *http.Request)
在Go中,我们知道ServeHTTP是在内建包net/http中定义的一个请求处理器的接口:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
凡是实现了该接口的结构体就能作为请求的处理器。在go的所有web框架中,处理器本质上也都是基于该接口实现的。
好了,现在我们来分析下csrf这个结构体的成员:
这里大家可能有这样一个疑问:csrf攻击就是基于cookie来进行攻击的,为什么还要把token存储在cookie中呢?在一次请求中,会有两个地方存储token:一个是cookie中,一个是请求体中(query中,header中,或form中),当服务端收到请求时,会同时取出这两个地方的token,进而进行比较。所以如果攻击者伪造了一个请求,服务器能接收到cookie中的token,但不能接收到请求体中的token,所以伪造的攻击还是无效的。
在开始的“使用net/http包启动的服务”示例中,我们先调用了Protect方法,然后又用返回值对muxServer进行了包装。大家是不是有点云里雾里,为什么要这么调用呢?接下来咱们就来分析下Protect这个函数以及csrf包的工作流程。
在使用csrf的时候,首先要调用的就是Protect函数。Protect的定义如下:
func Protect(authKey []byte, opts ...Option) func(http.Handler) http.Handler
该函数接收一个秘钥和一个选项切片参数。返回值是一个函数类型:func(http.Handler) http.Handler。实际的执行逻辑是在返回的函数中。如下:
CSRF := csrf.Protect([]byte("32-byte-long-auth-key"))http.ListenAndServe(":8000", CSRF(muxServer))
// Protect源码
func Protect(authKey []byte, opts ...Option) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
cs := parseOptions(h, opts...)
// Set the defaults if no options have been specified
if cs.opts.ErrorHandler == nil {
cs.opts.ErrorHandler = http.HandlerFunc(unauthorizedHandler)
}
if cs.opts.MaxAge < 0 {
// Default of 12 hours
cs.opts.MaxAge = defaultAge
}
if cs.opts.FieldName == "" {
cs.opts.FieldName = fieldName
}
if cs.opts.CookieName == "" {
cs.opts.CookieName = cookieName
}
if cs.opts.RequestHeader == "" {
cs.opts.RequestHeader = headerName
}
// Create an authenticated securecookie instance.
if cs.sc == nil {
cs.sc = securecookie.New(authKey, nil)
// Use JSON serialization (faster than one-off gob encoding)
cs.sc.SetSerializer(securecookie.JSONEncoder{})
// Set the MaxAge of the underlying securecookie.
cs.sc.MaxAge(cs.opts.MaxAge)
}
if cs.st == nil {
// Default to the cookieStore
cs.st = &cookieStore{
name: cs.opts.CookieName,
maxAge: cs.opts.MaxAge,
secure: cs.opts.Secure,
httpOnly: cs.opts.HttpOnly,
sameSite: cs.opts.SameSite,
path: cs.opts.Path,
domain: cs.opts.Domain,
sc: cs.sc,
}
}
return cs
}
}
Protect的实现源码起始很简单,就是在一个闭包中初始化了一个csrf结构体。示例中CSRF就是返回来的func(http.Handler) http.Handler
函数。再调用CSRF(muxServer),执行初始化csrf结构体的实例,同时将muxServer包装到csrf结构体的h属性上,最后将该csrf结构体对象返回。因为csrf结构体也实现了ServeHTTP接口,所以csrf自然也就是可以处理请求的http.Handler类型了。
当一个请求来了之后,先执行csrf结构体中的ServeHTTP方法,然后再执行实际的http.Handler。以最开始的请求为例,csrf包的工作流程如下:
大致了解了csrf的工作流程后,我们再来分析各个环节的实现。
在该包中生成随机、唯一的token是通过随机数来生成的。主要生成逻辑如下:
func generateRandomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
// err == nil only if len(b) == n
if err != nil {
return nil, err
} return b, nil
}
crypto/rand包中的rand.Read函数可以随机生成指定字节个数的随机数。但这里出的随机数是字节值,如果序列化成字符串则会是乱码。那如何将字节序列序列化成可见的字符编码呢?那就是对字节进行编码。这里使用的是标准库中的encoding/json包。该包能够对各种类型进行可视化编码。如果对字节序列进行编码,本质上是使用了base64的标准编码。如下:
realToken := generateRandomBytes(32)//编码后,encodeToken是base64编码的字符串
encodeToken := json.Encode(realToken)
生成token之后,token会存储在两个位置:
_gorilla_csrf
。同时,通过cookieStore类型存储到cookie的值是经过加密的,加密使用的是securecookie.SecureCookie包生成token后为什么要存在cookie中呢?CSRF的攻击原理不就是基于浏览器自动发送cookie造成的吗?攻击者伪造的请求还是会直接从cookie中获取token,附带在请求中不就行了吗?答案是否定的。在请求中保存的token,是经过转码后的,跟cookie中的token不一样。在收到请求时,再对token进行解码,然后再和cookie中的token进行比较。看下下面的实现:
func mask(realToken []byte, r *http.Request) string {
otp, err := generateRandomBytes(tokenLength)
if err != nil {
return ""
} // XOR the OTP with the real token to generate a masked token. Append the
// OTP to the front of the masked token to allow unmasking in the subsequent
// request.
return base64.StdEncoding.EncodeToString(append(otp, xorToken(otp, realToken)...))
}
这里我们看到,先生成一个和token一样长度的随机值otp,然后让实际的realToken和opt通过xorToken进行异或操作,将异或操作的结果放到随机值的末尾,然后再进行base64编码产生的。
假设一个token是32位的字节,那么最终的maskToken由64位组成。前32位是otp的随机值,后32位是异或之后的token。两个组合起来就是最终的maskToken。如下图:这里利用了异或操作的原理来进行转码和解码。我们假设 A ^ B = C
。那么会有 A = C ^ B
所以,要想还原异或前的真实token值,则从maskToken中取出前32个字节和后32字节,再进行异或操作就能得到真实的token了。然后就可以和cookie中存储的真实的token进行比较了。同时因为经过异或转码的token,攻击者想要进行伪造就很难了。
在上述我们已经知道经过异或操作对原始token进行了转码,我们叫做maskToken。该token要下发给客户端(HEADER、form或其他位置)。那么,客户端用什么字段来接收呢?
默认情况下,maskToken是存储在以下位置的:
当然,我们在初始化csrf的实例时,可以指定保存的位置。例如,我们指定HEADER头中的字段名为 X-CSRF-Token-Request中,则可以使用如下代码:
csrf.Protect([]byte("32-byte-long-auth-key"),
RequestHeader("X-CSRF-Token-Request"))
csrf中可以指定的选项如下:
RequestHeader选项函数:指定在HEADER中存储token的字段名称。
FieldName选项函数:指定form表中存储token的input的name
MaxAge选项函数:指定cookie中值的有效期
Domain选项函数:指定cookie的存储域名
Path选项函数:指定cookie的存储路径
HttpOnly选项函数:指定cookie的值只能在服务端设置,禁止在客户端使用javascript修改
SameSite选项函数:指定cookie的SameSite属性
ErrorHandler选项函数:指定当token校验不通过或生成token失败时的错误响应的handler
「更新token」
在调用csrf.ServeHTTP函数中,每次都会生成一个新的token,存储在对应的位置上,同时下发给客户端,以便该请求的后续请求携带token值给服务端进行验证。所以,该请求之前的token也就失效了。
在csrf包中,我们还看到有这么一段判断逻辑:
// Idempotent (safe) methods as defined by RFC7231 section 4.2.2.
safeMethods = []string{"GET", "HEAD", "OPTIONS", "TRACE"}if !contains(safeMethods, r.Method) {
//这里进行token的校验
}
为什么GET、HEAD、OPTIONS、TRACE方法的请求不需求token验证呢?因为根据RFC7231文档的规定,这些方法的请求本质上是一种 幂等 的访问方法,这说明开发web的时候g这些请求不应该用于修改数据库状态,而只作为一个请求访问或者链接跳转。通俗地讲,发送一个GET请求不应该引起任何数据状态的改变。用于修改状态更加合适的是post方法,特别是对用户信息状态改变的情况。
所以,如果严格按照RFC的规定来开发的话,这些请求不应该修改数据,而只是获取数据。获取数据对于攻击者来说也没实际价值。
CSRF攻击是基于将验证信息存储于cookie中,同时浏览器在发送请求时会自动携带cookie的原理进行的。所以,其预防原理也就是验证请求来源的真实性。csrf包就是利用了token校验的原理,让前后连续的请求签发token、下次请求验证token的方式进行预防的。
推荐阅读