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:
@@ -21,13 +21,19 @@ import java.util.Set;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /api/admin/** 兜底鉴权过滤器。
|
* /api/admin/**、/debug/** 兜底鉴权过滤器。
|
||||||
*
|
*
|
||||||
* <p>SecurityConfig 全局 permitAll 且无路径级拦截,历史上有多个 /api/admin 控制器遗漏方法级
|
* <p>SecurityConfig 全局 permitAll 且无路径级拦截,历史上有多个 /api/admin 控制器遗漏方法级
|
||||||
* require*(公网 18080 直连可达即匿名裸奔)。本过滤器作为第二道网:凡进入 /api/admin 的请求
|
* require*(公网 18080 直连可达即匿名裸奔)。本过滤器作为第二道网:凡进入受保护前缀的请求
|
||||||
* 必须具备"有效身份"(Java JWT,或可信内部令牌 X-Internal-Token + operatorId,复用
|
* 必须具备"有效身份"(Java JWT,或可信内部令牌 X-Internal-Token + operatorId,复用
|
||||||
* AdminAuthSupport),否则直接返回与全局异常一致的 401 响应体(HTTP 200 + {success:false,code})。
|
* AdminAuthSupport),否则直接返回与全局异常一致的 401 响应体(HTTP 200 + {success:false,code})。
|
||||||
*
|
*
|
||||||
|
* <p>/debug/** 于 2026-09-11 一并纳入:两个 debug 控制器都没有方法级鉴权,而主机A 18080 公网
|
||||||
|
* 直连、主机B nginx 的 location / 全量反代 Java,实测三处均可匿名调通——
|
||||||
|
* GET /debug/request-info(泄漏实例与转发头)与
|
||||||
|
* POST /debug/task-recovery/run-stale-finalize(**写操作**,强制收尾指定 task)。
|
||||||
|
* 应急调用方改用 X-Internal-Token 或管理员 JWT 即可通过,不再裸奔。
|
||||||
|
*
|
||||||
* <p>只保证"已认证",管理员/菜单级校验仍由各控制器 require* 负责,避免拦截普通登录用户本可
|
* <p>只保证"已认证",管理员/菜单级校验仍由各控制器 require* 负责,避免拦截普通登录用户本可
|
||||||
* 访问的 /api/admin 接口(如权限菜单列表仅 requireUser)。
|
* 访问的 /api/admin 接口(如权限菜单列表仅 requireUser)。
|
||||||
*
|
*
|
||||||
@@ -42,6 +48,9 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
private static final String ADMIN_API_PREFIX = "/api/admin";
|
private static final String ADMIN_API_PREFIX = "/api/admin";
|
||||||
|
|
||||||
|
/** 调试端点前缀:无方法级鉴权的诊断/运维入口,同样纳入兜底(2026-09-11)。 */
|
||||||
|
private static final String DEBUG_PREFIX = "/debug";
|
||||||
|
|
||||||
private final AdminAuthSupport adminAuthSupport;
|
private final AdminAuthSupport adminAuthSupport;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@@ -57,7 +66,7 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
String uri = request.getRequestURI();
|
String uri = request.getRequestURI();
|
||||||
if (!(uri.equals(ADMIN_API_PREFIX) || uri.startsWith(ADMIN_API_PREFIX + "/"))) {
|
if (!isGuarded(uri)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
@@ -92,9 +101,24 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
|||||||
response.getWriter().write(objectMapper.writeValueAsString(body));
|
response.getWriter().write(objectMapper.writeValueAsString(body));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// /debug/** 是应急/诊断入口,调用频次极低但敏感,单独留一条审计日志便于事后追溯
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
if (matchesPrefix(uri, DEBUG_PREFIX)) {
|
||||||
|
log.warn("[debug-guard] {} {} 通过鉴权放行,remoteAddr={}",
|
||||||
|
request.getMethod(), uri, request.getRemoteAddr());
|
||||||
|
}
|
||||||
chain.doFilter(request, response);
|
chain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 命中受保护前缀(/api/admin、/debug 及其子路径)才进入鉴权,其余请求直接放行。 */
|
||||||
|
private static boolean isGuarded(String uri) {
|
||||||
|
return matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matchesPrefix(String uri, String prefix) {
|
||||||
|
return uri.equals(prefix) || uri.startsWith(prefix + "/");
|
||||||
|
}
|
||||||
|
|
||||||
private Set<String> exemptSet() {
|
private Set<String> exemptSet() {
|
||||||
if (exemptPrefixes == null || exemptPrefixes.isBlank()) {
|
if (exemptPrefixes == null || exemptPrefixes.isBlank()) {
|
||||||
return Collections.emptySet();
|
return Collections.emptySet();
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import static org.mockito.Mockito.verify;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /api/admin 兜底鉴权过滤器单测:非 admin 路径放行、OPTIONS/豁免前缀放行、
|
* 兜底鉴权过滤器单测:非受保护路径放行、OPTIONS/豁免前缀放行、
|
||||||
* 匿名拒绝返回与全局一致的 401 体、紧急开关可整体关闭。
|
* 匿名拒绝返回与全局一致的 401 体、紧急开关可整体关闭。
|
||||||
|
*
|
||||||
|
* <p>/debug/** 于 2026-09-11 纳入保护范围,对应用例见本类后半部分。
|
||||||
*/
|
*/
|
||||||
class AdminApiGuardFilterTest {
|
class AdminApiGuardFilterTest {
|
||||||
|
|
||||||
@@ -160,4 +162,93 @@ class AdminApiGuardFilterTest {
|
|||||||
verify(authSupport, never()).requireUserOrInternal(any());
|
verify(authSupport, never()).requireUserOrInternal(any());
|
||||||
assertThat(chain.getRequest()).isNotNull();
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user