背景
公司的一个服务被安全扫描出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>