XSS和SQL注入是Web应用中常见计算机安全漏洞,文章主要分享通过Spring Cloud Gateway 全局过滤器对XSS和SQL注入进行安全防范。
写这篇文章也是因为项目在经过安全组进行安全巡检时发现项目存储该漏洞后进行系统整改,本文的运行结果是经过安全组验证通过。
使用版本
- spring-cloud-dependencies Hoxton.SR7
- spring-boot-dependencies 2.2.9.RELEASE
- spring-cloud-gateway 2.2.4.RELEASE
核心技术点
1. AddRequestParameterGatewayFilterFactory 获取get请求参数并添加参数然后重构get请求
public GatewayFilter apply(NameValueConfig config) {
return new GatewayFilter() {
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
URI uri = exchange.getRequest().getURI();
StringBuilder query = new StringBuilder();
//获取请求url携带的参数,?号后面参数体,类似cl=3&tn=baidutop10&fr=top1000&wd=31
String originalQuery = uri.getRawQuery();
if (StringUtils.hasText(originalQuery)) {
query.append(originalQuery);
if (originalQuery.charAt(originalQuery.length() - 1) != '&') {
query.append('&');
}
}
String value = ServerWebExchangeUtils.expand(exchange, config.getValue());
query.append(config.getName());
query.append('=');
query.append(value);
try {
//重构请求uri
URI newUri = UriComponentsBuilder.fromUri(uri).replaceQuery(query.toString()).build(true).toUri();
ServerHttpRequest request = exchange.getRequest().mutate().uri(newUri).build();
return chain.filter(exchange.mutate().request(request).build());
} catch (RuntimeException var9) {
throw new IllegalStateException("Invalid URI query: \"" + query.toString() + "\"");
}
}
public String toString() {
return GatewayToStringStyler.filterToStringCreator(AddRequestParameterGatewayFilterFactory.this).append(config.getName(), config.getValue()).toString();
}
};
}
2. [Spring Cloud Gateway中RequestBody只能获取一次的问题解决方案] (https://blog.csdn.net/dear_little_bear/article/details/105319657)
3. Spring Gateway GlobalFilter
技术实现
-
创建Filter 实现GlobalFilter, Ordered
@Slf4j
@Component
public class SqLinjectionFilter implements GlobalFilter, Ordered {@SneakyThrows
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain){
// grab configuration from Config object
log.debug("----自定义防XSS攻击网关全局过滤器生效----");
ServerHttpRequest serverHttpRequest = exchange.getRequest();
HttpMethod method = serverHttpRequest.getMethod();
String contentType = serverHttpRequest.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
URI uri = exchange.getRequest().getURI();Boolean postFlag = (method == HttpMethod.POST || method == HttpMethod.PUT) && (MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(contentType) || MediaType.APPLICATION_JSON_VALUE.equals(contentType)); //过滤get请求 if (method == HttpMethod.GET) { String rawQuery = uri.getRawQuery(); if (StringUtils.isBlank(rawQuery)){ return chain.filter(exchange); } log.debug("原请求参数为:{}", rawQuery); // 执行XSS清理 rawQuery = XssCleanRuleUtils.xssGetClean(rawQuery); log.debug("修改后参数为:{}", rawQuery); // 如果存在sql注入,直接拦截请求 if (rawQuery.contains("forbid")) { log.error("请求【" + uri.getRawPath() + uri.getRawQuery() + "】参数中包含不允许sql的关键词, 请求拒绝"); return setUnauthorizedResponse(exchange); } try { //重新构造get request URI newUri = UriComponentsBuilder.fromUri(uri) .replaceQuery(rawQuery) .build(true) .toUri(); ServerHttpRequest request = exchange.getRequest().mutate() .uri(newUri).build(); return chain.filter(exchange.mutate().request(request).build()); } catch (Exception e) { log.error("get请求清理xss攻击异常", e); throw new IllegalStateException("Invalid URI query: \"" + rawQuery + "\""); } } //post请求时,如果是文件上传之类的请求,不修改请求消息体 else if (postFlag){ return DataBufferUtils.join(serverHttpRequest.getBody()).flatMap(d -> Mono.just(Optional.of(d))).defaultIfEmpty( Optional.empty()) .flatMap(optional -> { // 取出body中的参数 String bodyString = ""; if (optional.isPresent()) { byte[] oldBytes = new byte[optional.get().readableByteCount()]; optional.get().read(oldBytes); bodyString = new String(oldBytes, StandardCharsets.UTF_8); } HttpHeaders httpHeaders = serverHttpRequest.getHeaders(); // 执行XSS清理 log.debug("{} - [{}:{}] XSS处理前参数:{}", method, uri.getPath(), bodyString); bodyString = XssCleanRuleUtils.xssPostClean(bodyString); log.info("{} - [{}:{}] XSS处理后参数:{}", method, uri.getPath(), bodyString); // 如果存在sql注入,直接拦截请求 if (bodyString.contains("forbid")) { log.error("{} - [{}:{}] 参数:{}, 包含不允许sql的关键词,请求拒绝", method, uri.getPath(), bodyString); return setUnauthorizedResponse(exchange); } ServerHttpRequest newRequest = serverHttpRequest.mutate().uri(uri).build(); // 重新构造body byte[] newBytes = bodyString.getBytes(StandardCharsets.UTF_8); DataBuffer bodyDataBuffer = toDataBuffer(newBytes); Flux<DataBuffer> bodyFlux = Flux.just(bodyDataBuffer); // 重新构造header HttpHeaders headers = new HttpHeaders(); headers.putAll(httpHeaders); // 由于修改了传递参数,需要重新设置CONTENT_LENGTH,长度是字节长度,不是字符串长度 int length = newBytes.length; headers.remove(HttpHeaders.CONTENT_LENGTH); headers.setContentLength(length); headers.set(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf8"); // 重写ServerHttpRequestDecorator,修改了body和header,重写getBody和getHeaders方法 newRequest = new ServerHttpRequestDecorator(newRequest) { @Override public Flux<DataBuffer> getBody() { return bodyFlux; } @Override public HttpHeaders getHeaders() { return headers; } }; return chain.filter(exchange.mutate().request(newRequest).build()); }); } else { return chain.filter(exchange); }
}
// 自定义过滤器执行的顺序,数值越大越靠后执行,越小就越先执行
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}/**
* 设置403拦截状态
*/
private Mono<Void> setUnauthorizedResponse(ServerWebExchange exchange) {
return WebfluxResponseUtil.responseFailed(exchange, HttpStatus.FORBIDDEN.value(),
"request is forbidden, SQL keywords are not allowed in the parameters.");
}/**
* 字节数组转DataBuffer
*
* @param bytes 字节数组
* @return DataBuffer
*/
private DataBuffer toDataBuffer(byte[] bytes) {
NettyDataBufferFactory nettyDataBufferFactory = new NettyDataBufferFactory(ByteBufAllocator.DEFAULT);
DataBuffer buffer = nettyDataBufferFactory.allocateBuffer(bytes.length);
buffer.write(bytes);
return buffer;
}}
-
定义xss注入、sql注入工具类
@Slf4j
public class XssCleanRuleUtils {private final static Pattern[] scriptPatterns = {
Pattern.compile("<script>(.?)</script>", Pattern.CASE_INSENSITIVE),
Pattern.compile("src[\r\n]=[\r\n]\'(.?)\'", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("</script>", Pattern.CASE_INSENSITIVE),
Pattern.compile("<script(.?)>", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("eval\((.?)\)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("expression\((.?)\)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL),
Pattern.compile("javascript:", Pattern.CASE_INSENSITIVE),
Pattern.compile("vbscript:", Pattern.CASE_INSENSITIVE),
Pattern.compile("onload(.?)=", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL)
};private static String badStrReg = "\b(and|or)\b.{1,6}?(=|>|<|\bin\b|\blike\b)|\/\.+?\\/|<\s*script\b|\bEXEC\b|UNION.+?SELECT|UPDATE.+?SET|INSERT\s+INTO.+?VALUES|(SELECT|DELETE).+?FROM|(CREATE|ALTER|DROP|TRUNCATE)\s+(TABLE|DATABASE)";
private static Pattern sqlPattern = Pattern.compile(badStrReg, Pattern.CASE_INSENSITIVE);//整体都忽略大小写
/**
* GET请求参数过滤
* @param value
* @return
*/
public static String xssGetClean(String value) throws UnsupportedEncodingException {//过滤xss字符集 if (value != null) { value = value.replaceAll("\0|\n|\r", ""); for (Pattern pattern : scriptPatterns) { value = pattern.matcher(value).replaceAll(""); } value = value.replaceAll("<", "<").replaceAll(">", ">"); } //sql关键字检查 return cleanGetSqlKeyWords(value);
}
public static String xssPostClean(String value) {
//过滤xss字符集 if (value != null) { value = value.replaceAll("\0|\n|\r", ""); for (Pattern pattern : scriptPatterns) { value = pattern.matcher(value).replaceAll(""); } value = value.replaceAll("<", "<").replaceAll(">", ">"); } //sql关键字检查 return cleanPostSqlKeyWords(value);
}
/**
* 解析参数SQL关键字
* @param value
* @return
*/
private static String cleanGetSqlKeyWords(String value) throws UnsupportedEncodingException {//参数需要url编码 //这里需要将参数转换为小写来处理 //不改变原值 //value示例 order=asc&pageNum=1&pageSize=100&parentId=0 String lowerValue = URLDecoder.decode(value, "UTF-8").toLowerCase(); //获取到请求中所有参数值-取每个key=value组合第一个等号后面的值 boolean isContains = Stream.of(lowerValue.split("\\&")) .map(kp -> kp.substring(kp.indexOf("=") + 1)) .parallel() .anyMatch(param -> { if (sqlPattern.matcher(param).find()) { log.error("参数中包含不允许sql的关键词"); return true; } return false; }); return isContains ? "forbid" : value;
}
/**
* 解析参数SQL关键字
* @param value
* @return
*/
private static String cleanPostSqlKeyWords(String value){JSONObject json = JSONObject.parseObject(value); Map<String, Object> map = json; Map<String, Object> mapjson = new HashMap<>(); for (Map.Entry<String, Object> entry : map.entrySet()) { String value1 = entry.getValue().toString(); //这里需要将参数转换为小写来处理-不改变原值 String lowerValue = value1.toLowerCase(); if (sqlPattern.matcher(lowerValue).find()) { log.error("参数中包含不允许sql的关键词"); value1 = "forbid"; mapjson.put(entry.getKey(),value1); break; } else { mapjson.put(entry.getKey(),entry.getValue()); } } return JSONObject.toJSONString(mapjson);
}
踩坑过程
-
sql注入过滤规则
网上大多数sql注入拦截规则都是使用一个sql关键字匹配,//定义sql注入关键字
String badStr = "'|and|exec|execute|insert|select|delete|update|count|drop|%|chr|mid|master|truncate|" +
"char|declare|sitename|net user|xp_cmdshell|;|or|+|,|like'|and|exec|execute|insert|create|drop|" +
"table|from|grant|use|group_concat|column_name|" +
"information_schema.columns|table_schema|union|where|select|delete|update|order|by|count|" +
"chr|mid|master|truncate|char|declare|or|;|--|,|like|//|/|%|#";
//过滤规则
for (String bad : badStrs) {
if (value1.equalsIgnoreCase(bad)) {
value1 = "forbid";
mapjson.put(entry.getKey(),value1);
break;
} else {
mapjson.put(entry.getKey(),entry.getValue());
}
}
}
最初我们也是使用改方式,但是关键字匹配方式实在太容易误杀正常业务,且容易漏,比如
select/*/1from/*/tt
这样形式的参数就无法过滤。
最后我们还是采取sql正则匹配的方式(见代码),已和安全工程师完成联调,能够挡住安全工程师的注入测试案例,对业务也完成回归测试,基本不影响现有业务正常运行。
- get请求拦截过程不要对源参数进行url编码,否则应用可能出现不必要的错误
get请求参数中的中文字符以及一些特色字符请求到服务器会自动编码,在对xss注入过滤过程需要进行url编码才能进行过滤规则的验证,在起初我们是在源参数上进行编码,但是一些正常请求中携带+号这样的特色符号的请求在处理过程会被过滤掉,该问题排查了许久才发现的,因此建议不要改变源请求参数的编码格式
//参数需要url编码
//这里需要将参数转换为小写来处理
//不改变原值
//value示例 order=asc&pageNum=1&pageSize=100&parentId=0
String lowerValue = URLDecoder.decode(value, "UTF-8").toLowerCase();
- RequestBody只能获取一次的问题
代码参考了网上对于RequestBody只能获取一次的问题解决的方案,在spring-cloud-gateway 2.2.4.RELEASE验证有效
优化
拦截器在实际生产运行过程存在一些列问题:
- 对xss字符集的转换会导致会第三方平台接入的接口出现一些列问题,尤其是需要参数签名验签的接口,因为参数的变化导致验签不成功
- 对于第三方平台(尤其时强势的第三方),我们往往无法要求第三方按照我们的参数规则传递参数,这类的接口会包含sql注入的关键字
- 在请求重构过程,可能会改变参数的结构,会导致验签失败
- 对post请求,虽然目前前后端大多交互都是通过Json,但如有特殊请求参数可能是非Json格式参数,需要多改类型参数进行兼容
因此,在实现XSS、SQL注入拦截基础上进行优化,移除xss字符集转换且不改变请求参数,增加白名单机制,具体实现如下:
@Slf4j
@Component
@ConfigurationProperties(prefix = "gateway.security.ignore")
@RefreshScope
public class SqLinjectionFilter implements GlobalFilter, Ordered {
private String[] sqlinjectionHttpUrls = new String[0];
@SneakyThrows
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain){
// grab configuration from Config object
log.debug("----自定义防sql注入网关全局过滤器生效----");
ServerHttpRequest serverHttpRequest = exchange.getRequest();
HttpMethod method = serverHttpRequest.getMethod();
String contentType = serverHttpRequest.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
URI uri = exchange.getRequest().getURI();
//1.动态刷新 sql注入的过滤的路径
String path = serverHttpRequest.getURI().getRawPath();
String matchUrls[] = this.getSqlinjectionHttpUrls();
if( AuthUtils.isMatchPath(path, matchUrls)){
log.error("请求【{}】在sql注入过滤白名单中,直接放行", path);
return chain.filter(exchange);
}
Boolean postFlag = (method == HttpMethod.POST || method == HttpMethod.PUT) &&
(MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(contentType) || MediaType.APPLICATION_JSON_VALUE.equals(contentType));
//过滤get请求
if (method == HttpMethod.GET) {
String rawQuery = uri.getRawQuery();
if (StringUtils.isBlank(rawQuery)){
return chain.filter(exchange);
}
log.debug("请求参数为:{}", rawQuery);
// 执行sql注入校验清理
boolean chkRet = SqLinjectionRuleUtils.getRequestSqlKeyWordsCheck(rawQuery);
// 如果存在sql注入,直接拦截请求
if (chkRet) {
log.error("请求【" + uri.getRawPath() + uri.getRawQuery() + "】参数中包含不允许sql的关键词, 请求拒绝");
return setUnauthorizedResponse(exchange);
}
//透传参数,不对参数做任何处理
return chain.filter(exchange);
}
//post请求时,如果是文件上传之类的请求,不修改请求消息体
else if (postFlag){
return DataBufferUtils.join(serverHttpRequest.getBody()).flatMap(d -> Mono.just(Optional.of(d))).defaultIfEmpty(
Optional.empty())
.flatMap(optional -> {
// 取出body中的参数
String bodyString = "";
if (optional.isPresent()) {
byte[] oldBytes = new byte[optional.get().readableByteCount()];
optional.get().read(oldBytes);
bodyString = new String(oldBytes, StandardCharsets.UTF_8);
}
HttpHeaders httpHeaders = serverHttpRequest.getHeaders();
// 执行XSS清理
log.debug("{} - [{}] 请求参数:{}", method, uri.getPath(), bodyString);
if (MediaType.APPLICATION_JSON_VALUE.equals(contentType)) {
//如果MediaType是json才执行json方式验证
chkRet = SqLinjectionRuleUtils.postRequestSqlKeyWordsCheck(bodyString);
} else {
//form表单方式,需要走get请求
chkRet = SqLinjectionRuleUtils.getRequestSqlKeyWordsCheck(bodyString);
}
// 如果存在sql注入,直接拦截请求
if (chkRet) {
log.error("{} - [{}] 参数:{}, 包含不允许sql的关键词,请求拒绝", method, uri.getPath(), bodyString);
return setUnauthorizedResponse(exchange);
}
ServerHttpRequest newRequest = serverHttpRequest.mutate().uri(uri).build();
// 重新构造body
byte[] newBytes = bodyString.getBytes(StandardCharsets.UTF_8);
DataBuffer bodyDataBuffer = toDataBuffer(newBytes);
Flux<DataBuffer> bodyFlux = Flux.just(bodyDataBuffer);
// 重新构造header
HttpHeaders headers = new HttpHeaders();
headers.putAll(httpHeaders);
// 由于修改了传递参数,需要重新设置CONTENT_LENGTH,长度是字节长度,不是字符串长度
int length = newBytes.length;
headers.remove(HttpHeaders.CONTENT_LENGTH);
headers.setContentLength(length);
headers.set(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf8");
// 重写ServerHttpRequestDecorator,修改了body和header,重写getBody和getHeaders方法
newRequest = new ServerHttpRequestDecorator(newRequest) {
@Override
public Flux<DataBuffer> getBody() {
return bodyFlux;
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
};
return chain.filter(exchange.mutate().request(newRequest).build());
});
} else {
return chain.filter(exchange);
}
}
// 自定义过滤器执行的顺序,数值越大越靠后执行,越小就越先执行
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
/**
* 设置403拦截状态
*/
private Mono<Void> setUnauthorizedResponse(ServerWebExchange exchange) {
return WebfluxResponseUtil.responseFailed(exchange, HttpStatus.FORBIDDEN.value(),
"request is forbidden, SQL keywords are not allowed in the parameters.");
}
/**
* 字节数组转DataBuffer
*
* @param bytes 字节数组
* @return DataBuffer
*/
private DataBuffer toDataBuffer(byte[] bytes) {
NettyDataBufferFactory nettyDataBufferFactory = new NettyDataBufferFactory(ByteBufAllocator.DEFAULT);
DataBuffer buffer = nettyDataBufferFactory.allocateBuffer(bytes.length);
buffer.write(bytes);
return buffer;
}
public String[] getSqlinjectionHttpUrls() {
return sqlinjectionHttpUrls;
}
public void setSqlinjectionHttpUrls(String[] sqlinjectionHttpUrls) {
this.sqlinjectionHttpUrls = sqlinjectionHttpUrls;
}
}
@Slf4j
public class SqLinjectionRuleUtils {
private static String badStrReg = "\\b(and|or)\\b.{1,6}?(=|>|<|\\bin\\b|\\blike\\b)|\\/\\*.+?\\*\\/|<\\s*script\\b|\\bEXEC\\b|UNION.+?SELECT|UPDATE.+?SET|INSERT\\s+INTO.+?VALUES|(SELECT|DELETE).+?FROM|(CREATE|ALTER|DROP|TRUNCATE)\\s+(TABLE|DATABASE)";
private static Pattern sqlPattern = Pattern.compile(badStrReg, Pattern.CASE_INSENSITIVE);//整体都忽略大小写
/**
* get请求sql注入校验
* @param value
* @return
*/
public static boolean getRequestSqlKeyWordsCheck(String value) throws UnsupportedEncodingException {
//参数需要url编码
//这里需要将参数转换为小写来处理
//不改变原值
//value示例 order=asc&pageNum=1&pageSize=100&parentId=0
String lowerValue = URLDecoder.decode(value, "UTF-8").toLowerCase();
//获取到请求中所有参数值-取每个key=value组合第一个等号后面的值
return Stream.of(lowerValue.split("\\&"))
.map(kp -> kp.substring(kp.indexOf("=") + 1))
.parallel()
.anyMatch(param -> {
if (sqlPattern.matcher(param).find())
{
log.error("参数中包含不允许sql的关键词");
return true;
}
return false;
});
}
/**
* post请求sql注入校验
* @param value
* @return
*/
public static boolean postRequestSqlKeyWordsCheck(String value){
Object jsonObj = JSON.parse(value);
if (jsonObj instanceof JSONObject) {
JSONObject json = (JSONObject) jsonObj;
Map<String, Object> map = json;
//对post请求参数值进行sql注入检验
return map.entrySet().stream().parallel().anyMatch(entry -> {
//这里需要将参数转换为小写来处理
String lowerValue = Optional.ofNullable(entry.getValue())
.map(Object::toString)
.map(String::toLowerCase)
.orElse("");
if (sqlPattern.matcher(lowerValue).find())
{
log.error("参数[{}]中包含不允许sql的关键词", lowerValue);
return true;
}
return false;
});
} else {
JSONArray json = (JSONArray) jsonObj;
List<Object> list = json;
//对post请求参数值进行sql注入检验
return list.stream().parallel().anyMatch(obj -> {
//这里需要将参数转换为小写来处理
String lowerValue = Optional.ofNullable(obj)
.map(Object::toString)
.map(String::toLowerCase)
.orElse("");
if (sqlPattern.matcher(lowerValue).find())
{
log.error("参数[{}]中包含不允许sql的关键词", lowerValue);
return true;
}
return false;
});
}
}
ps:网关全局拦截影响应用所有请求,拦截规则和对请求类型的兼容还需要根据项目线上实际情况进行调整。