最近在我的 Zoraxy 開源專案那邊出現了這樣的一個 bug issue
他還很好的附了一個網頁測試介面的截圖
甚麼是 CSRF 攻擊?
CSRF(Cross-Site Request Forgery,跨站請求偽造)是一種網絡攻擊技術,攻擊者通過在受害者不知情的情況下,冒充受害者的身份在受害者已經認證的網站上執行未經授權的操作。
CSRF 攻擊的工作原理
- 受害者登錄到可信網站:受害者首先登錄到一個需要認證的網站,在這例子是 Zoraxy 的後台管理頁面
- 攻擊者製造惡意請求:攻擊者創建一個包含惡意請求的網頁或電子郵件,例如說這裡的一個 free burger 網頁 html + web form (action 或 form submit URL 指向 Zoraxy 的後台管理頁面)
- 受害者訪問惡意鏈接:受害者在登錄狀態下 request 了攻擊者控制的網頁並提交了 web-form。
- 執行未經授權的操作:由於受害者已經登錄(Auth Cookie 會跟著 form 一起送出去),Zoraxy webmin 介面會以為這些 request 是 valid 的,並執行相應的操作,例如上面例子的 toggle proxy 開關狀態 。
防禦 CSRF 攻擊的方法
- CSRF Token:在每個受保護的操作請求中加入隨機生成的、唯一的 CSRF Token,並驗證這些 Token 以確保請求是合法的。
- 檢查 Referer 和 Origin 頭:檢查 HTTP 頭中的 Referer 和 Origin 字段,確保請求是從合法的來源發出的。但是我在寫的是 Reverse proxy,本來 Referer 跟 origin 就是不可信的,所以不能使用此方法。
- 雙重提交 Cookie:將 CSRF Token 存儲在 Cookie 中,並在請求中一起提交和驗證。
- 使用 SameSite Cookie 屬性:設置 Cookie 的 SameSite 屬性為
Strict
或Lax
,限制跨站請求攜帶 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 進行攔阻。所以這裡有兩個需要做的事情
- 在 HTML 頁面生成的時候,在頁面上加入一個 csrf token
- 在有資料需要以 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 就解決了啦~