迷思:只要限定 POST 呼叫就不會有跨站台存取風險?
0 |
十二年前我寫過一篇 隱含殺機的 GET 式 AJAX 資料更新,說明「使用 GET 方式接收指令進行資料更新」的高風險,觀念還是對的,但隨時代演進,結論所說的「加上(Request.HttpMethod == "POST")的檢查阻止 GET 請求」具有防護效果在當年也許是對的,但當代瀏覽器支援跨來源資源共用(CORS),處理邏輯不同,只防堵 GET 或限定 POST 已不足以防止 XHR 跨站台存取。
依跨來源資源共用(CORS)規範,瀏覽器的 XHR 請求在符合以下條件時,會先送一個 OPTIONS 行前檢查請求(Preflight Request)確認是否對方開放 CORS 再正式發出 Request:(延伸閱讀:CORS OPTIONS Preflight Request 與 IIS 設定)
- GET/HEAD/POST 以外的請求
- 使用 POST,但使用 application/x-www-form-urlencoded, multipart/form-data, or text/plain 之外的 Content-Type,例如:以 POST 傳送 XML、JSON 等。
- 使用自訂 Header
然而,當未符合以上條件時,瀏覽器並非拒絕發送,而是直接發送請求,再檢查回應是否包含 Access-Control-Allow-Origin 等 Header 決定正常回傳結果或是觸發存取被拒錯誤。問題來了,若這是個更新作業 AJAX 呼叫,若伺服器端沒有妥善檢核請求發送方式,也沒有加上 CSRF / 跨站請求偽造防護,雖然瀏覽器會報錯並阻止 JavaScript 取得執行結果,但更新作業也已經跑完。
由維基百科的這張流程圖可以看得更清楚,左下箭頭所指的 Make actual XHR,會造成未開放 CORS 的 API 也被執行:
用個實例驗證此點。為求簡便,我讓同一支 ASPX danger.aspx 依 mode 參數不同扮演三個角色:
- mode == "ajax"
Guid.NewGuid() 產生隨機字串,寫入 Page.Cache["State"],這裡用 Request.HttpMethod == "POST" 限定 POST 請求 (注意:這是不夠的,將成為破口) - mode == "check"
顯示目前的 Page.Cache["State"] - 其他情況預設顯示 HTML 介面,有一個 Button 呼叫 XHR 用 POST 方式呼叫 danger.aspx?mode=ajax,同時用 IFrame 內嵌 danger.aspx?mode=check 檢查 AJAX 更新是否成功
用先前介紹過的 Windows\System32\drivers\etc\hosts 多域名指向 127.0.0.1 技巧,我開啟 ℎttp://parent.utopia.net/aspnet/xss/danger.aspx 呼叫 ℎttp://child.uotpia.com/aspnet/xss/danger.aspx?mode=ajax,在 IFrame 則嵌入 ℎttp://child.uotpia.com/aspnet/xss/danger.aspx?mode=check 觀察 AJAX 更新結果。從 parent.utopia.net 呼叫 child.uotpia.com danger.aspx?mode=ajax 是一個跨來源呼叫,在沒設定 Access-Control-Allow-Origin Header 的情況下理應被瀏覽器擋下來。
<%@Page Language="C#"%>
<script runat="server">
void SetData(string value)
{
Page.Cache["State"] = value;
}
void Page_Load(object sender, EventArgs e)
{
if (Request.HttpMethod == "POST" && Request.Form["mode"] == "ajax")
{
//提醒:此為示範用途,未進行檢查即進行 AJAX 更新
//實務上應增加 CSRF 防禦機制才合格
SetData(Guid.NewGuid().ToString().Substring(0, 8));
Response.Write("OK");
Response.End();
}
else if (Request["mode"] == "check")
{
Response.Write((string)Page.Cache["State"]);
Response.End();
}
else {
SetData("Empty");
}
}
</script>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body,button { font-size: 9pt; }
div { height: 20px; padding: 3px; }
iframe { height: 40px; width: 150px; }
</style>
</head>
<body>
<button id=b onclick="testAjax()">Test Browser AJAX Call</button>
<div id=m>Ready</div>
<iframe id=f></iframe>
<script>
function showMsg(msg, noRefreshChk) {
document.getElementById('m').innerText = msg;
if (!noRefreshChk) {
document.getElementById('f').src = location.href.split('?')[0] +
"?mode=check&t=" + (new Date().getTime());
}
}
showMsg("Ready");
var url = "http://child.utopia.net/aspnet/xss/danger.aspx";
function testAjax() {
var req = new XMLHttpRequest();
req.addEventListener("load", function () {
if (req.status == 200)
showMsg("SUCC - " + req.responseText);
else {
showMsg("ERROR - " + req.status);
}
});
req.addEventListener("error", function () {
//failed to get response from remote server
showMsg("ERROR - Failed to send request");
});
req.open("POST", url);
showMsg("Sending Request...", true);
req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
req.send("mode=ajax");
}
</script>
</body>
</html>
執行結果如下:
如以上所展示,parent.utopia.net 呼叫 child.utopia.net 的 POST 行為,雖然跨來源但 Content-Type 為 application/x-www-form-urlencoded,故不需要 Preflight Request,瀏覽器採取「先照常送出再檢查回應決定是否放行」策略,接著伺服器執行完回傳 HTTP 200,瀏覽器發現沒有 Access-Control-Allow-Origin 拋出錯誤:
Access to XMLHttpRequest at 'ℎttp://child.utopia.net/aspnet/xss/danger.aspx?mode=ajax' from origin 'ℎttp://parent.utopia.net' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. POST ℎttp://child.utopia.net/aspnet/xss/danger.aspx?mode=ajax net::ERR_FAILED 200
每次按鈕 Page.Cache["State"] 都會被更新,瀏覽器再回報 XHR 存取被拒,但木已成舟...
由此可知,瀏覽器只確保 JavaScript 拿不到未開放 CORS 的 AJAX 呼叫結果(用 F12 DevTool 可看到),在特定條件下會先發送再檢查 CORS 設定。
基於這個行為,伺服器端必須加入 CSRF 防護機制才能有效阻絕跨站台攻擊,以 ASP.NET 來說,不同版本都有內建相關 API:
- WebForm - ViewStateUserKey
- ASP.NET MVC - HtmlHelper.AntiForgeryToken
- ASP.NET Core - HtmlHelper.AntiForgeryToken
除了引用現成機制,我們也可運用 CORS 原理,限定 POST 內容使用 JSON/XML 等 Content-Type 或要求自訂 Header,如此 JavaScript 端為送出有效 Request 就一定會觸發 OPTIONS Preflight Request 檢查,即可避免來自非 CORS 開放對象的 POST 請求降低風險。當然,這種做法的保護力比不上標準的 CSRF 防禦機制,但這裡仍會做個測試驗證效果。
<%@Page Language="C#"%>
<script runat="server">
//...略...
const string HeaderKeyName = "X-AJAX-KEY";
const string HeaderValueChk = "4890e7b7-5e60-4036-8ce4-cc56c79496de";
void Page_Load(object sender, EventArgs e)
{
if (Request.HttpMethod == "POST"
&& Request.Form["mode"] == "ajax"
&& Request.Headers[HeaderKeyName] == HeaderValueChk)
{
//提醒:此為示範用途,未進行檢查即進行 AJAX 更新
//實務上應增加 CSRF 防禦機制才合格
SetData(Guid.NewGuid().ToString().Substring(0, 8));
Response.Write("OK");
Response.End();
}
//...略...
}
</script>
<!DOCTYPE html>
<html>
<!-- ...略... -->
<script>
//...略...
var url = "http://child.utopia.net/aspnet/xss/safe.aspx";
function testAjax() {
var req = new XMLHttpRequest();
//...略...
req.open("POST", url);
showMsg("Sending Request...", true);
req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
// 設定 Header 設定
req.setRequestHeader("<%=HeaderKeyName%>", "<%=HeaderValueChk%>");
req.send("mode=ajax");
}
</script>
</body>
</html>
實際測試,POST Request 便會因 Preflight OPTIONS 檢查失敗無法送出,不會執行更新。
[2021-8-28 補充] 感謝讀者元元提醒,忘了提除了 XHR 其實還有 HTML Form 跨站台傳送這招可完全避開瀏覽器的同源政策,限定 Content-Type JSON/XML、特殊 HTTP Method 及檢查客製 Request Header 可排除此一管道。
<form id="xsf" action="http://child.utopia.net/aspnet/xss/form.aspx"
method="post" target="result">
<input type="hidden" name="mode" value="ajax"></input>
</form>
<script>
document.getElementById('xsf').submit();
</script>
結論 - 擋掉 GET 限定 POST 並無法有效阻止跨站台存取,確實引用 CSRF 防禦以保安康。
Blocking GET and allowing POST only can't prevent cross-origin access because modern browser may send acutal request to check CORS settings.
Comments
Be the first to post a comment