找回密码
立即注册
搜索
发回帖 发新帖

6617

积分

1

好友

829

主题
发表于 前天 10:23 | 查看: 18| 回复: 0

一、一个真实的生产事故

2025 年下半年,一家金融科技平台在安全渗透测试中被发现一条高危漏洞:/v3/api-docs 端点完全不用认证就能访问,把全部内部管理接口完整暴露了出来。

攻击者从中能拿到的信息包括:/api/admin/user/freeze(冻结用户)、/api/admin/balance/adjust(手工调账)、/api/admin/audit/export(导出审计日志),以及这些接口的完整参数结构、枚举值和示例数据。即使这些接口本身有权限校验,接口清单和参数结构的泄露也已经是严重的信息安全事件——攻击者可以据此构造精准的越权测试和参数注入攻击。

追溯发现,团队在开发阶段集成了 SpringDoc,application.yml 里只配置了 springdoc.swagger-ui.path,从未在 prod profile 中禁用任何文档端点。更糟的是,由于项目是多微服务架构,每个服务的 /v3/api-docs 都独立暴露;网关的 JWT 过滤器虽然保护了业务接口,但 /v3/api-docs 不在路由匹配范围内,请求直接打到了各服务自身的端口。

事故复盘:

问题 说明
生产环境文档端点未关闭 api-docs.enabled 和 swagger-ui.enabled 均为默认 true
网关认证过滤器未覆盖文档路径 /v3/api-docs 不在路由谓词内,绕过了认证
无安全配置兜底 即使端点未关闭,也没有 Basic/JWT 保护
内部接口与外部接口混在同一文档 管理接口和公开接口共用同一个 /v3/api-docs
网关聚合方案缺失 前端需要逐服务切换地址,没有统一文档入口
未关注依赖安全公告 当时使用的 SpringDoc 版本存在已知的内存耗尽漏洞

安全团队给出的整改要求很明确:生产环境默认关闭文档 UI;/v3/api-docs 要么彻底关闭,要么必须经过认证和网络层白名单;内部接口与外部接口必须分组隔离;网关提供统一的文档入口。

这篇文章完整拆解这套生产级治理方案。

二、问题根源:接口文档的四类生产风险

2.1 风险一:信息泄露——文档端点就是攻击面地图

SpringDoc 默认暴露两个端点:/v3/api-docs(OpenAPI 规范 JSON)和 /swagger-ui/index.html(可视化界面)。这两个端点不经过业务权限体系,只要网络可达,就能拿到完整的接口清单、参数结构、枚举定义和示例数据。

对于内部系统,这相当于把一份持续更新的“攻击面地图”公开挂在公网上。

2.2 风险二:内存耗尽——Accept-Language 触发的缓存膨胀

SpringDoc 的 OpenAPIService 会按照请求的 Accept-Language 语言标签缓存多份完整的 OpenAPI 文档。在 2.9.1 和 3.1.1 之前的版本中,这个缓存没有大小上限,也没有淘汰策略。未认证的调用者可以用不同的合法语言标签反复请求 /v3/api-docs,每请求一次就向堆中添加一份完整文档,最终导致 OutOfMemoryError。

受影响版本为 >= 2.0.0, < 2.9.1 和 >= 3.0.0, < 3.1.1,修复版本是 2.9.1 和 3.1.1。修复后引入了 springdoc.cache.max-entries(默认 100,LRU 淘汰)。

这意味着:即使你不在意文档泄露,只要 /v3/api-docs 对外开放,就存在一个可被利用的 DoS 入口。

2.3 风险三:Springfox 已死,迁移不是换依赖那么简单

Springfox(Swagger 2.x)已停止维护,且存在未修复的 CVE(如 CVE-2021-28170)。从 Springfox 迁移到 SpringDoc,涉及三层变更:

  • 依赖替换:springfox-boot-starter → springdoc-openapi-starter-webmvc-ui(WebMVC)或 springdoc-openapi-starter-webflux-ui(WebFlux/Gateway)。
  • 注解包替换:io.swagger.annotations → io.swagger.v3.oas.annotations,@Api→@Tag,@ApiOperation→@Operation,@ApiModel→@Schema。
  • 配置类替换:多个 Docket Bean → 多个 GroupedOpenApi Bean,或者用 YAML 的 group-configs 替代。

如果只换依赖不换注解,文档会静默丢失大量描述信息,不会报错,但生成的文档形同虚设。

