Spring Cloud Gateway XSS防护大众方案实现优化

背景

公司的一个服务被安全扫描出XSS安全漏洞,需要进行XSS安全加固。本着SpringBoot/Cloud的东西现用现学的原则,搜索到如下文章:《spring cloud gateway 过滤器防止跨站脚本攻击(存储XSS、反射XSS)》https://blog.csdn.net/qq_26801767/article/details/106235359,简单看了下后觉得大致思路没有问题:利用SCGW的全局过滤器进行Http重写,于是动手开工,以为就是简简单单分分钟的事情,没想到,上文中的实现还是存在诸多问题,这里将相关问题进行分享,希望大家能够避坑。

另外,由于是后置加固,生产环境的库里已经存在了一些带有XSS脚本的东西了,因此采取请求Xss过滤和应答Xss过滤双保护。

1. 问题及注意点

1.1 【问题】未重写Content-Length

由于对Response重写后,Body变化必将导致长度的变化,因此必须要重写Response的Content-Length头;原文中作者没有发现错误的原因可能为重写后的ResponseBody长度是小于原始长度。如果因为业务需要修改了重写规则,当重写后的长度大于原始长度时,如果不重写Content-Length头,则应答内容会被截断。

1.2 【问题】DataBuffer读取不当

spring core io 中的DataBuffer我猜测跟Netty中的ByteBuff是一个套路(使用直接内存+读写2个指针),原文中的读取方式在一定条件下(例如,应答消息体内容较长)读取的结果可能不全,因而导致应答内容看上去被截断的问题,但产生的原因和上面完全不是一码事。

1.3 【问题】DataBuffer释放不当

上面说了应该可以把DataBuffer完全去类比ByteBuff,因此在使用时候一定要注意:显式的进行release,否则如果服务器长时间不重启将导致直接内存溢出。原文中虽然有:DataBufferUtils.release(dataBuffer)调用,但是如果调用前出现异常则保障不了release。netty推荐的方式是:try - finally 去保障一定被release

1.4 【问题】XSS正则替换不当

XssCleanRuleUtils里的方式好像被很多人用到,这样做无可厚非,但是XSS全局过滤器做为一个所有接口都要被执行东西,一点点的效率提升都能带来可观的收益。原文中用的是循环正则替换,我这里优化为一次正则替换,简单写了个循环进行1万次调用,对于两者,发现性能可以提高一倍左右。做法是把Xss正则List,用StringJoiner(“|”)构建成一个大正则("|"在正则表达式中就是:逻辑或),这样就只需要一次正则匹配即可。

1.5 【注意点】SpringGateway中全局过滤器对于请求和应答为双链

SpringGateway中全局过滤器对于请求和应答为双链(职责链),如果Response过滤器order不当,则可能导致ResponseFilter在被调用之前响应已被发送,使得ServerHttpResponseDecorator不生效。一般来说可以把请求Xss过滤器和应答Xss过滤器的order设置为:Ordered.HIGHEST_PRECEDENCE,即Int最小值。

2. 代码

2.1 XssResponseGlobalFilter

import com.beam.work.gateway.utils.XssCleanRuleUtils;
import org.reactivestreams.Publisher;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;

/**
 * XssResponseGlobalFilter
 *
 * @author chenx
 */
@Component
public class XssResponseGlobalFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpResponse originalResponse = exchange.getResponse();
        DataBufferFactory bufferFactory = originalResponse.bufferFactory();
        ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(originalResponse) {
            @Override
            public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
                if (body instanceof Flux) {
                    Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body;
                    return super.writeWith(fluxBody.buffer().map(dataBuffer -> {
                        DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
                        DataBuffer join = dataBufferFactory.join(dataBuffer);
                        try {
                            // body重写
                            byte[] content = new byte[join.readableByteCount()];
                            join.read(content);
                            String rawResponse = new String(content, StandardCharsets.UTF_8);
                            String newResponseStr = XssCleanRuleUtils.xssClean(rawResponse);
                            byte[] newResponseBytes = newResponseStr.getBytes(StandardCharsets.UTF_8);

                            // header重写
                            HttpHeaders httpHeaders = originalResponse.getHeaders();
                            httpHeaders.remove(HttpHeaders.CONTENT_LENGTH);
                            httpHeaders.setContentLength(newResponseBytes.length);

                            return bufferFactory.wrap(newResponseBytes);
                        } finally {
                            // DataBuffer看上去几乎等于Netty的ByteBuff,一定要保障显式释放,否则直接内存溢出!
                            DataBufferUtils.release(join);
                        }
                    }));
                }
                return super.writeWith(body);
            }
        };

        return chain.filter(exchange.mutate().response(decoratedResponse).build());
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

2.2 XssRequestFilter

public class XssRequestFilter implements GlobalFilter, Ordered {

