SQL注入是常见的系统安全问题之一,用户通过特定方式向系统发送SQL脚本,可直接自定义操作系统数据库,如果系统没有对SQL注入进行拦截,那么用户甚至可以直接对数据库进行增删改查等操作。
XSS全称为Cross Site Script跨站点脚本攻击,和SQL注入类似,都是通过特定方式向系统发送攻击脚本,对系统进行控制和侵害。SQL注入主要以攻击数据库来达到攻击系统的目的,而XSS则是以恶意执行前端脚本来攻击系统。
项目框架中使用mybatis/mybatis-plus数据持久层框架,在使用过程中,已有规避SQL注入的规则和使用方法。但是在实际开发过程中,由于各种原因,开发人员对持久层框架的掌握水平不同,有些特殊业务情况必须从前台传入SQL脚本。这时就需要对系统进行加固,防止特殊情况下引起的系统风险。
在微服务架构下,我们考虑如何实现SQL注入/XSS攻击拦截时,肯定不会在每个微服务都实现一遍SQL注入/XSS攻击拦截。根据我们微服务系统的设计,所有的请求都会经过Gateway网关,所以在实现时就可以参照前面的日志拦截器来实现。在接收到一个请求时,通过拦截器解析请求参数,判断是否有SQL注入/XSS攻击参数,如果有,那么返回异常即可。
我们前面在对微服务Gateway进行自定义扩展时,增加了Gateway插件功能。我们会根据系统需求开发各种Gateway功能扩展插件,并且可以根据系统配置文件来启用/禁用这些插件。下面我们就将防止SQL注入/XSS攻击拦截器作为一个Gateway插件来开发和配置
处理方法 :
新增SqlInjectionFilter 过滤器和XssInjectionFilter过滤器,分别用于解析请求参数并对参数进行判断是否存在SQL注入/XSS攻脚本。此处有公共判断方法,通过配置文件来读取请求的过滤配置,因为不是多有的请求都会引发SQL注入和XSS攻击,如果无差别的全部拦截和请求,那么势必影响到系统的性能。
1.首先新建 CacheBodyGlobalFilter.java
这个过滤器解决body不能重复读的问题(在低版本的spring-cloud不需要这个过滤器),为后续的XssRequestGlobalFilter重写请求body做准备。
package com.zhdj.gateway.filter;
import com.zhdj.gateway.utils.WebFluxUtils;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* 全局缓存获取body请求数据(解决流不能重复读取问题)
*
* @author Lion Li
*/
@Component
public class GlobalCacheRequestFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 只缓存json类型请求
if (!WebFluxUtils.isJsonRequest(exchange)) {
return chain.filter(exchange);
}
return ServerWebExchangeUtils.cacheRequestBody(exchange, (serverHttpRequest) -> {
if (serverHttpRequest == exchange.getRequest()) {
return chain.filter(exchange);
}
return chain.filter(exchange.mutate().request(serverHttpRequest).build());
});
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 1;
}
}
2.XSS跨站脚本配置
package com.zhdj.gateway.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.List;
/**
* XSS跨站脚本配置
*
* @author ruoyi
*/
@Data
@Configuration
@RefreshScope
@ConfigurationProperties(prefix = "security.xss")
public class XssProperties {
/**
* Xss开关
*/
private Boolean enabled;
/**
* 排除路径
*/
private List<String> excludeUrls = new ArrayList<>();
}
3.Sql注入配置
package com.zhdj.gateway.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.List;
/**
* Sql注入
*
* @author ruoyi
*/
@Data
@Configuration
@RefreshScope
@ConfigurationProperties(prefix = "security.sql")
public class SqlProperties {
/**
* Sql开关
*/
private Boolean enabled;
/**
* 排除路径
*/
private List<String> excludeUrls = new ArrayList<>();
}
4.跨站脚本过滤器
package com.zhdj.gateway.filter;
import cn.hutool.http.HtmlUtil;
import com.zhdj.common.core.utils.StringUtils;
import com.zhdj.gateway.config.properties.XssProperties;
import com.zhdj.gateway.utils.WebFluxUtils;
import io.netty.buffer.ByteBufAllocator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
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;
/**
* 跨站脚本过滤器
*
* @author ruoyi
*/
@Component
@ConditionalOnProperty(value = "security.xss.enabled", havingValue = "true")
public class XssFilter implements GlobalFilter, Ordered {
// 跨站脚本的 xss 配置,nacos自行添加
@Autowired
private XssProperties xss;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// GET DELETE 不过滤
HttpMethod method = request.getMethod();
if (method == null || method == HttpMethod.GET || method == HttpMethod.DELETE) {
return chain.filter(exchange);
}
// 非json类型,不过滤
if (!WebFluxUtils.isJsonRequest(exchange)) {
return chain.filter(exchange);
}
// excludeUrls 不过滤
String url = request.getURI().getPath();
if (StringUtils.matches(url, xss.getExcludeUrls())) {
return chain.filter(exchange);
}
ServerHttpRequestDecorator httpRequestDecorator = requestDecorator(exchange);
return chain.filter(exchange.mutate().request(httpRequestDecorator).build());
}
private ServerHttpRequestDecorator requestDecorator(ServerWebExchange exchange) {
ServerHttpRequestDecorator serverHttpRequestDecorator = new ServerHttpRequestDecorator(exchange.getRequest()) {
@Override
public Flux<DataBuffer> getBody() {
Flux<DataBuffer> body = super.getBody();
return body.buffer().map(dataBuffers -> {
DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
DataBuffer join = dataBufferFactory.join(dataBuffers);
byte[] content = new byte[join.readableByteCount()];
join.read(content);
DataBufferUtils.release(join);
String bodyStr = new String(content, StandardCharsets.UTF_8);
// 防xss攻击过滤
bodyStr = HtmlUtil.cleanHtmlTag(bodyStr);
// 转成字节
byte[] bytes = bodyStr.getBytes();
NettyDataBufferFactory nettyDataBufferFactory = new NettyDataBufferFactory(ByteBufAllocator.DEFAULT);
DataBuffer buffer = nettyDataBufferFactory.allocateBuffer(bytes.length);
buffer.write(bytes);
return buffer;
});
}
@Override
public HttpHeaders getHeaders() {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.putAll(super.getHeaders());
// 由于修改了请求体的body,导致content-length长度不确定,因此需要删除原先的content-length
httpHeaders.remove(HttpHeaders.CONTENT_LENGTH);
httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
return httpHeaders;
}
};
return serverHttpRequestDecorator;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}
5.防sql注入
package com.zhdj.gateway.filter;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zhdj.gateway.config.properties.SqlProperties;
import com.zhdj.gateway.config.properties.XssProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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.DataBufferUtils;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @Author {Yangb}
* @Date: 2023/10/30/ 10:40
* @description 防sql注入
*/
@Slf4j
@Component
@ConditionalOnProperty(value = "security.sql.enabled", havingValue = "true")
public class SqlInjectionFilter implements GlobalFilter, Ordered {
@Autowired
private SqlProperties sqlProperties;
/**
* SQL注入正则判断
*/
private static final 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 final Pattern sqlPattern = Pattern.compile(badStrReg, Pattern.CASE_INSENSITIVE);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求路径、请求方法、请求参数
ServerHttpRequest request = exchange.getRequest();
String requestPath = request.getPath().toString();
HttpMethod method = request.getMethod();
MediaType contentType = request.getHeaders().getContentType();
log.info("SqlInjectionFilter start requestPath:{}, method:{}, contentType:{}", requestPath, method, contentType);
// 白名单放行
String[] whiteListArray = sqlProperties.getExcludeUrls().toArray(new String[0]);
for (String path : whiteListArray) {
if (requestPath.startsWith(path)) {
log.info("SqlInjectionFilter white list:" + requestPath);
return chain.filter(exchange);
}
}
// 判断Param是否存在SQL注入
AtomicBoolean isSqlInjection = new AtomicBoolean(false);
request.getQueryParams().forEach((key, values) -> {
for (String value : values) {
if (StringUtils.hasText(value) && checkSqlInjection(value)) {
isSqlInjection.set(true);
return;
}
}
});
if (isSqlInjection.get()) {
return errorResponse(exchange.getResponse());
}
// contentType不为空,一般说明body中有参数,判断body中是否存在SQL注入,如果存在则直接返回错误信息
if (contentType != null) {
return DataBufferUtils.join(exchange.getRequest().getBody()).flatMap(dataBuffer -> {
// 取出body中的参数
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
Flux<DataBuffer> cachedFlux = Flux.defer(() -> {
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
DataBufferUtils.retain(buffer);
return Mono.just(buffer);
});
String bodyString = new String(bytes, StandardCharsets.UTF_8);
if (MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
if (StringUtils.hasText(bodyString) && checkJsonBody(bodyString)) {
isSqlInjection.set(true);
}
} else if (MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(contentType)) {
if (StringUtils.hasText(bodyString) && checkFormUrlencoded(bodyString)) {
isSqlInjection.set(true);
}
} else if (MediaType.MULTIPART_FORM_DATA.isCompatibleWith(contentType)) {
if (StringUtils.hasText(bodyString) && checkFormData(bodyString)) {
isSqlInjection.set(true);
}
}
// 如果存在sql注入,直接拦截请求
if (isSqlInjection.get()) {
log.error("SqlInjectionFilter {} - [{}] 参数:{}, 包含不允许sql的关键词,请求拒绝", method, requestPath, bodyString);
return errorResponse(exchange.getResponse());
}
// 重新包装ServerHttpRequest
ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
@Override
public Flux<DataBuffer> getBody() {
return cachedFlux;
}
};
// 用新的ServerHttpRequest改变交换对象
ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
// 使用默认的messageReaders读取正文字符串
return ServerRequest.create(mutatedExchange, HandlerStrategies.withDefaults().messageReaders()).bodyToMono(String.class).doOnNext(objectValue -> {
log.info("SqlInjectionFilter end requestPath:{}, method:{}, contentType:{}", requestPath, method, contentType);
}).then(chain.filter(mutatedExchange));
});
}
log.info("SqlInjectionFilter end requestPath:{}, method:{}, contentType:{}", requestPath, method, null);
return chain.filter(exchange);
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 2;
}
/**
* 返回错误响应
*
* @param response 响应
* @return Mono<Void>
*/
private Mono<Void> errorResponse(ServerHttpResponse response) {
response.setStatusCode(HttpStatus.BAD_REQUEST);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", HttpStatus.BAD_REQUEST.value());
jsonObject.put("msg", "invalid request params");
String body = jsonObject.toJSONString();
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Flux.just(buffer));
}
/**
* 判断输入的字符串是否包含SQL注入
*
* @param str 输入的字符串
* @return 如果输入的字符串包含SQL注入,返回 true,否则返回 false。
*/
private boolean checkSqlInjection(String str) {
str = str.toLowerCase();
Matcher matcher = sqlPattern.matcher(str);
if (matcher.find()) {
log.error("SqlInjectionFilter 参数[{}]中包含不允许sql的关键词", str);
return true;
}
return false;
}
/**
* 检测application/x-www-form-urlencoded是否包含SQL注入关键字
*
* @param bodyString 请求体
* @return 是否包含SQL注入关键字
*/
private boolean checkFormUrlencoded(String bodyString) {
String[] params = bodyString.split("&");
for (String param : params) {
String[] keyValue = param.split("=");
if (keyValue.length == 2) {
// 判断是否为空
if (StrUtil.isBlank(keyValue[1])) {
continue;
}
if (checkSqlInjection(keyValue[1])) {
return true;
}
}
}
return false;
}
/**
* 检测application/json是否包含SQL注入关键字
*
* @param body 请求体
* @return 是否包含SQL注入关键字
*/
private boolean checkJsonBody(String body) {
try {
ObjectMapper objectMapper = new ObjectMapper();
JsonNode rootNode = objectMapper.readTree(body);
return checkJsonNode(rootNode);
} catch (IOException e) {
log.error("SqlInjectionFilter Error while parsing JSON body", e);
return false;
}
}
private boolean checkJsonNode(JsonNode node) {
if (node.isValueNode()) {
return checkSqlInjection(node.asText());
} else if (node.isObject()) {
Iterator<Map.Entry<String, JsonNode>> fieldsIterator = node.fields();
while (fieldsIterator.hasNext()) {
Map.Entry<String, JsonNode> field = fieldsIterator.next();
// 判断是否为空
if (field.getValue().isNull()) {
continue;
}
if (checkJsonNode(field.getValue())) {
return true;
}
}
} else if (node.isArray()) {
for (JsonNode childNode : node) {
if (checkJsonNode(childNode)) {
return true;
}
}
}
return false;
}
/**
* 检测multipart/form-data是否包含SQL注入关键字
*
* @param bodyString 请求体
* @return 是否包含SQL注入关键字
*/
public boolean checkFormData(String bodyString) {
bodyString = bodyString.replaceAll("\r", "");
String[] parts = bodyString.split("\n");
for (int i = 0; i < parts.length; i++) {
String part = parts[i];
if (part.contains("Content-Disposition: form-data;")) {
// 文件类型不检测
if (part.contains("file")) {
log.info("SqlInjectionFilter file skip");
return false;
}
String value = parts[i + 2];
if (StrUtil.isBlank(value)) {
continue;
}
if (checkSqlInjection(value)) {
return true;
}
}
}
return false;
}
}