1

「Go工具箱」go语言csrf库的使用方式和实现原理

 1 year ago
source link: https://studygolang.com/articles/35927
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

上帝只垂青主动的人 --- 吴军 《格局》

大家好,我是渔夫子。本号新推出「Go工具箱」系列,意在给大家分享使用go语言编写的、实用的、好玩的工具。

今天给大家推荐的是web应用安全防护方面的一个包:csrf。该包为Go web应用中常见的跨站请求伪造(CSRF)攻击提供预防功能。

csrf小档案

「csrf小档案」
star 837 used by -
contributors 25 作者 Gorilla
功能简介 为Go web应用程序和服务提供跨站点请求伪造(csrf)预防功能。可作为gin、echo等主流框架的中间件使用。
项目地址 https://github.com/gorilla/csrf
相关知识 跨站请求伪造(CSRF)、contex.Contex、异或操作

第一部分 CSRF相关知识

一、CSRF及其实现原理

CSRF是CROSS Site Request Forgy的缩写,即跨站请求伪造。我们看下他的攻击原理。如下图:

算法反作弊系统流程图-csrf.png

当用户访问一个网站的时候,第一次登录完成后,网站会将验证的相关信息保存在浏览器的cookie中。在对该网站的后续访问中,浏览器会自动携带该站点下的cookie信息,以便服务器校验认证信息。

因此,当服务器经过用户认证之后,服务器对后续的请求就只认cookie中的认证信息,不再区分请求的来源了。那么,攻击者就可以模拟一个正常的请求来做一些影响正常用户利益的事情(比如对于银行来说可以把用户的钱转账到攻击者账户中。或获取用户的敏感、重要的信息等)

相关知识:因为登录信息是基于session-cookie的。浏览器在访问网站时会自动发送该网站的cookie信息,网站只要能识别cookie中的信息,就会认为是认证已通过,而不会区分该请求的来源的。所以给攻击者创造了攻击的机会。

CSRF攻击示例

假设有一个银行网站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种方法:

一种是在网站中增加对请求来源的验证,比如在请求头中增加REFFER信息。一种是在浏览器中启用SameSite策略。该策略是告诉浏览器,只有请求来源是同网站的才能发送cookie,跨站的请求不要发送cookie。但这种也有漏洞,就是依赖于浏览器是否支持这种策略。一种是使用Token信息。由网站自己决定token的生成策略以及对token的验证。

其中使用Token信息这种是三种方法中最安全的一种。接下来我们就看看今天要推荐的CSRF包是如何利用token进行预防的。

第二部分 CSRF包的使用及其实现原理

三、CSRF包的使用及实现原理

csrf包的安装

go get github.com/gorilla/csrf

该包主要包括三个功能:

通过csrf.Protect函数生成一个csrf中间件或请求处理器,用于后续的生成及校验token的流程。通过csrf.Token函数,可以在响应中输出当前生成的token值。通过csrf.TemplateField函数,可以在html模版中输出一个hidden的input,用于在form表单中提交token。

该包的使用很简单。首先通过csrf.Protect函数生成一个中间件或请求处理器,然后在启动web server时对真实的请求处理器进行包装。

我们来看下该包和主流web框架结合使用的实例。

使用net/http包启动的服务

package main

import (
 "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")
}

echo框架下使用csrf包

package main

import (
 "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)
}

gin框架下使用csrf包

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")
}

beego框架下使用csrf包

package main

import (
 "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结构体

该包的实现是基于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这个结构体的成员:

「h」:是一个http.Handler,作为实际处理请求的处理器。该h的来源是经Protect函数返回值包装后的,即开始示例中CSRF(muxServer)中的muxServer。又因为csrf也是一个请求处理器,请求就会先执行csrf的ServeHTTP方法的逻辑,如果通过了,再执行h的ServeHTTP逻辑。「sc」:类型是securecookie.SecureCookie,第三方包,该包的作用是对cookie的值进行加密/解密。在调用csrf.Protect方法时,传递的第一个32字节长的参数就是用于该包进行对称加密用的秘钥。下一篇文章我们会详细介绍该包是如何实现对cookie内容进行/加解密的。「st」:类型是store,是csrf包中定义的一个接口类型。该属性的作用是将token存储在什么地方。默认是使用cookieStore类型。即将token存储在cookie中。「opts」:Options属性,用于设置csrf的选项的。比如token存储在cookie中的名字,token在表单中的名字等。

这里大家可能有这样一个疑问:csrf攻击就是基于cookie来进行攻击的,为什么还要把token存储在cookie中呢?在一次请求中,会有两个地方存储token:一个是cookie中,一个是请求体中(query中,header中,或form中),当服务端收到请求时,会同时取出这两个地方的token,进而进行比较。所以如果攻击者伪造了一个请求,服务器能接收到cookie中的token,但不能接收到请求体中的token,所以伪造的攻击还是无效的。

csrf包的工作流程

在开始的“使用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包的工作流程如下: 算法反作弊系统流程图-请求处理流程.png

大致了解了csrf的工作流程后,我们再来分析各个环节的实现。

「生成唯一的token」

在该包中生成随机、唯一的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之后,token会存储在两个位置:

一个是随响应将token写入cookie中。在cookie中的token将用于下次请求的基准token和请求中携带的token进行比较。该实现是通过csrf中的cookieStore来存储到cookie中的(store类型)。在cookie中name默认是 _gorilla_csrf。同时,通过cookieStore类型存储到cookie的值是经过加密的,加密使用的是securecookie.SecureCookie包一个是存储在请求的上下文中。存在这里的token是原始token经过转码的,会随着响应下发给客户端,以便下次请求时随请求体一起发送。该实现是通过context.ValueContext存储在请求的上下文中。

生成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编码过程.png

假设一个token是32位的字节,那么最终的maskToken由64位组成。前32位是otp的随机值,后32位是异或之后的token。两个组合起来就是最终的maskToken。如下图: 算法反作弊系统流程图-masktoken的组成.png 这里利用了异或操作的原理来进行转码和解码。我们假设 A ^ B = C。那么会有 A = C ^ B

所以,要想还原异或前的真实token值,则从maskToken中取出前32个字节和后32字节,再进行异或操作就能得到真实的token了。然后就可以和cookie中存储的真实的token进行比较了。同时因为经过异或转码的token,攻击者想要进行伪造就很难了。

「输出token」

在上述我们已经知道经过异或操作对原始token进行了转码,我们叫做maskToken。该token要下发给客户端(HEADER、form或其他位置)。那么,客户端用什么字段来接收呢?

默认情况下,maskToken是存储在以下位置的:

若在HEADER头中,则保存在名为 X-CSRF-Token 的字段中。若在form表单,则保存在名为 gorilla.csrf.Token 的input中。

当然,我们在初始化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也就失效了。

为什么GET、HEAD、OPTIONS、TRACE的请求方法不需要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的方式进行预防的。

---特别推荐---

特别推荐:一个专注go项目实战、项目中踩坑经验及避坑指南、各种好玩的go工具的公众号。「Go学堂」,专注实用性,非常值得大家关注。点击下方公众号卡片,直接关注。关注送《100个go常见的错误》pdf文档。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK