CSRF 的傳統(tǒng)修復(fù)方式
幾天前,在閱讀一篇極為專業(yè)的滲透測(cè)試報(bào)告時(shí),發(fā)現(xiàn)了安全人員匯報(bào)了一個(gè)嚴(yán)重又常見的問題:CSRF 跨站請(qǐng)求偽造,誠然由于開發(fā)者的疏忽,產(chǎn)生 CSRF 的問題的確比較嚴(yán)重,好在發(fā)現(xiàn)的早我們可以盡早修復(fù)。安全人員是這樣建議的:
The application should implement anti-CSRF tokens into all requests that perform actions which change the application state or which add/modify/delete content. An anti-CSRF token should be a long randomly generated value unique to each user so that attackers cannot easily brute-force it. It is important that anti-CSRF tokens are validated when user requests are handled by the application. The application should both verify that the token exists in the request, and also check that it matches the user's current token. If either of these checks fails, the application should reject the request.
使用 anti-CSRF token 是防御 CSRF 的有效手段之一,安全人員的建議也很照本宣科的,很多 web 框架與編程語言都類似的實(shí)現(xiàn)方式,例如:
<form method="POST" action="/profile">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
...
</form>
或者使用 cookie / meta 與 ajax 全局設(shè)置,例如:
// 在 HTML 里面塞入這個(gè) meta
<meta name="csrf-token" content="{{ csrf_token() }}">
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
核心思路是,使用一個(gè)由服務(wù)器派發(fā)的 token,在前端進(jìn)行狀態(tài)修改時(shí),也同時(shí)提交這個(gè) token(往往會(huì)放在 html form 的 input 中,或者 ajax header 中),這時(shí)候服務(wù)端驗(yàn)證該 token 是否是之前所生成的,以此來判斷這個(gè)請(qǐng)求是否被允許。所以,當(dāng)用戶點(diǎn)擊 hacker 所提供的三方網(wǎng)站時(shí),這些惡意網(wǎng)站無論如何也無法獲取到之前服務(wù)端生成 token,這樣的話,即使請(qǐng)求可以發(fā)送至服務(wù)端,也不會(huì)通過驗(yàn)證。關(guān)于 CSRF 與 anti-CSRF Token 的具體機(jī)制本篇不再贅述,請(qǐng)參考這篇文章,此外,防范 CSRF 是一個(gè)稍微復(fù)雜的實(shí)踐,還可以使用 referer、origin 等其他手段進(jìn)行深度防御,而且一定是根據(jù)具體現(xiàn)狀的考慮。
按照這位安全人員的建議,我們應(yīng)該在渲染 form 時(shí),嵌入 token 作為 hidden input 并且在后端進(jìn)行驗(yàn)證,對(duì)于成熟的 web 框架來說,Spring MVC、Ruby on Rails、Play 或者 Lavarel 幾乎是兩行代碼的事情,那么為什么做不到呢?
前后端分離帶來了新問題
隨著前后端分離與單頁應(yīng)用的到來,我們往往在后端使用 RESTful 的方式暴露接口,前端使用 react、angular 或者 VUE 來控制渲染和交互,那么,也就不存在如何在 form 中放入一個(gè) token 來進(jìn)行 CSRF 的驗(yàn)證了。對(duì)于 RESTful 的接口,本質(zhì)上是無狀態(tài)的(stateless),而 anti-CSRF token 是依靠 session 中的狀態(tài)來進(jìn)行判斷,那么也就無法再使用這種方式了。


