十二年前我寫過一篇 隱含殺機的 GET 式 AJAX 資料更新,說明「使用 GET 方式接收指令進行資料更新」的高風險,觀念還是對的,但隨時代演進,結論所說的「加上(Request.HttpMethod == "POST")的檢查阻止 GET 請求」具有防護效果在當年也許是對的,但當代瀏覽器支援跨來源資源共用(CORS),處理邏輯不同,只防堵 GET 或限定 POST 已不足以防止 XHR 跨站台存取。

跨來源資源共用(CORS)規範,瀏覽器的 XHR 請求在符合以下條件時,會先送一個 OPTIONS 行前檢查請求(Preflight Request)確認是否對方開放 CORS 再正式發出 Request:(延伸閱讀:CORS OPTIONS Preflight Request 與 IIS 設定)

  1. GET/HEAD/POST 以外的請求
  2. 使用 POST,但使用 application/x-www-form-urlencoded, multipart/form-data, or text/plain 之外的 Content-Type,例如:以 POST 傳送 XML、JSON 等。
  3. 使用自訂 Header

然而,當未符合以上條件時,瀏覽器並非拒絕發送,而是直接發送請求,再檢查回應是否包含 Access-Control-Allow-Origin 等 Header 決定正常回傳結果或是觸發存取被拒錯誤。問題來了,若這是個更新作業 AJAX 呼叫,若伺服器端沒有妥善檢核請求發送方式,也沒有加上 CSRF / 跨站請求偽造防護,雖然瀏覽器會報錯並阻止 JavaScript 取得執行結果,但更新作業也已經跑完。

維基百科的這張流程圖可以看得更清楚,左下箭頭所指的 Make actual XHR,會造成未開放 CORS 的 API 也被執行:

用個實例驗證此點。為求簡便,我讓同一支 ASPX danger.aspx 依 mode 參數不同扮演三個角色:

  1. mode == "ajax"
    Guid.NewGuid() 產生隨機字串,寫入 Page.Cache["State"],這裡用 Request.HttpMethod == "POST" 限定 POST 請求 (注意:這是不夠的,將成為破口)
  2. mode == "check"
    顯示目前的 Page.Cache["State"]
  3. 其他情況預設顯示 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:

  1. WebForm - ViewStateUserKey
  2. ASP.NET MVC - HtmlHelper.AntiForgeryToken
  3. 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

Post a comment


55 - 39 =