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 {//重构请求uriURI 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@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain){// grab configuration from Config objectlog.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 requestURI 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();// 重新构造bodybyte[] newBytes = bodyString.getBytes(StandardCharsets.UTF_8);DataBuffer bodyDataBuffer = toDataBuffer(newBytes);Flux<DataBuffer> bodyFlux = Flux.just(bodyDataBuffer);// 重新构造headerHttpHeaders 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) {@Overridepublic Flux<DataBuffer> getBody() {return bodyFlux;}@Overridepublic HttpHeaders getHeaders() {return headers;}};return chain.filter(exchange.mutate().request(newRequest).build());});} else {return chain.filter(exchange);}}// 自定义过滤器执行的顺序,数值越大越靠后执行,越小就越先执行@Overridepublic 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=0String 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正则匹配的方式(见代码),已和安全工程师完成联调,能够挡住安全工程师的注入测试案例,对业务也完成回归测试,基本不影响现有业务正常运行。
2. get请求拦截过程不要对源参数进行url编码,否则应用可能出现不必要的错误
get请求参数中的中文字符以及一些特色字符请求到服务器会自动编码,在对xss注入过滤过程需要进行url编码才能进行过滤规则的验证,在起初我们是在源参数上进行编码,但是一些正常请求中携带+号这样的特色符号的请求在处理过程会被过滤掉,该问题排查了许久才发现的,因此建议不要改变源请求参数的编码格式
//参数需要url编码//这里需要将参数转换为小写来处理//不改变原值//value示例 order=asc&pageNum=1&pageSize=100&parentId=0String 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@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain){// grab configuration from Config objectlog.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();// 重新构造bodybyte[] newBytes = bodyString.getBytes(StandardCharsets.UTF_8);DataBuffer bodyDataBuffer = toDataBuffer(newBytes);Flux<DataBuffer> bodyFlux = Flux.just(bodyDataBuffer);// 重新构造headerHttpHeaders 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) {@Overridepublic Flux<DataBuffer> getBody() {return bodyFlux;}@Overridepublic HttpHeaders getHeaders() {return headers;}};return chain.filter(exchange.mutate().request(newRequest).build());});} else {return chain.filter(exchange);}}// 自定义过滤器执行的顺序,数值越大越靠后执行,越小就越先执行@Overridepublic 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=0String 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:网关全局拦截影响应用所有请求,拦截规则和对请求类型的兼容还需要根据项目线上实际情况进行调整。