diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java index f94725e7..5ee7fc98 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java @@ -21,13 +21,19 @@ import java.util.Set; import java.util.stream.Collectors; /** - * /api/admin/** 兜底鉴权过滤器。 + * /api/admin/**、/debug/** 兜底鉴权过滤器。 * *

SecurityConfig 全局 permitAll 且无路径级拦截,历史上有多个 /api/admin 控制器遗漏方法级 - * require*(公网 18080 直连可达即匿名裸奔)。本过滤器作为第二道网:凡进入 /api/admin 的请求 + * require*(公网 18080 直连可达即匿名裸奔)。本过滤器作为第二道网:凡进入受保护前缀的请求 * 必须具备"有效身份"(Java JWT,或可信内部令牌 X-Internal-Token + operatorId,复用 * AdminAuthSupport),否则直接返回与全局异常一致的 401 响应体(HTTP 200 + {success:false,code})。 * + *

/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 即可通过,不再裸奔。 + * *

只保证"已认证",管理员/菜单级校验仍由各控制器 require* 负责,避免拦截普通登录用户本可 * 访问的 /api/admin 接口(如权限菜单列表仅 requireUser)。 * @@ -42,6 +48,9 @@ public class AdminApiGuardFilter extends OncePerRequestFilter { 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 ObjectMapper objectMapper; @@ -57,7 +66,7 @@ public class AdminApiGuardFilter extends OncePerRequestFilter { return true; } String uri = request.getRequestURI(); - if (!(uri.equals(ADMIN_API_PREFIX) || uri.startsWith(ADMIN_API_PREFIX + "/"))) { + if (!isGuarded(uri)) { return true; } if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { @@ -92,9 +101,24 @@ public class AdminApiGuardFilter extends OncePerRequestFilter { response.getWriter().write(objectMapper.writeValueAsString(body)); 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); } + /** 命中受保护前缀(/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 exemptSet() { if (exemptPrefixes == null || exemptPrefixes.isBlank()) { return Collections.emptySet(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java index 3683d74c..cd0459e8 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/config/AdminApiGuardFilterTest.java @@ -19,8 +19,10 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * /api/admin 兜底鉴权过滤器单测:非 admin 路径放行、OPTIONS/豁免前缀放行、 + * 兜底鉴权过滤器单测:非受保护路径放行、OPTIONS/豁免前缀放行、 * 匿名拒绝返回与全局一致的 401 体、紧急开关可整体关闭。 + * + *

/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()); + } }