fix(security): /debug/** 纳入兜底鉴权——修复 debug 端点可被匿名调用(含写操作)

2026-09-11 实测确认(本机公网直连):
  http://47.110.241.161:18080/debug/request-info        → 200 匿名返回实例元数据
  http://api.aishufu.top:18080/debug/request-info       → 200
  https://api.aishufu.top/debug/request-info            → 200
根因:SecurityConfig 是 anyRequest().permitAll(),唯一兜底过滤器 AdminApiGuardFilter 只覆盖
/api/admin/**(shouldNotFilter 对其它路径一律放行),而 modules/debug 两个控制器均无
@Profile/@ConditionalOn*/@PreAuthorize。其中 POST /debug/task-recovery/run-stale-finalize
是强制收尾指定 task 的写操作。

修法:不删端点(stale-task-finalize-guidelines.md 明确把该接口列为线上应急工具),
改为把 /debug 前缀纳入既有兜底过滤器——URL 不变、runbook 不破、复用已测鉴权逻辑,
应急调用方改用 X-Internal-Token 或管理员 JWT 即可通过。
另加 /debug 放行审计日志,便于事后追溯这类低频敏感调用。

单测 13 个全绿(新增 5 个):匿名调 /debug/request-info 与 run-stale-finalize 均被 401 拒绝、
带内部令牌可直达 controller、/debug 裸前缀纳入保护、/debugging 与 /debug-tools 等
同前缀不同段的路径不被误伤。
This commit is contained in:
2026-09-11 01:33:58 +08:00
parent dd45ffe34c
commit 47b2758fe8
2 changed files with 119 additions and 4 deletions
@@ -19,8 +19,10 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* /api/admin 兜底鉴权过滤器单测:非 admin 路径放行、OPTIONS/豁免前缀放行、
* 兜底鉴权过滤器单测:非受保护路径放行、OPTIONS/豁免前缀放行、
* 匿名拒绝返回与全局一致的 401 体、紧急开关可整体关闭。
*
* <p>/debug/** 于 2026-09-11 纳入保护范围,对应用例见本类后半部分。
*/
class AdminApiGuardFilterTest {
@@ -160,4 +162,93 @@ class AdminApiGuardFilterTest {
verify(authSupport, never()).requireUserOrInternal(any());
assertThat(chain.getRequest()).isNotNull();
}
// ---- /debug/** 纳入保护(2026-09-11----
// 背景:两个 debug 控制器都没有方法级鉴权,而主机A 18080 公网直连、主机B nginx location /
// 全量反代 Java,导致匿名即可调通(其中 run-stale-finalize 是强制收尾指定 task 的写操作)。
@Test
void anonymousDebugWriteEndpointRejected() throws Exception {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
when(authSupport.requireUserOrInternal(any()))
.thenThrow(new BusinessException(401, "未登录"));
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
MockHttpServletRequest request =
new MockHttpServletRequest("POST", "/debug/task-recovery/run-stale-finalize");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
JsonNode body = objectMapper.readTree(response.getContentAsString());
assertThat(body.path("success").asBoolean()).isFalse();
assertThat(body.path("code").asInt()).isEqualTo(401);
assertThat(chain.getRequest()).isNull();
}
@Test
void anonymousDebugRequestInfoRejected() throws Exception {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
when(authSupport.requireUserOrInternal(any()))
.thenThrow(new BusinessException(401, "未登录"));
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/debug/request-info");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertThat(chain.getRequest()).isNull();
assertThat(response.getContentAsString()).contains("401");
}
@Test
void debugEndpointStillReachableWithInternalToken() throws Exception {
// 应急调用方(run-stale-finalize 是文档指定的线上排障工具)带 X-Internal-Token 仍可直达
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
when(authSupport.isTrustedInternalToken(any())).thenReturn(true);
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
MockHttpServletRequest request =
new MockHttpServletRequest("POST", "/debug/task-recovery/run-stale-finalize");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
verify(authSupport, never()).requireUserOrInternal(any());
assertThat(chain.getRequest()).isNotNull();
assertThat(response.getContentAsString()).isEmpty();
}
@Test
void debugBarePrefixItselfIsGuarded() throws Exception {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
when(authSupport.requireUserOrInternal(any()))
.thenThrow(new BusinessException(401, "未登录"));
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/debug");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertThat(chain.getRequest()).isNull();
}
@Test
void similarLookingPathsAreNotSweptIn() throws Exception {
// 前缀边界:只按整段匹配,/debugging、/debug-tools、/api/adminx 都不应被误纳入
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
for (String uri : new String[] {"/debugging", "/debug-tools/list", "/api/adminx"}) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", uri);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertThat(chain.getRequest()).as("路径 %s 不应被纳入保护", uri).isNotNull();
}
verify(authSupport, never()).requireUserOrInternal(any());
}
}