可以看到,在前后端進(jìn)行分離后,最簡(jiǎn)單的集成方式如上圖:
1)用戶通過瀏覽器請(qǐng)求某個(gè)網(wǎng)站例如 www.google.com,然后 DNS 轉(zhuǎn)移至前端站點(diǎn),獲取前端資源
2)返回頁面,JS,CSS 等后,瀏覽器進(jìn)行渲染頁面,這時(shí)候用戶就能看到頁面了
3)在頁面準(zhǔn)備好后,用戶的所有操作(不論是 form 提交、還是 ajax 請(qǐng)求),都發(fā)送給后端服務(wù),再通過 web service 響應(yīng),修改頁面,支持業(yè)務(wù)邏輯
這個(gè)流程中,對(duì)于真正存儲(chǔ)、修改用戶數(shù)據(jù)的后端服務(wù),是無狀態(tài)的,而用戶所操作的 form 是完全由前端應(yīng)用控制,后端服務(wù)無法感知。所以,即使前端使用某種方式在 form 中放入了 token,但是后端也無法驗(yàn)證,這種 anti CSRF token 的方式是無法實(shí)現(xiàn)的。
嘗試引入狀態(tài)進(jìn)行修復(fù)
好消息是,自從單頁應(yīng)用的崛起我們已經(jīng)很少直接使用 form 的方式跟后端服務(wù)打交道了(頁面上也許有 form,但是提交走 ajax),通過 OWSAP CSRF Cheat Sheet 中的這一節(jié) JavaScript Guidance for Auto-inclusion of CSRF tokens as an AJAX Request header,你依舊可以使用 token 的方式,具體的步驟是:
1)在某個(gè)地方存儲(chǔ) CSRF Token,推薦是 DOM,或者在 JS 變量中或者其他地方,不推薦 cookie 或者 localStorage。
<meta name="csrf-token" content="{{ csrf_token() }}">
2)在 ajax 中,使用自定義的 header 發(fā)送 CSRF Token。
<script type="text/javascript">
var csrf_token = document.querySelector("meta[name='csrf-token']").getAttribute("content");
function csrfSafeMethod(method) {
// these HTTP methods do not require CSRF protection
return (/^(GET|HEAD|OPTIONS)$/.test(method));
}
var o = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(){
var res = o.apply(this, arguments);
var err = new Error();
if (!csrfSafeMethod(arguments[0])) {
this.setRequestHeader('anti-csrf-token', csrf_token);
}
return res;
};
</script>
3)在后端服務(wù)進(jìn)行驗(yàn)證。
首先這種方式不能直接使用,并且也不是完全安全的,有這樣幾個(gè)問題:
1)存儲(chǔ) CSRF Token 的地方,無論是 DOM,Cookie 或者 localStorage,只要是 JavaScript 能讀取到,就會(huì)面臨 XSS 風(fēng)險(xiǎn),很容易拆東墻補(bǔ)西墻。
2)很難在合適的時(shí)機(jī)放入 CSRF Token,還是單頁應(yīng)用的問題,獲取完單頁應(yīng)用后,前端的渲染邏輯完全是瀏覽器負(fù)責(zé),這是無法生成 CSRF Token 的。
3)就算前端代碼在神奇的某處生成了 CSRF Token,后端應(yīng)用也無法獲取到 Token 用來驗(yàn)證請(qǐng)求是否合法,后端服務(wù)是無狀態(tài)的。
解決這些問題的套路也不復(fù)雜,無非就是引入一個(gè)狀態(tài),也就是生成 token 與驗(yàn)證 token 的組件應(yīng)該是一個(gè),而且對(duì)于后端的服務(wù)來說,這是透明的。那么使用 API Gateway 或者自己寫一個(gè) Security Sidecar 就可以做到。大約是這樣的邏輯:

