一、一个真实的生产事故
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/** |
覆盖分组路径 |
九、总结与思考
核心三句话:
- 生产环境默认关闭文档:
api-docs.enabled=false 和 swagger-ui.enabled=false 必须同时设置,并用 @Profile("!prod") 从 Bean 注册层面阻断,而不是只依赖配置开关。
- 分组是安全边界:external/internal 分组隔离,内部接口永远不进入外部文档;
@Hidden 用于隐藏调试和数据修复接口。
- 版本是安全底线: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 的网络暴露面