寫在前面
瀏覽器的同源策略(域名、協議、埠均為相同才能呼叫)讓人無語,但卻是非常有必要的:比如瀏覽器處於對安全方面的考慮讓 www.baidu.com
預設不能載入來自 www.alibaba-inc.com
的資料。但有時候我們又需要跨域訪問,比如 http://tmall.admin.taobao.org
要呼叫 http://zhaoshang.tmall.com
的介面,如何實現這樣的跨域呼叫呢?
本文以後端開發的視角,總結了解決跨域問題的幾個方案。同時也總結了最常見的利用“跨域”的幾種攻擊方式和防禦方案。
JSONP
JSONP就是利用HTML中<script>
標籤沒有瀏覽器跨域限制的歷史遺留“漏洞”來達到跨域訪問的目的。比如 http://zhaoshang.tmall.com
就可以在<script>
標籤中載入 https://g.alicdn.com
的資源,那麼我們可以利用這個“漏洞”。
當前端需要呼叫跨域介面時,JS指令碼動態建立一個標籤,地址指向第三方的API網址,形如:<script src=“http://www.example.net/api?param1=1¶m2=2” />
,同時前端需要生成一個回撥函式,呼叫後端介面時將回撥函式名提供給後端。
後端產生的響應為原始JSON資料的包裝(即JSONP),形如:callback({“name”:”hax”,”gender”:”Male”})
,其中callback為前端動態生成的函式名。這樣瀏覽器收到請求返回時會呼叫生成的callback函式,將括號裡的json物件作為引數,前端可在callback函式裡處理所傳入的JSON資料做頁面相應的變化。
前端程式碼:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>JSONP測試</title>
<script type="text/javascript">
const callbackFunc = function (data) {
alert('收到介面返回的資料:' + data);
// 其他頁面操作
};
const url = "http://tmall.admin.taobao.org/example/ajax/GetData.do?orderId=1&callback=callbackFunc";
const script = document.createElement('script');
script.setAttribute('src', url);
document.getElementsByTagName('head')[0].appendChild(script);
</script>
</head>
<body>
</body>
</html>
複製程式碼
後端程式碼:
public void returnJson(RunData rundata, Object data, String callback) {
try {
rundata.getResponse().setContentType("text/json");
String response = "";
if (data != null) {
response = JSON.toJSONStringWithDateFormat(data, CalendarUtil.TIME_PATTERN, SerializerFeature.BrowserCompatible);
}
if (StringUtil.isNotBlank(callback)) {
response = callback + "(" + response + ")"; // 將原始返回JSON組裝成JSONP的形式
}
rundata.getResponse().getWriter().write(response);
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
複製程式碼
這種方式的優點就是相容性很好,在古老的瀏覽器也能很好的執行,同時實現起來也不復雜,但缺點是它支援GET請求而不支援POST等其它類行的HTTP請求。
CORS
在出現CORS標準之前,我們只能通過JSONP的形式去向跨域伺服器去傳送AJAX請求,這種方式前後端都需要做處理,而且請求的方式僅支援GET。而CORS避免了這些缺點,前端不需要對跨域請求做任何特殊處理。
瀏覽器預設呼叫跨域介面時,控制檯會報錯:
No ‘Access-Control-Allow-Origin’ header is present on the requested resource.Origin ‘http://www.example.com’ is therefore not allowed access
複製程式碼
對於一個簡單請求,我們在後端返回的Response Header裡,直接指定允許任意域名訪問即可。在Webx3下可以這樣配置,支援請求的Origin跨域訪問:
public static void allowCrossDomain(RunData rundata) {
if (null != rundata) {
HttpServletRequest request = rundata.getRequest();
HttpServletResponse response = rundata.getResponse();
String origin = request.getHeader("Origin");
if (null != origin) {
response.setHeader("Access-Control-Allow-Origin", origin);
}
}
}
複製程式碼
這個方法是最簡單的,瀏覽器收到Respnose後會自動判斷自己的源是否存在 Access-Control-Allow-Origin
允許源中,如果不存在,會丟擲“同源檢測異常”。但要注意在設定Access-Control-Allow-Origin
時最好只設定為可信賴的源,不要設定為*
,否則此頁面會允許任意頁面JS跨域請求訪問,可能會導致洩漏使用者敏感資訊,例如csrftoken等。
CSRF攻擊
CSRF(Cross-site request forgery)跨站請求偽造,其原理是攻擊者構造網站後臺某個功能介面的請求地址,誘導使用者去點選或者用特殊方法讓該請求地址自動載入。使用者在登入狀態下這個請求被服務端接收後會被誤以為是使用者合法的操作。
舉個例子,比如你開了一個視窗登入了淘寶賬號,然後在另一個視窗開啟了黑客構造了另一個看似正常的頁面(如普通的廣告頁面),但此頁面在後臺自動呼叫了一個隱藏的轉賬介面。而這個介面如果只校驗使用者是已經登入狀態且登入沒有過期,且登入的賬號有權進行轉賬操作。這時,悲劇就會發生,這個轉賬請求就會得到正常響應,而且你不會有任何感知。
CSRF攻擊是攻擊者藉助受害者的cookie騙取伺服器的信任,但是黑客並不能拿到cookie,也看不到cookie的內容。CSRF可以造成資訊洩露,如登陸ID,隱私資訊等,也可能會發生惡意操作:如加好友、關注黑客微博、給黑客點贊、自動發帖、加購物車、刪除資料等。
CSRF解決方案1:Token二次提交
一、服務端在收到路由請求時,生成一個隨機數,在渲染請求頁面時把隨機數埋入頁面(一般埋入form表單的隱藏欄位內)
二、服務端把該隨機數Token作為cookie或者session種入使用者瀏覽器
VM模板裡:
<input type="hidden" name="_tb_token_" id="_tb_token_" value="$!csrfToken.getUniqueToken()"/>
複製程式碼
Java生成Token,並塞入到Session裡(最多塞10個):
public String getUniqueToken() {
RunData runData = (RunData) getThreadContextService().getObject(RunData.class);
HttpSession session = runData.getSession();
String tokenOfRequest = StringUtil.trimToNull((String) session.getAttribute(tokenKey));
String token = "";
if (tokenOfRequest == null) {
token = genToken();
tokenOfRequest = ","+token;
session.setAttribute(tokenKey, tokenOfRequest);
} else {
// 當session中儲存的token超過11個時刪除第一個
String[] tokenList = tokenOfRequest.split(",");
if (tokenList.length >= 11) {
tokenOfRequest = tokenList[2];
for (int i = 3; i < tokenList.length; i++) {
tokenOfRequest += "," + tokenList[i];
}
tokenOfRequest = tokenList[0]+","+tokenOfRequest;
}
token = genToken();
tokenOfRequest = tokenOfRequest + "," + token;
session.setAttribute(tokenKey, tokenOfRequest);
}
return token;
}
複製程式碼
三、當使用者傳送 GET 或者 POST 請求時帶上_csrf_token
引數(對於 Form 表單直接提交即可,因為會自動把當前表單內所有的 input 提交給後臺,包括_csrf_token)
public void execute(RunData rundata, TemplateContext context) throws WebxException {
if (!checkCsrf(rundata)) { // 校驗CSRF
return;
}
// 其他操作...
returnJson(rundata, ResultData.buildSuccessResult("操作成功"));
}
複製程式碼
四、後臺在接受到請求後解析請求的cookie獲取_csrf_token的值,然後和使用者請求提交的_csrf_token做個比較,如果相等表示請求是合法的。
public static boolean validate(RunData runData){
String fromRequest = StringUtil.trimToNull(runData.getParameters().getString(tokenKey));
if (StringUtil.isBlank(fromRequest)) {
return false;
}
HttpSession session = runData.getSession();
String fromSession = StringUtil.trimToNull((String) session.getAttribute(tokenKey));
boolean result = false;
// 校驗是否存在request中的token
if (StringUtil.isNotBlank(fromSession)) {
String[] ss = StringUtil.split(fromSession, ",");
if (ss != null) {
for (String str : ss) {
if (fromRequest.equals(str)) {
result = true;
break;
}
}
}
}
return result;
}
複製程式碼
CSRF解決方案2:Referer驗證
在 HTTP 頭中有一個欄位叫Referer,它記錄了該HTTP請求的來源地址(值是由瀏覽器提供的),後端要校驗該請求的Referer是是否只自己可信賴的網站,
/**
* 校驗refer來自招商系統
*/
public boolean isSelfLegalRefer(RunData rundata) {
String refererStr = rundata.getRequest().getHeader("referer");
String refer = SecurityUtil.parseURL(refererStr); // 檢測是否為阿里集團的url
if (StringUtils.isBlank(refer)) {
return false;
}
if (refer.contains("zhaoshang.tmall.com") || refer.contains("zhaoshang.daily.tmall.net")
|| refer.contains("shangjia.tmall.com") || refer.contains("huangjintai.tmall.com")
|| refer.contains("peixun.tmall.com")) {
return true;
}
return false;
}
複製程式碼
然而這種方式也不是萬無一失的,Referer的值雖然是由瀏覽器提供的,但也有利用瀏覽器漏洞篡改Referer的可能。
XSS注入
跨站指令碼攻擊(Cross Site Scripting),通常縮寫為XSS。惡意攻擊者往Web頁面裡插入惡意Script程式碼,當使用者瀏覽該頁之時,嵌入其中Web裡面的Script程式碼會被執行,從而達到惡意攻擊使用者的目的。
XSS注入的主要流程是,首選尋找一個資料入口,這個入口可能是GET/POST介面中的引數,也可能是Header頭部裡的UA_Referer/Cookie
,甚至是一個普通的頁面搜尋框文字框,這些都可能是潛在的注入點。是否為有效的注入點,通常是輸入一些隨機值,再判斷這6位數字是否返回輸出在頁面,以此來進行判斷,如果介面上沒有復現是不能進行XSS注入的。
如果找到了有效的注入點,就能執行惡意的JavaScript程式碼,比如獲取當前網頁的cookies。舉個例子,比如一個商家的暱稱寫為了<script>alert(1);post('http://example.com/a.do')</script>
,而類似暱稱這種欄位可能在多個頁面出現,極端情況下如果有一個介面比如管理員後臺沒有轉義這個字串而是直接執行了這段程式碼,那麼很可能就以管理員的身份去執行了http://example.com/a.do
這個介面,獲取資料或者進行其他修改資料、截圖、強制下載軟體等惡意操作。
如果我們向VM模板裡輸出一個JSON串並顯示出來,如果是這樣實現,則很有可能中招:
context.put("sellerDO", JSON.toJSONString(sellerDO));
$!sellerDO // 輸出: {"nick":"<script>alert(1)</script>"};
複製程式碼
XSS解決方案1:轉義
通過對使用者資料的正確編碼,使資料最終正確顯示在瀏覽器介面,而不是當做Html/Javascript解析、執行。
XSS解決方案2:過濾
通過把使用者提交的內容進行過濾,移除可能導致XSS攻擊的內容。集團提供Fasttext了安全元件實現XSS過濾及轉義。比如我們可以在Webx下新增如下的Filter實現XSS攻擊字串過濾。
public class RichTextXssFilter {
private static final Logger log = LoggerFactory.getLogger(RichTextXssFilter.class);
private static XssXppScanner scanner = null;
static {
Policy p;
try {
p = Policy.getStrictPolicyInstance();
scanner = new XssXppScanner(p);
} catch (PolicyException e) {
throw new RuntimeException(e);
}
}
public static String filter(String html) {
try {
return scanner.scan(html);
} catch (RuntimeException e) {
log.error("富文字xss過濾錯誤", e);
return html;
}
}
}
複製程式碼
同時在輸出JSON字串時,也可以對文字做escapeHtml處理:
protected void printJSON(RunData rundata, JSONObject jSONObject) {
PrintWriter printWriter = null;
try {
printWriter = rundata.getResponse().getWriter();
printWriter.write(SecurityUtil.escapeJson(jSONObject.toString()));
printWriter.flush();
} catch (Exception e) {
log.error("SendMessage ERROR!", e);
} finally {
if (printWriter != null) {
printWriter.close();
}
}
}
複製程式碼
總結
前端跨域及相關漏洞是每個Web開發前後端都人繞不過的坎。有時候寫的一些新介面只能依賴內部的漏洞掃描工具,但很有可能由於工具掃描不完全、開發理解不深刻等原因導致中招漏洞。