淺談 CSRF 與 CSRF Middleware
Toby
Toby

最近在我的 Zoraxy 開源專案那邊出現了這樣的一個 bug issue

他還很好的附了一個網頁測試介面的截圖

甚麼是 CSRF 攻擊?

CSRF(Cross-Site Request Forgery,跨站請求偽造)是一種網絡攻擊技術,攻擊者通過在受害者不知情的情況下,冒充受害者的身份在受害者已經認證的網站上執行未經授權的操作。

CSRF 攻擊的工作原理

  1. 受害者登錄到可信網站:受害者首先登錄到一個需要認證的網站,在這例子是 Zoraxy 的後台管理頁面
  2. 攻擊者製造惡意請求:攻擊者創建一個包含惡意請求的網頁或電子郵件,例如說這裡的一個 free burger 網頁 html + web form (action 或 form submit URL 指向 Zoraxy 的後台管理頁面)
  3. 受害者訪問惡意鏈接:受害者在登錄狀態下 request 了攻擊者控制的網頁並提交了 web-form。
  4. 執行未經授權的操作:由於受害者已經登錄(Auth Cookie 會跟著 form 一起送出去),Zoraxy webmin 介面會以為這些 request 是 valid 的,並執行相應的操作,例如上面例子的 toggle proxy 開關狀態 。

防禦 CSRF 攻擊的方法

  1. CSRF Token:在每個受保護的操作請求中加入隨機生成的、唯一的 CSRF Token,並驗證這些 Token 以確保請求是合法的。
  2. 檢查 Referer 和 Origin 頭:檢查 HTTP 頭中的 Referer 和 Origin 字段,確保請求是從合法的來源發出的。但是我在寫的是 Reverse proxy,本來 Referer 跟 origin 就是不可信的,所以不能使用此方法。
  3. 雙重提交 Cookie:將 CSRF Token 存儲在 Cookie 中,並在請求中一起提交和驗證。
  4. 使用 SameSite Cookie 屬性:設置 Cookie 的 SameSite 屬性為 StrictLax,限制跨站請求攜帶 Cookie。

說真的這也不是我第一次處理 csrf 問題。先前同一位使用者也對 ArozOS 提出過類似的 issue report。然而 ArozOS 因為是使用 agile 開發的,API 散落得到處都是,根本沒辦法用標準的方式來做 csrf middleware 來保護 API 接口,所以當時只用了一個非常不標準的方式來做(就是每一個 post request 之前都加一個要求 csrf token 的 ajax request,變成每個 request 都會變成要 request 2 次)

csrf middleware

csrf middleware 是一個 http Handler 並讓它來對 request 進行預處理並對不合規格的 POST (PUT / DELETE) 等 request 進行攔阻。所以這裡有兩個需要做的事情

  1. 在 HTML 頁面生成的時候,在頁面上加入一個 csrf token
  2. 在有資料需要以 POST 等 request 回傳到伺服器時,在 payload 中以 header field 的方式附上頁面的 csrf token

在 ArozOS 的做法上,它是先對伺服器進行一個 csrf token generation request,然後再把 request 附到下一個 ajax POST request 裡面。這做法雖然不是不行,但是對於前端來說要改實在太複雜,而且一個 csrf token 又沒有那麼快 expire,不斷生的話只會浪費後端資源。

Production grade 的 csrf middleware 使用方法

由於 Zoraxy 的設計從一開始就按著標準的 monolithic architecture 設計,所以 api 也相對集中容易處理。我這裡使用的是 gorilla csrf middleware 的 package,首先第一件事要做的是先建立一個 csrf Protect 物件並設定好它的參數。以 Zoraxy 為例,因為 Zoraxy 的管理介面是用 http 的,因此我這裡的建立方式如下:

//Create a new webmin mux and csrf middleware layer
	webminPanelMux = http.NewServeMux()
	csrfMiddleware = csrf.Protect(
		[]byte(nodeUUID),
		csrf.CookieName("zoraxy-csrf"),
		csrf.Secure(false),
		csrf.Path("/"),
		csrf.SameSite(csrf.SameSiteLaxMode),
	)

webminPanelMux 是處理 webmin panel HTTP mux,是 net/http 內置的東西。而 nodeUUID 是一個 32 bit 的 random UUID,我這裡用的是一個隨機生成的 UUIDv4。

然後把 csrf Middleware 套進去 現有的 http 伺服器也很簡單,只要把它放到 ListenAndServe 裡面就好,例如說:

