1. 从一个被扫出来的接口文档说起
很多团队在项目上线前的安全扫描环节,都会收到一条看起来"不痛不痒"的告警:某个路径下存在接口文档页面,可未授权直接访问。扫描器给出的风险等级往往不高,于是被顺手标记成"误报"或者"下个迭代再处理"。直到某天有人发现,攻击者根本不需要猜接口路径,直接打开文档页面就能拿到全部接口清单、参数结构、字段含义,甚至连内部调试接口和管理接口都一览无余。
这就是Spring Boot 项目中 Swagger 未授权访问的典型场景。它本身不是一个"漏洞"级别的代码缺陷,而是一个配置疏忽导致的信息暴露面。但正是这种"看起来不严重"的问题,往往成为后续横向渗透的起点。接口文档里暴露的每一个路径、每一个参数名,都是攻击者构造请求的现成素材。
这篇内容面向的是正在使用 Spring Boot 做后端开发、并且集成了 Swagger(或 springdoc、Knife4j 等同类接口文档工具)的工程师。我会把这件事拆成几块讲清楚:Swagger 在 Spring Boot 里到底是怎么被暴露出去的、为什么默认配置下它几乎必然可未授权访问、真实风险边界在哪里、以及几种不同场景下可落地的修复方案。中间会穿插我自己在项目里踩过的坑,包括那些"改了配置但没生效"的典型情况。
需要先说明一点:本文讨论的所有内容都限定在自有系统的安全加固范围内,目的是帮助开发和运维人员把自家服务的暴露面收敛掉,不涉及任何针对他人系统的操作。
2. Swagger 在 Spring Boot 里是怎么被"挂"出去的
2.1 自动装配带来的默认暴露
Spring Boot 的核心设计哲学是"约定优于配置",大量功能通过 starter 自动装配完成。Swagger 的集成同样如此。以 springfox 为例,只要在pom.xml里引入springfox-boot-starter,再写一个@Configuration类加上@EnableSwagger2或@EnableOpenApi,一个完整的文档站点就自动挂载好了。
关键在于:这个文档站点默认注册在 Spring MVC 的 DispatcherServlet 上,和业务接口共享同一套请求处理链路。也就是说,只要应用端口对外可达,文档页面就对外可达。它不会因为你没在 Controller 里写映射就消失,因为它是通过Docket配置动态生成的。
springdoc-openapi 的逻辑类似。引入springdoc-openapi-ui之后,默认会暴露这几个路径:
| 路径 | 作用 |
|---|---|
/swagger-ui.html | UI 入口,通常 302 跳转到下面的 index |
/swagger-ui/index.html | 实际的文档界面 |
/v3/api-docs | OpenAPI 3.0 规范的 JSON 描述 |
/v3/api-docs/swagger-config | UI 的配置信息 |
/swagger-resources | springfox 时代的资源列表 |
这些路径是框架写死的默认值,不需要开发者做任何额外声明。很多人以为"我没主动开放它",但事实是"我没主动关闭它"。
2.2 为什么默认没有鉴权
这是最容易被误解的一点。Swagger 的 UI 和 api-docs 端点,本质上就是普通的 HTTP 资源,它们不经过你的业务鉴权逻辑。原因有两层:
第一层,大多数项目的鉴权是通过拦截器(Interceptor)或过滤器(Filter)实现的,而这些组件的放行规则里,通常会包含静态资源路径。开发者为了让前端页面、图标、JS 文件能正常加载,往往写的是"放行/swagger-ui/**、/v3/api-docs/**"这类规则。一旦放行,鉴权就绕过了。
第二层,如果你用的是 Spring Security,默认配置下所有请求都需要认证,但很多教程为了"先跑起来",会直接写http.authorizeRequests().antMatchers("/**").permitAll(),或者干脆把 Swagger 相关路径加进白名单。这两种做法都会让文档端点变成匿名可访问。
我在一个真实项目里见过更隐蔽的情况:项目用了自定义的网关鉴权,网关层只校验了业务前缀的路径,而 Swagger 的路径不在校验范围内,于是直接从网关透传到了后端服务。这种"鉴权在网关、暴露在后端"的错位,是很多微服务架构下的通病。
2.3 生产环境打包时它并不会自动消失
一个常见的侥幸心理是:"这是开发环境的东西,打包上线应该就没了。"事实并非如此。Swagger 的依赖是编译期依赖,配置类也是普通 Bean,只要在 classpath 里、只要 profile 没做区分,它就会在任何环境下生效,包括生产环境。
我见过有团队用@Profile("dev")标注 Swagger 配置类,这确实是一种正确做法。但问题在于,很多项目的 profile 激活方式是spring.profiles.active=dev写死在配置文件里,上线时忘了改,或者运维用环境变量覆盖时漏了这一项,结果生产环境照样把文档暴露出去。
3. 未授权访问的真实风险边界
3.1 信息泄露是第一层,也是最直接的一层
打开一个未授权的 Swagger 页面,攻击者能拿到什么?远不止"接口列表"这么简单。
- 全部接口路径和 HTTP 方法:包括那些没有在前端使用的内部接口、管理接口、调试接口。
- 请求参数结构:字段名、类型、是否必填、示例值。字段名往往能透露业务含义,比如
isAdmin、internalToken、userId。 - 响应结构:返回字段同样暴露业务模型。
- 接口分组和描述:很多团队会在
@ApiOperation里写详细的中文说明,等于把接口说明书直接送出去。 - 部分框架还会暴露实体类的字段注释,进一步降低攻击者的理解成本。
把这些信息拼起来,攻击者几乎不需要逆向就能理解你的业务模型。这比盲扫目录高效得多。
3.2 从信息泄露到实际攻击的链路
信息本身不是终点。真正的风险在于,这些信息会显著降低后续攻击的成本。举几个我在安全评估中见过的真实链路:
第一种,文档里暴露了一个/api/internal/user/export接口,参数是userId。攻击者发现这个接口没有做权限校验(因为原本设计是内部调用),于是遍历 userId 批量导出用户数据。
第二种,文档里暴露了某个接口的debug参数,默认 false,但传 true 时会返回详细的异常堆栈。攻击者借此拿到数据库表名和 SQL 片段。
第三种,文档暴露了文件上传接口的完整参数,包括存储路径字段。攻击者构造路径穿越的请求,把文件写到非预期目录。
这些都不是 Swagger 本身的漏洞,而是Swagger 把攻击面完整地展示了出来。没有文档,攻击者需要花大量时间盲测;有了文档,这些接口就像被贴上了标签。
3.3 为什么扫描器给的等级往往偏低
安全扫描器通常把"未授权访问接口文档"归类为"信息泄露",风险等级中等或偏低。这个定级逻辑本身没错,因为单看这一个点,它不直接导致数据被篡改或泄露。但定级偏低带来的副作用是:团队容易忽视它。
我的建议是,不要只看扫描器的等级,要看这个文档暴露在什么网络位置。如果服务只在内网、且有严格的网络隔离,风险相对可控;如果服务对公网可达,那这个问题的优先级应该直接拉高。判断标准很简单:把文档页面的 URL 拿到公网环境访问一下,能打开就是高危。
4. 修复方案:从"关掉"到"管住"的几种思路
4.1 方案一:生产环境彻底关闭文档
最直接的做法,就是让 Swagger 在生产环境不生效。这里的关键是用 profile 做隔离,并且确保隔离真的生效。
以 springdoc 为例,可以这样配置:
# application.yml springdoc: api-docs: enabled: false swagger-ui: enabled: false然后在开发环境用application-dev.yml覆盖为 true。但这里有个坑:springdoc.api-docs.enabled=false只关闭了 api-docs 端点,UI 页面可能仍然可访问(取决于版本)。更稳妥的做法是配合 profile 条件装配配置类:
@Configuration @Profile({"dev", "test"}) public class SwaggerConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info().title("项目接口文档").version("1.0")); } }这样在非 dev/test 环境下,这个配置类根本不会被加载,文档自然不存在。
注意:用
@Profile隔离时,一定要确认生产环境的spring.profiles.active不是 dev。我建议在启动脚本里加一行日志,把当前激活的 profile 打出来,上线时肉眼确认一遍。
4.2 方案二:保留文档但加鉴权
有些团队确实需要生产环境的文档(比如给合作方对接用),这时候就不能简单关掉,而是要做访问控制。
如果项目用了 Spring Security,可以把 Swagger 路径纳入认证范围:
@Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/swagger-ui/**", "/v3/api-docs/**").authenticated() .anyRequest().permitAll() .and() .httpBasic(); return http.build(); } }这样访问文档需要先通过 HTTP Basic 认证。但要注意,Basic 认证的凭据是明文传输的(Base64 不是加密),所以必须配合 HTTPS 使用。
如果项目用的是自定义拦截器,思路类似,把 Swagger 路径从白名单里移除,让它走正常的登录校验。这里有个细节:Swagger UI 加载时会请求多个资源(JS、CSS、api-docs),如果只拦截了入口页面而没拦截 api-docs,攻击者仍然可以直接请求/v3/api-docs拿到接口 JSON。所以鉴权规则要覆盖所有相关路径,不能只拦 UI。
4.3 方案三:网关层统一拦截
在微服务架构下,更推荐的做法是在网关层做统一处理。因为后端服务可能有几十个,逐个改配置容易遗漏,而网关是所有流量的入口。
以 Spring Cloud Gateway 为例,可以加一个全局过滤器,对 Swagger 相关路径做拦截:
@Component public class SwaggerBlockFilter implements GlobalFilter, Ordered { private static final List<String> BLOCK_PATHS = Arrays.asList( "/swagger-ui", "/v3/api-docs", "/swagger-resources", "/webjars" ); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); for (String block : BLOCK_PATHS) { if (path.contains(block)) { exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND); return exchange.getResponse().setComplete(); } } return chain.filter(exchange); } @Override public int getOrder() { return -100; } }这个过滤器的逻辑是:只要路径里包含 Swagger 相关关键字,直接返回 404。返回 404 而不是 403,是为了不暴露"这里本来有东西"的信息。
提示:网关拦截要注意路径匹配的准确性。有些项目的业务接口路径里恰好包含
swagger字样(虽然少见),用contains可能误伤。更严谨的做法是用startsWith配合路径前缀列表。
4.4 方案四:改掉默认路径
如果既想保留文档,又不想被扫描器轻易发现,可以修改默认路径。springdoc 支持自定义:
springdoc: swagger-ui: path: /doc-${random.uuid} api-docs: path: /api-spec-${random.uuid}用随机值做路径,扫描器基于默认字典就扫不到了。但我要强调:这只是"隐藏",不是"防护"。一旦路径被泄露(比如前端代码里引用了、日志里打印了),防护就失效了。所以这个方案只能作为辅助手段,不能替代鉴权。
5. 那些"改了配置却没生效"的排查过程
5.1 依赖冲突导致配置类没加载
有一次我在一个老项目里改 Swagger 配置,明明加了@Profile("dev"),生产环境却依然能访问文档。排查了半天,最后发现项目里同时存在 springfox 和 springdoc 两套依赖。我改的是 springdoc 的配置,但实际生效的是 springfox 的自动配置。
排查方法很简单,启动时看日志里有没有DocumentationPluginsBootstrapper或OpenApiResource相关的初始化信息。或者直接看/swagger-resources这个端点是否存在——它是 springfox 特有的,springdoc 不用这个路径。
经验:接手一个项目要改 Swagger 配置前,先确认用的是哪套工具。看pom.xml里是springfox-boot-starter还是springdoc-openapi-ui,两者的配置项完全不同,混用会出各种奇怪问题。
5.2 静态资源放行规则覆盖了鉴权
另一个高频坑是:鉴权配置里明明写了要拦截 Swagger 路径,但实际还是能访问。原因通常是放行规则和拦截规则的顺序问题。
在 Spring Security 的链式配置里,规则是从上到下匹配,匹配到就停止。如果前面有一条.antMatchers("/**").permitAll(),后面的 Swagger 拦截规则就永远不会生效。正确的顺序是把具体路径的规则放在前面:
http.authorizeRequests() .antMatchers("/swagger-ui/**").authenticated() // 具体规则在前 .antMatchers("/v3/api-docs/**").authenticated() .anyRequest().permitAll(); // 兜底规则在后这个顺序问题在自定义拦截器里同样存在。我见过有项目在WebMvcConfigurer里注册了多个拦截器,Swagger 的放行逻辑写在了一个excludePathPatterns里,而另一个拦截器又把它加回来了,导致最终行为取决于拦截器的注册顺序。
5.3 缓存和 CDN 让"关闭"看起来没生效
还有一种情况:配置改对了,服务也重启了,但浏览器访问文档页面还是能打开。这通常是浏览器缓存或 CDN 缓存导致的。Swagger UI 的静态资源(JS、CSS)会被浏览器强缓存,即使后端已经返回 404,页面可能还在用缓存渲染。
排查方法:用无痕窗口访问,或者用curl直接请求接口:
curl -i http://your-host/v3/api-docs如果curl返回 404 而浏览器能打开,那就是缓存问题。清理缓存或加版本号即可。
5.4 多实例部署下的配置不一致
在容器化部署的环境里,服务可能有多个实例。如果配置是通过环境变量注入的,而某个实例的环境变量没更新,就会出现"部分实例关闭了、部分实例还开着"的情况。扫描器只要扫到任意一个实例,就会报出问题。
排查方法:逐个实例请求文档路径,确认所有实例的行为一致。这个检查在滚动发布之后尤其重要。
6. 把这件事做成一个可持续的检查项
6.1 上线前的自检清单
与其每次靠扫描器提醒,不如把检查固化到流程里。我整理了一份自检清单,上线前逐项确认:
| 检查项 | 确认方式 | 通过标准 |
|---|---|---|
| 文档端点是否可匿名访问 | 无痕窗口访问/swagger-ui/index.html | 返回 401/403/404 |
| api-docs 是否可匿名访问 | curl请求/v3/api-docs | 返回 401/403/404 |
| 生产环境 profile 是否正确 | 查看启动日志中的 active profile | 不是 dev/test |
| 网关是否拦截了文档路径 | 从网关入口访问文档路径 | 返回 404 |
| 所有实例行为是否一致 | 逐个实例请求 | 全部不可访问 |
这份清单看起来简单,但能覆盖绝大多数遗漏场景。我建议把它写进 CI 流程,用脚本自动跑一遍。
6.2 用自动化脚本做回归检查
手动检查容易忘,可以写一个简单的脚本,在每次发布后自动验证:
#!/bin/bash HOST=$1 PATHS=("/swagger-ui/index.html" "/v3/api-docs" "/swagger-resources") for path in "${PATHS[@]}"; do code=$(curl -s -o /dev/null -w "%{http_code}" "http://${HOST}${path}") if [ "$code" = "200" ]; then echo "[FAIL] ${path} 返回 200,存在未授权访问风险" else echo "[PASS] ${path} 返回 ${code}" fi done把这个脚本挂到发布流水线的最后一步,一旦有实例返回 200 就中断发布。这比事后被扫描器通报要主动得多。
6.3 关于"要不要保留生产文档"的取舍
最后聊一个决策层面的问题:生产环境到底要不要保留接口文档?
我的观点是,默认关闭,确有需要再按最小权限开放。如果确实要给外部对接方提供文档,更好的做法是单独导出一份静态文档(比如用 springdoc 的离线导出功能生成 HTML 或 PDF),通过受控的渠道分发,而不是把在线文档直接暴露出去。
在线文档的价值在于"实时更新",但生产环境的接口变更频率通常不高,静态文档完全够用。用静态文档替代在线文档,既满足了对接需求,又消除了未授权访问的风险,是一笔划算的买卖。
如果团队坚持要保留在线文档,那至少要做到三点:走 HTTPS、加认证、限制来源 IP。这三条缺一不可。我在实际项目里见过只加了认证但没限制 IP 的,结果认证凭据被弱口令爆破,文档照样泄露。
7. 一个容易被忽略的关联点:其他同类端点
Swagger 不是唯一会被默认暴露的端点。Spring Boot Actuator 的/actuator路径下,/actuator/env、/actuator/health、/actuator/metrics等端点同样可能未授权访问。其中/actuator/env会暴露环境变量,风险比 Swagger 更高。
所以做安全加固时,建议把这类"框架自带的、默认开放的"端点统一梳理一遍。判断方法很简单:翻一遍项目引入的 starter,看看哪些会注册额外的 HTTP 端点。常见的包括:
- springdoc / springfox:接口文档
- spring-boot-starter-actuator:监控端点
- H2 Console:数据库控制台(
/h2-console) - Druid:监控页面(
/druid/**)
这些端点的加固思路和 Swagger 一致:要么关闭,要么鉴权,要么在网关拦截。把它们列成一张清单,逐个确认,比零散处理要可靠得多。
我在一个项目里做过统计,光是框架默认暴露的端点就有七八个,其中三个是未授权可访问的。这些问题单看都不严重,但叠加起来,攻击者能拼凑出的信息量相当可观。安全这件事,往往就是把这些"小问题"一个个收掉,整体暴露面才会真正降下来。