看起來我們是解決了這個(gè)問題,我們引入了新的安全模塊,它有可能是寫在 WAF里,也有可能是 Security Sidecar 或者自定義的 API Gateway,總之,它在哪里用什么技術(shù)實(shí)現(xiàn)并不重要,重要的是這幾個(gè)職責(zé):
1)生成 CSRF Token 并且驗(yàn)證下來的請(qǐng)求
2)順便可以做 token 驗(yàn)證,來確保用戶是否有權(quán)限使用后端服務(wù)
3)常用的 HTTP Referer 與 Origin 檢查
4)其他的安全攔截,比如基于 User-Agent 或者 IP 等等
使用 Origin/Referer Header 進(jìn)行防范
不論你采用哪種方式實(shí)現(xiàn)了 CSRF Token,或者壓根沒做,但是通過 Origin/Referer 的驗(yàn)證判斷是必須要做的,你可以參考以下的代碼實(shí)現(xiàn)。聽起來這種策略很完美,但是取決于瀏覽器的實(shí)現(xiàn)以及后端服務(wù)端支持的 HTTP Method(比如有程序員寫的端口,通過 GET 方式去修改狀態(tài))。
/* STEP 1: Verifying Same Origin with Standard Headers */
//Try to get the source from the "Origin" header
String source = httpReq.getHeader("Origin");
if (this.isBlank(source)) {
//If empty then fallback on "Referer" header
source = httpReq.getHeader("Referer");
//If this one is empty too then we trace the event and we block the request
//(recommendation of the article)...
if (this.isBlank(source)) {
accessDeniedReason = "CSRFValidationFilter: ORIGIN and REFERER request" +
"headers are both absent/empty so we block the request !";
LOG.warn(accessDeniedReason);
httpResp.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedReason);
return;
}
}
//Compare the source against the expected target origin
URL sourceURL = new URL(source);
if (!this.targetOrigin.getProtocol().equals(sourceURL.getProtocol()) ||
!this.targetOrigin.getHost().equals(sourceURL.getHost())
|| this.targetOrigin.getPort() != sourceURL.getPort()) {
//One the part do not match so we trace the event and we block the request
accessDeniedReason = String.format("CSRFValidationFilter: Protocol/Host/Port " +
"do not fully matches so we block the request! (%s != %s) ",
this.targetOrigin, sourceURL);
LOG.warn(accessDeniedReason);
httpResp.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedReason);
return;
}
請(qǐng)求首部字段 Origin 指示了請(qǐng)求來自于哪個(gè)站點(diǎn)。該字段僅指示服務(wù)器名稱,并不包含任何路徑信息。該首部用于 CORS 請(qǐng)求或者 POST 請(qǐng)求。除了不包含路徑信息,該字段與 Referer 首部字段相似。Referer 請(qǐng)求頭包含了當(dāng)前請(qǐng)求頁面的來源頁面的地址,即表示當(dāng)前頁面是通過此來源頁面里的鏈接進(jìn)入的。服務(wù)端一般使用 Referer 請(qǐng)求頭識(shí)別訪問來源,可能會(huì)以此進(jìn)行統(tǒng)計(jì)分析、日志記錄以及緩存優(yōu)化等。
使用 Samesite Cookie Attribute
一般認(rèn)為是 CSRF 的終極解決方式,SameSite 是最新的 cookie 屬性,如同 http-only 與 secure 一般,目前在 RFC6265 中推出,這個(gè)屬性顧名思義,就是限制 cookie 只能在同站中使用。我認(rèn)為是很好的,因?yàn)槲乙恢笔鞘褂?cookie 進(jìn)行系統(tǒng)的認(rèn)證與授權(quán)設(shè)計(jì),即使用 http-only,secure 確保只有瀏覽器能夠獲取 cookie,而 JS 不能,同時(shí),通過 domain、path 與 expires 來控制 token。這樣對(duì)前后端都非常友好,此外也很安全。對(duì)于 CSRF,除了 token 與 Origin/Refer 的方式,還可以使用其他更嚴(yán)格的做法。
目前 Samesite 的可選值為 Lax, Strict 或 None。對(duì)于 Strict 值,用來阻止瀏覽器在任何跨站的情況下發(fā)送 cookie,只有當(dāng)前網(wǎng)頁的 URL 與請(qǐng)求目標(biāo)一致,才會(huì)帶上,所以用戶體驗(yàn)可能會(huì)遭受影響,特別是你的后端服務(wù)在不同的域下,具體請(qǐng)參考阮一峰的文章。
很遺憾,如同下面引用的那句話一樣,我們不得不信任瀏覽器的安全實(shí)現(xiàn),這在網(wǎng)絡(luò)時(shí)代是無法避免的。如同安全方法一樣,能做到什么級(jí)別的安全取決于成本與投入,安全只是一種平衡,絕對(duì)的安全是不存在的。
At the end of the day you have to "trust" the client browser to safely store user's data and protect the client-side of the session. If you don't trust the client browser, then you should stop using the web at all for anything other than static content.
更嚴(yán)格的保護(hù)
某些時(shí)候我們需要更嚴(yán)格的保護(hù),特別是一些安全級(jí)別很高的后臺(tái)或者服務(wù),可以考慮以下這幾種方式
- Re-Authentication (password or stronger):在進(jìn)行安全級(jí)別較高的操作時(shí),需要用戶重新認(rèn)證
- One-time Token:使用類似于 HOTP / TOTP 的 token 進(jìn)行認(rèn)證
- CAPTCHA:驗(yàn)證碼其實(shí)也是一種選擇
參考資料
- https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- https://security.stackexchange.com/questions/166724/should-i-use-csrf-protection-on-rest-api-endpoints
- https://jemurai.com/2018/08/27/csrf-tokens-with-restful-apis/
- http://www.ruanyifeng.com/blog/2019/09/cookie-samesite.html
- https://www.sjoerdlangkemper.nl/2019/02/27/prevent-csrf-with-the-origin-http-request-header/
- https://my.oschina.net/meituantech/blog/2243958