err = http.ListenAndServe(*webUIPort, csrfMiddleware(webminPanelMux))
if err != nil {
	log.Fatal(err)
}

這樣你的 web server 就具備了 csrf token 檢測功能了。然後就是前端的部分。前端由於我是使用純 javascript 與 jQuery 開發的(嘛,那時候想著能用就好沒想到現在這個專案長這麼大),所以前端要加 csrf 支援就略為麻煩一點。

Vailla JS + jQuery 前端加入 csrf token

首先,我要想個辦法把 csrf token inject 到前端去。這個我們可以透過 golang 的 http template 或是手動 template 的方式來做。在所有 HTML 檔案的 head 裡加入

<meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">

然後在後端的 File Server 裡面,在遇到副檔名是 .html 或是 / (index.html)的時候,對 html template 注入 csrf token 。


// Serve the html file with template token injected
func handleInjectHTML(w http.ResponseWriter, r *http.Request, relativeFilepath string) {
	// Read the HTML file
	var content []byte
	var err error
	if len(relativeFilepath) > 0 && relativeFilepath[len(relativeFilepath)-1:] == "/" {
		relativeFilepath = relativeFilepath + "index.html"
	}
	if development {
		//Load from disk
		targetFilePath := strings.ReplaceAll(filepath.Join("web/", relativeFilepath), "\\", "/")
		content, err = os.ReadFile(targetFilePath)
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}
	} else {
		//Load from embedded fs, require trimming off the prefix slash for relative path
		relativeFilepath = strings.TrimPrefix(relativeFilepath, "/")
		content, err = webres.ReadFile(relativeFilepath)
		if err != nil {
			SystemWideLogger.Println("load embedded web file failed: ", err)
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}
	}

	// Convert the file content to a string
	htmlContent := string(content)

	//Defeine the system template for this request
	templateStrings := map[string]string{
		".csrfToken": csrf.Token(r),
	}

	// Replace template tokens in the HTML content
	for key, value := range templateStrings {
		placeholder := "{{" + key + "}}"
		htmlContent = strings.ReplaceAll(htmlContent, placeholder, value)
	}

	// Write the modified HTML content to the response
	w.Header().Set("Content-Type", "text/html")
	w.Write([]byte(htmlContent))
}

這裡面的例子略為複雜一點,因為 Zoraxy release 版本是使用 go:embed 的方式來放檔案的,而 development 版本側是從硬碟讀取 html 檔案,所以多了一部分的 code 來選擇從哪邊讀取前端檔案。

在 html 檔案讀出來成為 byte[] 之後,進行 template string rewrite (暫時只 rewrite .csrfToken 的部分),之後再送出。

Post Request

當然要進行 post request 的時候,前端就是需要從 meta data 那邊讀取 csrf token 並跟著 post request 一起送出去。

//Fill up other variables
let csrfToken = document.getElementsByTagName("meta")["zoraxy.csrf.Token"].getAttribute("content");

$.ajax({
    url: "/api/auth/register",
    method: "POST",
    beforeSend: function(request) {
        request.setRequestHeader("X-CSRF-Token", csrfToken);
    },
    data: {
        username: username,
        password: magic
    },
    success: function(data) {
        if (data.error != undefined) {
            alert(data.error);
        } else {
            //Register success. Refresh page
            window.location.reload();
        }
    },
    error: function(xhr, status, error) {
        console.error("Error registering user:", error);
    }
});

除了 beforeSend 以外,新版本的 jQuery 也可以使用 headers 來附 user defined headers。好像說後面我為了省時間不用每個 ajax 都要改寫 header,所以我寫了一個用來替代 ajax 的 function 叫 cjax

//Add a new function to jquery for ajax override with csrf token injected
$.cjax = function(payload) {
    let requireTokenMethod = ["POST", "PUT", "DELETE"];
    if (requireTokenMethod.includes(payload.method) || requireTokenMethod.includes(payload.type)) {
        //csrf token is required
        let csrfToken = document.getElementsByTagName("meta")["zoraxy.csrf.Token"].getAttribute("content");
        payload.headers = {
            "X-CSRF-Token": csrfToken,
        }a
    }

    $.ajax(payload);
}

在 include jquery 之後 加入這段 function prototype 改寫的 js 檔案,再之後只要把整個 codebase 的 $.ajax 換成 $.cjax 就可以了。這樣的話所有使用 ajax 送出的 POST request 在 headers 的部分都會附有 X-Csrf-Token field 。

解決

就是這樣,這個安全性問題的 bug 就解決了啦~