2.4 风险四:微服务下文档碎片化,前端无统一入口

每个服务独立暴露 /v3/api-docs,前端联调时需要记住 N 个地址。网关虽然可以做聚合,但聚合配置和认证过滤器的路径放行规则如果没对齐,会出现“文档页面能打开,但下拉框里的服务全部空白”或“文档请求被网关 401 拦截”的典型故障。

三、核心技术原理

3.1 端点与配置模型

SpringDoc 的核心端点:

端点 作用 生产处置
/v3/api-docs OpenAPI JSON 规范 默认关闭,或严格认证+白名单
/v3/api-docs.yaml OpenAPI YAML 规范 同上
/swagger-ui/index.html 可视化 UI 生产默认关闭

核心开关:

springdoc:
  api-docs:
    enabled: false      # 关闭JSON/YAML端点
  swagger-ui:
    enabled: false      # 关闭UI

api-docs.enabled=false 会同时关闭 JSON 和 YAML 端点。

3.2 分组机制(GroupedOpenApi / group-configs)

分组的本质是按路径或包路径生成多份独立的 OpenAPI 文档,每份文档对应一个独立的 /v3/api-docs/{group} 端点。生产环境的标准做法是至少拆成两组:

分组 路径 可见性
external /api/public/** 对外,可在受控条件下暴露
internal /api/admin/**、/api/internal/** 仅内网,生产默认关闭

YAML 配置方式:

springdoc:
  group-configs:
    - group: external
      paths-to-match: /api/public/**
    - group: internal
      paths-to-match: /api/admin/**,/api/internal/**

Java 配置方式等价于 GroupedOpenApi.builder().group(...).pathsToMatch(...)。

3.3 网关聚合的两种模式

Spring Cloud Gateway 聚合下游文档有两种路径:

模式一:网关自身作为文档宿主,下游文档 URL 通过 springdoc.swagger-ui.urls 注册。 网关的 Swagger UI 读取 /v3/api-docs/swagger-config,其中列出各个下游服务的文档 URL,UI 显示为下拉框。

模式二:网关通过 GroupedOpenApi 动态构建分组,按服务 ID 或路径前缀聚合。 需要在网关侧遍历路由配置或服务发现结果,为每个下游生成一个 GroupedOpenApi Bean。

生产选择:模式一更轻量,适合下游服务已经稳定暴露 /v3/api-docs 的场景;模式二更灵活,但需要在网关侧处理路径剥离和认证放行,复杂度更高。

3.4 缓存与语言安全

SpringDoc 的 OpenAPIService 按 Accept-Language 缓存文档。修复后的版本通过 springdoc.cache.max-entries 限制缓存条目数(默认 100,LRU 淘汰)。同时应设置 springdoc.allowed-locales,只允许应用实际支持的语言,其他语言标签回退到默认语言,防止攻击者用无穷多的合法语言标签制造缓存条目。

四、实现方案及场景

4.1 本文采用的方案

组件 作用
springdoc-openapi-starter-webmvc-ui WebMVC 服务,版本 ≥ 2.9.1
springdoc-openapi-starter-webflux-ui Gateway(WebFlux),版本与 WebMVC 对齐
@Profile 条件化配置类 生产环境不注册 OpenAPI Bean,非生产环境生效
Security 条件放行 文档路径仅在内网/认证后可达
group-configs external/internal 分组隔离
springdoc.allowed-locales 语言缓存防膨胀
网关 urls 聚合 统一文档入口
CI 依赖扫描 阻止回退到受影响版本

4.2 适用场景

场景 推荐
单体服务,有外部 API external 分组 + 生产关闭 UI,api-docs 按需保留或关闭
微服务 + 网关 网关聚合 + 各服务内网暴露
内部管理后台 internal 分组,生产环境 enabled=false
Springfox 遗留系统 依赖+注解+配置三件套迁移

五、完整实现

5.1 依赖选型

WebMVC 服务(业务微服务):

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.9.1</version>
</dependency>

WebFlux/Gateway:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
    <version>2.9.1</version>
</dependency>

版本规则:版本规则上,Spring Boot 3.x 必须搭配 SpringDoc 2.x;其中 2.9.1 和 3.1.1 是当前已知内存耗尽漏洞的最低修复版本,生产环境不要低于这两个版本。

5.2 条件化 OpenAPI 配置(生产不注册 Bean)

仅仅在 YAML 中设置 enabled=false 有时不够——如果代码中仍然注册了 OpenAPI Bean 或 GroupedOpenApi Bean,启动时仍会进行扫描和构建,产生不必要的开销,并且在某些组合下可能暴露端点。更严格的做法是用 @Profile 控制配置类的加载:

@Configuration
@Profile("!prod")
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Order Service API")
                        .version("1.0.0")
                        .description("订单服务接口文档(非生产环境)"))
                .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
                .components(new Components()
                        .addSecuritySchemes("bearerAuth",
                                new SecurityScheme()
                                        .type(SecurityScheme.Type.HTTP)
                                        .scheme("bearer")
                                        .bearerFormat("JWT")));
    }
}

prod profile 下这个配置类完全不加载,OpenAPI Bean 不存在,从源头上消除了文档端点的注册基础。

5.3 分组配置

@Configuration
@Profile("!prod")
public class OpenApiGroupConfig {

    @Bean
    public GroupedOpenApi externalApi() {
        return GroupedOpenApi.builder()
                .group("external")
                .pathsToMatch("/api/public/**")
                .build();
    }

    @Bean
    public GroupedOpenApi internalApi() {
        return GroupedOpenApi.builder()
                .group("internal")
                .pathsToMatch("/api/admin/**", "/api/internal/**")
                .build();
    }
}

或者等价的 YAML:

springdoc:
  group-configs:
    - group: external
      paths-to-match: /api/public/**
    - group: internal
      paths-to-match: /api/admin/**,/api/internal/**

分组的意义:internal 分组只在内网/预发环境启用;即使 external 分组在受控条件下暴露,内部管理接口也不会出现在其中。

5.4 生产环境 YAML(严格关闭)

# application-prod.yml
springdoc:
  api-docs:
    enabled: false
  swagger-ui:
    enabled: false
  cache:
    max-entries: 50          # 即使未来临时启用,也限制缓存上限
  allowed-locales: zh-CN,en  # 只允许应用实际支持的语言

三个关键点:

  • api-docs.enabled=false 关闭 JSON/YAML 端点。
  • swagger-ui.enabled=false 关闭 UI。
  • allowed-locales 和 cache.max-entries 是纵深防御——即使未来某次变更临时开启了端点,也不会因为 Accept-Language 导致缓存无限增长。

5.5 Spring Security 条件放行(非生产环境)

在非生产环境,文档端点通常需要放行给开发和测试使用。下面这段 Spring Security条件放行 配置就是这个思路,但它绝不能原样出现在 prod profile 里,否则 permitAll() 会直接打开安全口子:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Value("${spring.profiles.active:dev}")
    private String activeProfile;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> {
            auth.requestMatchers("/actuator/health").permitAll();

            if (!"prod".equals(activeProfile)) {
                auth.requestMatchers(
                        "/v3/api-docs/**",
                        "/swagger-ui/**",
                        "/swagger-ui.html"
                ).permitAll();
            }

            auth.anyRequest().authenticated();
        });
        return http.build();
    }
}

注意:/v3/api-docs/**(带 /**)覆盖了分组路径 /v3/api-docs/external 和 /v3/api-docs/internal。如果只写 /v3/api-docs,分组文档可能无法访问。

5.6 网关聚合配置

这套 网关聚合配置 使用 WebFlux 版本的 SpringDoc,通过 urls 注册下游文档入口:

# gateway application.yml(非生产)
springdoc:
  swagger-ui:
    enabled: true
    urls:
      - name: order-service
        url: /order-service/v3/api-docs
      - name: product-service
        url: /product-service/v3/api-docs
    path: /swagger-ui.html
  api-docs:
    enabled: false    # 网关自身不需要暴露文档

同时网关需要为下游文档路径配置路由(带 StripPrefix),确保 /order-service/v3/api-docs 能正确转发到 order-service 的 /v3/api-docs。认证过滤器必须显式放行文档相关路径,否则就会出现典型的网关认证未放行文档路径故障:Swagger UI 能打开,但下拉框里的文档全部加载失败。

5.7 注解规范(迁移后的写法)

从 Springfox 迁移后,统一使用 io.swagger.v3.oas.annotations:

@Tag(name = "订单管理", description = "订单查询与操作接口")
@RestController
@RequestMapping("/api/public/order")
public class OrderController {

    @Operation(
            summary = "查询订单详情",
            description = "根据订单ID返回订单完整信息",
            operationId = "getOrderById"
    )
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "成功",
                    content = @Content(schema = @Schema(implementation = OrderVO.class))),
            @ApiResponse(responseCode = "404", description = "订单不存在")
    })
    @GetMapping("/{id}")
    public OrderVO getOrder(
            @Parameter(description = "订单ID", required = true, example = "1001")
            @PathVariable Long id) {
        return orderService.getById(id);
    }
}

关键注解对照:

Springfox SpringDoc
@Api @Tag
@ApiOperation @Operation
@ApiModel @Schema
@ApiModelProperty @Schema
@ApiParam @Parameter
@ApiIgnore @Parameter(hidden = true) / @Operation(hidden = true) / @Hidden

5.8 隐藏敏感接口

即使在内网文档中,某些接口也不应该出现在文档里(如数据修复、调试接口)。使用 @Hidden 或 @Operation(hidden = true):

@Hidden
@RestController
@RequestMapping("/api/internal/debug")
public class DebugController { ... }

或者在方法上:

@Operation(hidden = true)
@PostMapping("/api/admin/repair")
public void repair() { ... }

六、进阶优化

6.1 分组对性能的影响

GroupedOpenApi 的按路径/包扫描会减少单份文档的模型数量,但多分组意味着多次生成。生产环境的性能原则是:

  • 不要为每个 Controller 建一个分组,按可见性边界分组(external/internal)即可。
  • 使用 paths-to-exclude 排除不需要生成文档的路径(如 /actuator/**)。
  • 非生产环境如果文档生成较慢,关闭 springdoc.cache.disabled=true 反而会增加重复生成开销,应保持缓存开启并设置合理的 max-entries。

6.2 网关聚合的路径前缀陷阱

网关配置 urls 时,url 值必须与浏览器实际可访问的路径一致。如果网关本身有 server.servlet.context-path 或前置了 Nginx 路径重写,/order-service/v3/api-docs 可能无法被正确解析。

在代理场景下,不要手动设置 config-url,让 SpringDoc 自动检测。手动指定绝对路径的 config-url 在部署到带 context path 的环境后,会导致 Swagger UI 去错误的地址拉取配置,表现为“No API Definition Provided”。正确做法是设置 springdoc.swagger-ui.url=/v3/api-docs(或对应的分组路径),让浏览器相对于当前页面路径解析。

6.3 CI 中加入依赖版本门禁

内存耗尽漏洞的修复版本是明确的。在 CI 中加一条检查:如果 springdoc-openapi-starter-* 的版本落在 [2.0.0, 2.9.1) 或 [3.0.0, 3.1.1) 区间,构建失败。

七、踩坑指南

坑 表现 解决方案
生产端点未关闭 /v3/api-docs 公网可访问 prod profile 中 api-docs.enabled=false + swagger-ui.enabled=false
只关 UI 没关 API Swagger 页面打不开,但 JSON 仍泄露 两个开关必须同时关闭
网关认证未放行文档路径 UI 能打开,文档下拉框全部空白 认证过滤器显式放行 /v3/api-docs/** 和文档聚合路径
分组路径未覆盖 分组文档 404 Security 放行 /v3/api-docs/** 而不是仅 /v3/api-docs
config-url 硬编码绝对路径 部署到 context path 后“No API Definition Provided” 使用 swagger-ui.url 相对路径,或让 SpringDoc 自动检测
网关 urls 路径缺少 StripPrefix 下游收到错误的 /v3/api-docs 路径 路由配置 StripPrefix=1
Springfox 注解残留 文档字段全部空白,无报错 全局替换为 io.swagger.v3.oas.annotations
Springfox 与 SpringDoc 混用 Bean 冲突、重复注册、启动报错 彻底移除 springfox-* 依赖
javax.validation 与 Jakarta 冲突 Spring Boot 3 下校验注解不生效,文档模型缺少约束 全部改为 jakarta.validation.*
Accept-Language 缓存膨胀 堆内存持续上涨,最终 OOM 升级到 2.9.1/3.1.1+,设置 allowed-locales 和 cache.max-entries
internal 分组在生产暴露 内部管理接口出现在外部文档 @Profile 隔离或生产关闭全部文档
调试接口未隐藏 数据修复/调试接口出现在文档中 @Hidden 或 @Operation(hidden = true)
网关自身 api-docs 未关闭 网关也暴露了自己的端点 网关侧 api-docs.enabled=false,只保留 UI 聚合

八、生产环境推荐

场景 推荐方案
生产环境(默认) api-docs.enabled=false + swagger-ui.enabled=false + @Profile("!prod")
需要对外提供文档 仅暴露 external 分组,经认证+IP 白名单,internal 分组关闭
微服务+网关 各服务内网暴露 /v3/api-docs,网关聚合 UI
Springfox 迁移 依赖+注解+配置三件套,CI 阻断旧注解
安全合规 allowed-locales + cache.max-entries + 版本门禁

参数推荐:

参数 推荐值 说明
SpringDoc 版本 ≥ 2.9.1(Boot 3.x) 修复 Accept-Language 内存耗尽
api-docs.enabled(prod) false 关闭 JSON/YAML 端点
swagger-ui.enabled(prod) false 关闭 UI
allowed-locales 应用实际支持的语言 防止缓存膨胀
cache.max-entries 50~100 LRU 上限
分组数 2(external/internal) 按可见性边界
文档路径放行 /v3/api-docs/** 覆盖分组路径

九、总结与思考

核心三句话:

  1. 生产环境默认关闭文档:api-docs.enabled=false 和 swagger-ui.enabled=false 必须同时设置,并用 @Profile("!prod") 从 Bean 注册层面阻断,而不是只依赖配置开关。
  2. 分组是安全边界:external/internal 分组隔离,内部接口永远不进入外部文档;@Hidden 用于隐藏调试和数据修复接口。
  3. 版本是安全底线:SpringDoc >=2.0.0,<2.9.1 和 >=3.0.0,<3.1.1 存在 Accept-Language 缓存内存耗尽漏洞,生产必须使用修复版本,并配合 allowed-locales 和 cache.max-entries 做纵深防御。

方案速查:

你的需求 推荐方案
生产关闭文档 @Profile("!prod") + 双 enabled=false
内部/外部隔离 group-configs:external/internal
网关统一入口 springdoc.swagger-ui.urls 聚合
网关认证不拦文档 显式放行 /v3/api-docs/**
Springfox 迁移 换依赖 + 换注解包 + Docket→GroupedOpenApi
内存耗尽防护 升级 2.9.1+/3.1.1+ + allowed-locales
隐藏敏感接口 @Hidden / @Operation(hidden = true)

附录:完整 Maven 依赖

<dependencies>
    <!-- WebMVC业务服务 -->
    <dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
        <version>2.9.1</version>
    </dependency>

    <!-- WebFlux/Gateway -->
    <dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
        <version>2.9.1</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

⚠️ 运行前请确认:

  • [ ] SpringDoc 版本 ≥ 2.9.1(或 ≥ 3.1.1),不在内存耗尽漏洞影响区间
  • [ ] prod profile 中 springdoc.api-docs.enabled=false
  • [ ] prod profile 中 springdoc.swagger-ui.enabled=false
  • [ ] OpenAPI 配置类使用 @Profile("!prod"),生产环境不注册 OpenAPI/GroupedOpenApi Bean
  • [ ] 项目中不存在任何 springfox-* 依赖
  • [ ] 所有 Swagger 注解已替换为 io.swagger.v3.oas.annotations
  • [ ] 所有校验注解已使用 jakarta.validation.*
  • [ ] springdoc.allowed-locales 已设置为应用实际支持的语言
  • [ ] springdoc.cache.max-entries 已设置(建议 50~100)
  • [ ] 非生产环境的 Security 放行路径为 /v3/api-docs/**(覆盖分组)
  • [ ] 网关认证过滤器已显式放行文档聚合路径
  • [ ] 网关 urls 配置中的路径与路由 StripPrefix 一致
  • [ ] 未硬编码 swagger-ui.config-url 为绝对路径
  • [ ] internal 分组在生产环境不可达
  • [ ] 调试/数据修复接口已用 @Hidden 或 @Operation(hidden = true) 隐藏
  • [ ] CI 中已加入依赖版本门禁,阻止回退到受影响版本
  • [ ] 安全部门已确认生产环境 /v3/api-docs 和 /swagger-ui 的网络暴露面



上一篇:调用 MiniMax-M3.1-Flash 前,先核对控制台 ID、双跑测试与账单
下一篇:开源AI剪辑工具Concat:离线字幕与抠图,免费替代剪映
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-25 23:08 , Processed in 0.095458 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表