SpringBoot(SpringMVC)拦截Druid数据监控页面

目标

不暴露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));
            }
        }
    }
}


趟坑思路&历程

  1. 直接狂翻Druid官方说明文档,看看有没有SpringMVC拦截数据监控页面的方法(反正我是没有找到)
  2. 度娘一下,看看有没有别人做出来了。结果只找到这个https://blog.csdn.net/weixin_44216706/article/details/106189393 而且还是不知所云的那一类。(鲁迅曾说过,生命是以时间为单位的,浪费别人的时间等于谋财害命。所以,遇到这一类作者,该骂的还得骂!)
  3. 直接使用SpringMVC拦截器 拦截 Druid 内置的 Servlet (无效,因为 拦截器只对 Controller 起作用,通过 ServletRegistrationBean 注册的Servlet不归SpringMVC管。虽然不使用ServletRegistrationBean注册Servlet还有别的办法拦截请求,但太麻烦)
  4. 直接把Druid Jar包里的资源文件原地引用,作为SpringMVC的静态资源,动态资源拦截即可
  5. 直接把Druid Jar包里的资源文件复制出来放到 webapp 文件夹,作为SpringMVC的静态资源。直接指定classpath路径,当前Jar包内引用。然后动态资源拦截即可。(代价是浪费两倍空间,明明 Druid Jar包里就有。而且和官方同步比较麻烦)
  6. 直接使用 Servlet 的 forward 大法转发访问 Druid 内置的 Servlet ,并配置 Druid 只允许本机访问(依然只能本机访问,无法实现代理拦截效果,传到 SpringMVC 的 Controller 层 的 HttpServletRequest 是约定不允许修改的)
  7. 学习使用 SpringCloud里的网关代理 或者 学习使用各大佬实现的现成的 ProxyServlet 工具 转发访问 Druid 内置的 Servlet ,并配置 Druid 只允许本机访问(太复杂,而且session这些问题不好控制。学习成本高,遂放弃)

一开始折腾第四点没成功,因为不熟悉SpringMVC,不知道SpringMVC 的 Resource大法支持通过万能的URL/URI方式引用资源。所以绕了个大弯,还是抱着破罐子破摔万一可行的心态摸索了一下SpringMVC API源码,继续走第四点,看看能不能走得通。现在看来是真的可行。

分享成功的喜悦(发表试验成功的感言):OHHHHHHHHHHHHHHHHHHHHHHH!!!

2022年10月1日更新

发现原来的路子有 BUG,导致一些 API 不能被正常访问。
遂放弃原来的结构,使用 Controller 全面接管,实现了一个简单的 “半Servlet”
(这下应该没有问题了吧……逃)

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值