	@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		ServerHttpRequest request = exchange.getRequest();
		if(XssFilterUtils.isIgnore(request.getHeaders())) {
			return chain.filter(exchange);
		}

		return DataBufferUtils.join(request.getBody())
				.flatMap(d -> Mono.just(Optional.of(d))).defaultIfEmpty(Optional.empty())
				.flatMap(optional -> {
					try {
						// URI(path & query) XSS清理
						URI uri = XssFilterUtils.uriXssClean(request.getURI());

						// Header XSS清理
						HttpHeaders headers = XssFilterUtils.headerXssClean(request.getHeaders());

						// Body XSS清理
						byte[] bodyBytes = null;
						if (optional.isPresent()) {
							byte[] oldBytes = new byte[optional.get().readableByteCount()];
							optional.get().read(oldBytes);
							bodyBytes = XssFilterUtils.bodyXssClean(request.getHeaders(), oldBytes);
						}

						// 无Body请求重写
						if (ArrayUtils.isEmpty(bodyBytes)) {
							return chain.filter(exchange.mutate().request(new ServerHttpRequestDecorator(request.mutate().uri(uri).build()) {
								@Override
								public HttpHeaders getHeaders() {
									return headers;
								}
							}).build());
						}

						// 有Body请求重写
						if (!ArrayUtils.isEmpty(bodyBytes)) {
							XssFilterUtils.resetContentLength(headers, bodyBytes.length);
						}

						final byte[] finalBodyBytes = bodyBytes;
						return chain.filter(exchange.mutate().request(new ServerHttpRequestDecorator(request.mutate().uri(uri).build()) {
							@Override
							public Flux<DataBuffer> getBody() {
								DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(finalBodyBytes);
								DataBufferUtils.retain(buffer);

								return Flux.just(buffer);
							}

							@Override
							public HttpHeaders getHeaders() {
								return headers;
							}
						}).build());
					} catch (Exception ex) {
						log.error("XssRequestFilter.filter() error!", ex);
						return chain.filter(exchange);
					} finally {
						if(optional.isPresent()) {
							DataBufferUtils.release(optional.get());
						}
					}
				});
	}

	@Override
	public int getOrder() {
		return Ordered.HIGHEST_PRECEDENCE;
	}
}

2.3 XssFilterUtils

public class XssFilterUtils {

    /**
     * 临时特殊处理(后续如果明确出忽略规则考虑将规则进行配置,先hardcode)
     */
    private static final String[] XSS_FILTER_IGNORE_CONTENT_TYPES = {"application/vnd.ms-excel", "multipart/form-data"};

    private XssFilterUtils() {

    }

