目标
不暴露Druid内置的servlet到公网(防止被爆破、防止Druid出现 0 Day漏洞后被直接波及)。拦截请求,使用自定义鉴权机制,再放行请求。
版本信息
- Java 17
- SpringBoot 2.7.3
- druid-spring-boot-starter 1.2.12
- Apache Tika 2.4.1
application.yml
spring:
thymeleaf:
cache: false
datasource:
username: xfl
password: amazingxfl666
url: jdbc:mysql://localhost:3306/xfl_mybigdata?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
# druid 其它配置
druid:
initialSize: 5
minIdle: 5
maxActive: 60
maxWait: 120000
defaultAutoCommit: true
poolPreparedStatements: true
maxPoolPreparedStatementPerConnectionSize: 50
aop-patterns: cc.xfl12345.mybigdata.server.*
use-global-data-source-stat: true
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;
filters: config,stat,slf4j,wall # wall用于防火墙
filter:
wall:
config:
alter-table-allow: true
# 允许一次执行多条语句
multi-statement-allow: true
# 允许非基本语句的其他语句
none-base-statement-allow: true
# 是否允许重置数据 (已设计成必填。安全相关,必须设置)
stat-view-servlet:
reset-enable: false
#是否启用StatFilter默认值false,用于采集 web-jdbc 关联监控的数据。
web-stat-filter:
enabled: true
#排除一些静态资源,以提高效率
exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"
#需要监控的 url
url-pattern: "/*"
session-stat-enable: true # 开启session统计功能
session-stat-max-count: 1000 # session的最大个数,默认100
sql:
init:
encoding: UTF-8
jackson:
date-format: yyyy-MM-dd HH:mm:ss.SSS
property-naming-strategy: LOWER_CAMEL_CASE
time-zone: GMT+8
default-property-inclusion: non_null
servlet:
multipart:
max-file-size: -1
mvc:
converters:
preferred-json-mapper: jackson
view:
prefix: /WEB-INF/views/
suffix: .jsp
contentnegotiation:
favor-parameter: true
data:
rest:
default-media-type: application/json
server:
port: 8880
tomcat:
accesslog:
enabled: true
encoding: UTF-8
ipv6-canonical: true
remoteip:
protocol-header: X-Forwarded-Proto
use-relative-redirects: true
servlet:
encoding:
enabled: true
charset: UTF-8
force-response: true
forward-headers-strategy: native
http2:
enabled: true
debug: true
SpringBoot 配置 SpringMVC
package cc.xfl12345.mybigdata.server.mysql.spring.boot.conf;
import cc.xfl12345.mybigdata.server.mysql.spring.web.controller.DruidStatController;
import cc.xfl12345.mybigdata.server.mysql.spring.web.interceptor.DruidStatInterceptor;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@ComponentScan(basePackageClasses = DruidStatController.class)
public class DruidSpringMvcConfig implements WebMvcConfigurer {
@Getter
protected DruidStatInterceptor druidStatInterceptor;
@Autowired
public void setDruidStatInterceptor(DruidStatInterceptor druidStatInterceptor) {
this.druidStatInterceptor = druidStatInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 Druid 的路由拦截器
registry.addInterceptor(druidStatInterceptor).addPathPatterns(
String.format("/%s/**", DruidStatController.servletName)
);
}
}
DruidStatInterceptor.java
package cc.xfl12345.mybigdata.server.mysql.spring.web.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 拦截 Druid 监视器请求。限制访问。
*/
@Slf4j
public class DruidStatInterceptor implements HandlerInterceptor {
/**
* 拦截请求
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// System.out.println("拦截请求");
return true;
}
/**
* 拦截响应
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
// System.out.println("拦截响应");
}
/**
* 拦截渲染
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
// System.out.println("拦截渲染");
}
}
DruidStatController.java
package cc.xfl12345.mybigdata.server.mysql.spring.web.controller;
import com.alibaba.druid.stat.DruidStatService;
import lombok.extern.slf4j.Slf4j;
import org.apache.tika.Tika;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.http.HttpMethod;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Writer;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
@RestController
@Slf4j
@RequestMapping(DruidStatController.servletName)
public class DruidStatController implements InitializingBean {
protected DruidStatService statService = DruidStatService.getInstance();
public static final String servletName = "druid";
public static final String servletPathCache1 = "/" + servletName;
// 为安全考虑,强制必须设置 reset-enable 的值
@Value("${spring.datasource.druid.stat-view-servlet.reset-enable}")
public void setResetEnable(boolean resetEnable) {
statService.setResetEnable(resetEnable);
}
public boolean isResetEnable() {
return statService.isResetEnable();
}
protected String resourceRootPath = "support/http/resources/";
protected URL rootFileURL;
protected String rootFileUrlString;
protected ConcurrentHashMap<String, ResourceDetail> druidFrontendFiles = new ConcurrentHashMap<>();
public static class ResourceDetail {
public Resource resource;
public String mimeType;
public String path;
}
@Override
public void afterPropertiesSet() throws Exception {
Tika tika = new Tika();
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
rootFileURL = Objects.requireNonNull(classLoader.getResource(resourceRootPath));
rootFileUrlString = rootFileURL.toString();
// String rootFileClasspathBase = rootFileUrlString.substring(0, rootFileUrlString.lastIndexOf(resourceRootPath));
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(resourceRootPath + "**");
for (Resource resource : resources) {
URL currentFileURL = resource.getURL();
String relativePath;
if (resource instanceof ClassPathResource classPathResource) {
relativePath = '/' + classPathResource.getPath();
} else {
relativePath = '/' + currentFileURL.toString().substring(rootFileUrlString.length());
}
relativePath = relativePath.substring(resourceRootPath.length());
int lastIndexOfSplitChar = relativePath.lastIndexOf('/');
// 如果是文件,而不是文件夹
if (relativePath.length() - 1 > lastIndexOfSplitChar) {
String filename = relativePath.substring(lastIndexOfSplitChar + 1);
ResourceDetail detail = new ResourceDetail();
detail.resource = resource;
detail.path = relativePath;
try (InputStream inputStream = resource.getInputStream()) {
detail.mimeType = tika.detect(inputStream, filename);
}
log.debug(relativePath + " <---> " + currentFileURL.toString());
druidFrontendFiles.put(relativePath, detail);
}
}
}
@GetMapping(path = {"", "index"})
public void redirectIndexPage(HttpServletResponse response) throws IOException {
response.sendRedirect("./index.html");
}
@RequestMapping("/**")
public void forward(HttpServletRequest request, HttpServletResponse response) throws IOException {
String relativeURL = request.getServletPath().substring(servletPathCache1.length());
ResourceDetail resourceDetail = druidFrontendFiles.get(relativeURL);
// 如果命中了静态资源,则直接返回文件。
if (resourceDetail != null) {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(resourceDetail.mimeType);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
InputStream is = resourceDetail.resource.getInputStream();
OutputStream os = response.getOutputStream();
try (is; os) {
// 8 KiB buffer
byte[] buffer = new byte[((1 << 10) << 3)];
int bytesRead;
while ((bytesRead = is.read(buffer)) > 0) {
os.write(buffer, 0, bytesRead);
}
}
} else {
if (request.getMethod().equalsIgnoreCase(HttpMethod.GET.name())) {
String httpGetQueryString = request.getQueryString();
if (httpGetQueryString != null && !httpGetQueryString.isEmpty()) {
relativeURL += '?' + httpGetQueryString;
}
}
response.setContentType("application/json;charset=UTF-8");
try (Writer writer = response.getWriter()) {
writer.write(statService.service(relativeURL));
}
}
}
}
趟坑思路&历程
- 直接狂翻Druid官方说明文档,看看有没有SpringMVC拦截数据监控页面的方法(反正我是没有找到)
- 度娘一下,看看有没有别人做出来了。结果只找到这个 而且还是不知所云的那一类。(鲁迅曾说过,生命是以时间为单位的,浪费别人的时间等于谋财害命。所以,遇到这一类作者,该骂的还得骂!)
- 直接使用SpringMVC拦截器 拦截 Druid 内置的 Servlet (无效,因为 拦截器只对 Controller 起作用,通过 ServletRegistrationBean 注册的Servlet不归SpringMVC管。虽然不使用ServletRegistrationBean注册Servlet还有别的办法拦截请求,但太麻烦)
- 直接把Druid Jar包里的资源文件原地引用,作为SpringMVC的静态资源,动态资源拦截即可
- 直接把Druid Jar包里的资源文件复制出来放到 webapp 文件夹,作为SpringMVC的静态资源。直接指定classpath路径,当前Jar包内引用。然后动态资源拦截即可。(代价是浪费两倍空间,明明 Druid Jar包里就有。而且和官方同步比较麻烦)
- 直接使用 Servlet 的 forward 大法转发访问 Druid 内置的 Servlet ,并配置 Druid 只允许本机访问(依然只能本机访问,无法实现代理拦截效果,传到 SpringMVC 的 Controller 层 的 HttpServletRequest 是约定不允许修改的)
- 学习使用 SpringCloud里的网关代理 或者 学习使用各大佬实现的现成的 ProxyServlet 工具 转发访问 Druid 内置的 Servlet ,并配置 Druid 只允许本机访问(太复杂,而且session这些问题不好控制。学习成本高,遂放弃)
一开始折腾第四点没成功,因为不熟悉SpringMVC,不知道SpringMVC 的 Resource大法支持通过万能的URL/URI方式引用资源。所以绕了个大弯,还是抱着破罐子破摔万一可行的心态摸索了一下SpringMVC API源码,继续走第四点,看看能不能走得通。现在看来是真的可行。
分享成功的喜悦(发表试验成功的感言):OHHHHHHHHHHHHHHHHHHHHHHH!!!
2022年10月1日更新
发现原来的路子有 BUG,导致一些 API 不能被正常访问。
遂放弃原来的结构,使用 Controller 全面接管,实现了一个简单的 “半Servlet” 。
(这下应该没有问题了吧……逃)