go 語言 csrf 庫的使用方式和實現原理
上帝只垂青主動的人 --- 吳軍 《格局》
大家好,我是漁夫子。本號新推出「Go 工具箱」系列,意在給大家分享使用 go 語言編寫的、實用的、好玩的工具。
今天給大家推薦的是 web 應用安全防護方面的一個包:csrf。該包爲 Go web 應用中常見的跨站請求僞造(CSRF)攻擊提供預防功能。
csrf 小檔案
第一部分 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" />
<input type="hidden" />
</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 包的工作流程如下:
大致瞭解了 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。如下圖: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 的方式進行預防的。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/lrMJYBbNwisE-c19upq7nA