    /**
     * isIgnore
     *
     * @param httpHeaders
     * @return
     */
    public static boolean isIgnore(HttpHeaders httpHeaders) {
        if (MapUtil.isEmpty(httpHeaders)) {
            return false;
        }

        List<String> contentTypeValues = httpHeaders.get(HttpHeaders.CONTENT_TYPE);
        if (CollectionUtils.isEmpty(contentTypeValues)) {
            return false;
        }

        for (String contentTypeValue : contentTypeValues) {
            for (String ignoredContentTypeValue : XSS_FILTER_IGNORE_CONTENT_TYPES) {
                if (contentTypeValue.toLowerCase().indexOf(ignoredContentTypeValue) >= 0) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * uriXssClean
     *
     * @param uri
     * @return
     */
    public static URI uriXssClean(URI uri) {
        if (uri == null) {
            return null;
        }

        URI result = uri;
        String rawQuery = getUrlDecoderValue(uri.getRawQuery());
        if (!XssCleanRuleUtils.isFind(rawQuery)) {
            return uri;
        }

        // Query XSS清理
        Map<String, String> queryMap = getQueryMap(uri);
        int i = 0;
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> entry : queryMap.entrySet()) {
            String value = URLEncoder.encode(XssCleanRuleUtils.xssClean(entry.getValue()), StandardCharsets.UTF_8);
            sb.append(entry.getKey() + "=" + value);
            if ((i + 1) != queryMap.size()) {
                sb.append("&");
            }

            i++;
        }

        return UriComponentsBuilder.fromUri(result)
                .replaceQuery(sb.toString())
                .build(true)
                .toUri();
    }

    /**
     * headerXssClean
     *
     * @param httpHeaders
     * @return
     */
    public static HttpHeaders headerXssClean(HttpHeaders httpHeaders) {
        HttpHeaders newHeaders = new HttpHeaders();
        if (MapUtil.isEmpty(httpHeaders)) {
            return newHeaders;
        }

        newHeaders.putAll(httpHeaders);
        List<String> removedHeaderKeys = new ArrayList<>();
        httpHeaders.forEach((key, values) -> {
            for (String value : values) {
                if (XssCleanRuleUtils.isFind(getUrlDecoderValue(value))) {
                    removedHeaderKeys.add(key);
                    break;
                }
            }
        });

        for (String key : removedHeaderKeys) {
            newHeaders.remove(key);
            newHeaders.put(key, XSS_REPLACEMENT_LIST);
        }

        return newHeaders;
    }

    /**
     * bodyXssClean
     *
     * @param headers
     * @param data
     * @return
     */
    public static byte[] bodyXssClean(HttpHeaders headers, byte[] data) {
        if (ArrayUtils.isEmpty(data)) {
            return new byte[0];
        }

        String body = new String(data, StandardCharsets.UTF_8);
        if (isFormUrlEncodedContentType(headers)) {
            Map<String, String> rawFormDataMap = getFormDataMap(body);
            Map<String, String> newFormDataMap = new LinkedHashMap<>(rawFormDataMap.size());
            for (Map.Entry<String, String> entry : rawFormDataMap.entrySet()) {
                newFormDataMap.put(entry.getKey(), XssCleanRuleUtils.xssClean(entry.getValue()));
            }

            return getFormDataString(newFormDataMap).getBytes(StandardCharsets.UTF_8);
        }

        return XssCleanRuleUtils.xssClean(body).getBytes(StandardCharsets.UTF_8);
    }

    /**
     * resetContentLength
     *
     * @param headers
     * @param length
     */
    public static void resetContentLength(HttpHeaders headers, int length) {
        if (!MapUtil.isEmpty(headers) && headers.containsKey(HttpHeaders.CONTENT_LENGTH)) {
            headers.remove(HttpHeaders.CONTENT_LENGTH);
            headers.setContentLength(length);
        }
    }


    /**
     * 兼容进行URL编码和没有编码的情况(先尝试URL解码,如果失败返回原始值)
     *
     * @param value
     * @return
     */
    private static String getUrlDecoderValue(String value) {
        try {
            return URLDecoder.decode(value, StandardCharsets.UTF_8);
        } catch (Exception ex) {
            return value;
        }
    }

    /**
     * getQueryMap
     *
     * @param uri
     * @return
     */
    private static Map<String, String> getQueryMap(@NonNull final URI uri) {
        String query = uri.getQuery();
        final Map<String, String> queryArgsMap = new HashMap<>(16);
        if (StringUtils.isEmpty(query)) {
            return queryArgsMap;
        }

        List<NameValuePair> params = URLEncodedUtils.parse(uri, Charsets.UTF_8);
        for (NameValuePair param : params) {
            queryArgsMap.putIfAbsent(param.getName(), param.getValue());
        }

        return queryArgsMap;
    }

    /**
     * isFormUrlEncodedContentType
     *
     * @param headers
     * @return
     */
    private static boolean isFormUrlEncodedContentType(HttpHeaders headers) {
        if (MapUtil.isEmpty(headers)) {
            return false;
        }

        List<String> contentTypeValues = headers.get(HttpHeaders.CONTENT_TYPE);
        if (CollectionUtils.isEmpty(contentTypeValues)) {
            return false;
        }

        for (String contentTypeValue : contentTypeValues) {
            if (contentTypeValue.toLowerCase().indexOf("application/x-www-form-urlencoded") >= 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * getFormDataMap
     *
     * @param formData
     * @return
     */
    private static Map<String, String> getFormDataMap(String formData) {
        Map<String, String> formDataMap = new LinkedHashMap<>(8);
        if (StringUtils.isEmpty(formData)) {
            return formDataMap;
        }

        String[] pairs = formData.split("\\&");
        for (int i = 0; i < pairs.length; i++) {
            String[] fields = pairs[i].split("=");
            if (fields.length >= 2) {
                String name = getUrlDecoderValue(fields[0]);
                String value = getUrlDecoderValue(fields[1]);
                formDataMap.put(name, value);
            }
        }

        return formDataMap;
    }

    /**
     * getFormDataString
     *
     * @param formDataMap
     * @return
     */
    private static String getFormDataString(Map<String, String> formDataMap) {
        StringBuilder result = new StringBuilder();
        boolean first = true;
        for (Map.Entry<String, String> entry : formDataMap.entrySet()) {
            if (first) {
                first = false;
            } else {
                result.append("&");
            }

            result.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8));
            result.append("=");
            result.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
        }

        return result.toString();
    }
}

2.3 XssCleanRuleUtils

import org.apache.commons.lang.StringUtils;

import java.util.Collections;
import java.util.List;
import java.util.StringJoiner;
import java.util.regex.Pattern;

/**
 * XssCleanRuleUtils
 * 优化考虑:
 * 1、增加是否开启Xss防护开关配置(默认开启,对外压力测试关闭)
 * 2、xssScriptRegArr从配置读取用于防护扩展
 *
 * @author chenx
 */
public class XssCleanRuleUtils {

    private XssCleanRuleUtils() {

    }

    private static StringJoiner joiner = new StringJoiner("|");
    private static final String XSS_REPLACEMENT = "***XSS***";
    private static Pattern xssPattern;
    private static final String[] xssScriptRegArr = {
            "<script>(.*?)</script>",
            "src[\r\n]*=[\r\n]*\\'(.*?)\\'",
            "</script>",
            "<script(.*?)>",
            "eval\\((.*?)\\)",
            "expression\\((.*?)\\)",
            "javascript:",
            "vbscript:",
            "onload(.*?)=",
            "<(.*?)>"
    };

    public static final List<String> XSS_REPLACEMENT_LIST = Collections.singletonList(XSS_REPLACEMENT);

    static {
        for (String reg : xssScriptRegArr) {
            joiner.add(reg);
        }

        xssPattern = Pattern.compile(joiner.toString(), Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL);
    }

    /**
     * isFind
     * @param value
     * @return
     */
    public static boolean isFind(String value) {
        if (StringUtils.isEmpty(value)) {
            return false;
        }

        return xssPattern.matcher(value).find();
    }

    /**
     * xssClean
     * @param value
     * @return
     */
    public static String xssClean(String value) {
        if (StringUtils.isEmpty(value)) {
            return value;
        }

        return xssPattern.matcher(value).replaceAll(XSS_REPLACEMENT);
    }

}

3. 坑

DataBufferUtils.release(buffer)在低版本spring-core下是有问题,详见:https://github.com/spring-projects/spring-framework/issues/26060;如果依赖的spring-cloud-starter-gateway版本较低,可以单独升spring-core的版本spring-core升级为5.2.13.RELEASE及以上,否则会出:IllegalReferenceCountException异常(io.netty.util.IllegalReferenceCountException: refCnt: 0, decrement: 1)。

<dependency>
        	<groupId>org.springframework.cloud</groupId>
        	<artifactId>spring-cloud-starter-gateway</artifactId>
			<exclusions>
				<exclusion>
					<groupId>org.springframework</groupId>
					<artifactId>spring-core</artifactId>
				</exclusion>
			</exclusions>
    	</dependency>
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-core</artifactId>
			<version>5.2.13.RELEASE</version>
		</dependency>
  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论
Spring Cloud Gateway可以通过编写自定义的过滤器实现XSS过滤。 首先,我们需要创建一个XSS过滤器类,实现`GlobalFilter`和`Ordered`接口: ```java @Component public class XssGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); HttpHeaders headers = request.getHeaders(); MediaType contentType = headers.getContentType(); HttpMethod method = request.getMethod(); if (contentType != null && contentType.isCompatibleWith(MediaType.APPLICATION_JSON) && HttpMethod.POST.equals(method)) { return chain.filter(exchange.mutate().request(new XssServerHttpRequest(request)).build()); } return chain.filter(exchange); } @Override public int getOrder() { return -1; } } ``` 这里,我们首先判断请求的Content-Type是否为`application/json`,并且请求方法是否为POST,如果是,则将请求的`ServerHttpRequest`替换为我们自定义的`XssServerHttpRequest`,该类继承自`ServerHttpRequestDecorator`,在该类中对请求体进行XSS过滤,代码如下: ```java public class XssServerHttpRequest extends ServerHttpRequestDecorator { public XssServerHttpRequest(ServerHttpRequest delegate) { super(delegate); } @Override public Flux<DataBuffer> getBody() { Flux<DataBuffer> body = super.getBody(); return body.map(dataBuffer -> { CharBuffer charBuffer = StandardCharsets.UTF_8.decode(dataBuffer.asByteBuffer()); String bodyContent = charBuffer.toString(); // 进行XSS过滤 String filteredBodyContent = Jsoup.clean(bodyContent, Whitelist.none()); byte[] bytes = filteredBodyContent.getBytes(StandardCharsets.UTF_8); DataBuffer buffer = new DefaultDataBufferFactory().wrap(bytes); DataBufferUtils.release(dataBuffer); return buffer; }); } } ``` 在该类中,我们首先将`DataBuffer`转换成`CharBuffer`,再将其转换成字符串,然后使用Jsoup对字符串进行XSS过滤,最后再将过滤后的字符串转换成`DataBuffer`返回。 最后,我们需要将这个过滤器添加到Spring Cloud Gateway的过滤器链中,在配置类中添加: ```java @Configuration public class GatewayConfig { @Bean public XssGlobalFilter xssGlobalFilter() { return new XssGlobalFilter(); } @Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() // 添加自定义路由 .route(r -> r.path("/api/**").uri("lb://service-provider")) .build(); } } ``` 这样,当请求Content-Type为`application/json`,并且请求方法为POST时,请求体中的HTML标签就会被过滤掉,从而实现XSS过滤。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BossFriday

原创不易,请给作者打赏或点赞!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值