fix(security): reject forged staging identity headers

This commit is contained in:
lili 2026-07-23 00:23:15 -07:00
parent 8570d9b620
commit c454610183
8 changed files with 134 additions and 5 deletions

View File

@ -9,9 +9,9 @@ import org.springframework.core.env.Environment;
/**
* 内部狗粮认证启动不变量校验
*
* <p>mock token 绕过真实 OAuth2 token 校验因此只要狗粮总开关开启
* mock 认证也必须关闭该校验在应用接流量前 fail-closed防止 Nacos 或命令行
* 覆盖 staging 文件后重新打开 {@code test<userId>} 身份通道</p>
* <p>mock token login-user 请求头都能绕过真实 OAuth2 token 校验因此只要狗粮总开关开启
* 两条认证捷径都必须关闭该校验在应用接流量前 fail-closed防止 Nacos 或命令行
* 覆盖 staging 文件后重新打开伪造身份通道</p>
*/
@Slf4j
@RequiredArgsConstructor
@ -31,7 +31,12 @@ public class DogfoodSecurityStartupValidator implements ApplicationRunner {
log.error("[dogfoodSecurityStartup] 狗粮态认证配置拒绝启动 key=huijing.security.mock-enable expected=false");
throw new IllegalStateException("狗粮态要求 huijing.security.mock-enable=false");
}
log.info("[dogfoodSecurityStartup] 狗粮态 mock 认证已关闭");
if (Boolean.TRUE.equals(securityProperties.getLoginUserHeaderEnabled())) {
log.error("[dogfoodSecurityStartup] 狗粮态认证配置拒绝启动 "
+ "key=huijing.security.login-user-header-enabled expected=false");
throw new IllegalStateException("狗粮态要求 huijing.security.login-user-header-enabled=false");
}
log.info("[dogfoodSecurityStartup] 狗粮态 mock 认证与 login-user 请求头信任均已关闭");
}
}

View File

@ -27,6 +27,15 @@ public class SecurityProperties {
@NotEmpty(message = "Token Parameter 不能为空")
private String tokenParameter = "token";
/**
* 是否信任服务间透传的 login-user 请求头
*
* <p>默认开启以保持现有微服务 RPC 行为外部流量可直达服务的环境必须显式关闭
* 避免客户端伪造该请求头绕过 OAuth2 token 校验</p>
*/
@NotNull(message = "login-user 请求头信任开关不能为空")
private Boolean loginUserHeaderEnabled = true;
/**
* mock 模式的开关
*/

View File

@ -130,6 +130,10 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
private LoginUser buildLoginUserByHeader(HttpServletRequest request) {
String loginUserStr = request.getHeader(SecurityFrameworkUtils.LOGIN_USER_HEADER);
if (!Boolean.TRUE.equals(securityProperties.getLoginUserHeaderEnabled())) {
// 不解析不记录客户端可控的身份头避免认证绕过与恶意请求放大日志量
return null;
}
if (StrUtil.isEmpty(loginUserStr)) {
return null;
}

View File

@ -20,6 +20,32 @@ class DogfoodSecurityStartupValidatorTest {
() -> new DogfoodSecurityStartupValidator(properties, environment).run(null));
}
/** 狗粮态必须拒绝重新开启服务间身份头信任。 */
@Test
void shouldRejectLoginUserHeaderTrustWhenDogfoodIsEnabled() {
SecurityProperties properties = new SecurityProperties();
properties.setMockEnable(false);
properties.setLoginUserHeaderEnabled(true);
MockEnvironment environment = new MockEnvironment()
.withProperty("huijing.dogfood.enabled", "true");
// 单体狗粮环境可被外部直达服务间身份头不能作为认证凭据
assertThrows(IllegalStateException.class,
() -> new DogfoodSecurityStartupValidator(properties, environment).run(null));
}
/** mock 与身份头信任均关闭时,狗粮认证配置允许启动。 */
@Test
void shouldAcceptSafeAuthenticationConfigurationWhenDogfoodIsEnabled() {
SecurityProperties properties = new SecurityProperties();
properties.setMockEnable(false);
properties.setLoginUserHeaderEnabled(false);
MockEnvironment environment = new MockEnvironment()
.withProperty("huijing.dogfood.enabled", "true");
assertDoesNotThrow(() -> new DogfoodSecurityStartupValidator(properties, environment).run(null));
}
@Test
void shouldAllowMockAuthenticationOutsideDogfood() {
SecurityProperties properties = new SecurityProperties();

View File

@ -2,7 +2,9 @@ package com.wanxiang.huijing.framework.security.core.filter;
import com.wanxiang.huijing.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.common.util.json.JsonUtils;
import com.wanxiang.huijing.framework.security.config.SecurityProperties;
import com.wanxiang.huijing.framework.security.core.LoginUser;
import com.wanxiang.huijing.framework.security.core.handler.AuthenticationEntryPointImpl;
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
import com.wanxiang.huijing.framework.web.config.WebProperties;
@ -16,7 +18,11 @@ import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
@ -61,4 +67,52 @@ class TokenAuthenticationFilterTest {
assertNull(SecurityFrameworkUtils.getLoginUser());
}
/** 验证关闭身份头信任后,伪造头不能让受保护接口获得登录态。 */
@Test
void forgedLoginUserHeaderShouldRemainUnauthenticatedWhenHeaderTrustIsDisabled() throws Exception {
SecurityProperties properties = new SecurityProperties();
properties.setLoginUserHeaderEnabled(false);
TokenAuthenticationFilter filter = new TokenAuthenticationFilter(
properties, mock(GlobalExceptionHandler.class), mock(OAuth2TokenCommonApi.class));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app-api/protected");
request.addHeader(SecurityFrameworkUtils.LOGIN_USER_HEADER, encodeLoginUserHeader(1L));
MockHttpServletResponse response = new MockHttpServletResponse();
AuthenticationEntryPointImpl authenticationEntryPoint = new AuthenticationEntryPointImpl();
filter.doFilter(request, response, (currentRequest, currentResponse) -> {
// staging 不信任客户端可控的服务间身份头受保护接口必须沿匿名路径返回 401
assertNull(SecurityFrameworkUtils.getLoginUser());
authenticationEntryPoint.commence(request, response,
new InsufficientAuthenticationException("未认证"));
});
assertEquals(401, response.getStatus());
assertTrue(response.getContentAsString().contains("\"code\":401"));
assertNull(SecurityFrameworkUtils.getLoginUser());
}
/** 验证默认配置继续接受既有 Feign RPC 透传身份。 */
@Test
void loginUserHeaderShouldKeepExistingRpcBehaviorByDefault() throws Exception {
SecurityProperties properties = new SecurityProperties();
TokenAuthenticationFilter filter = new TokenAuthenticationFilter(
properties, mock(GlobalExceptionHandler.class), mock(OAuth2TokenCommonApi.class));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app-api/protected");
request.addHeader(SecurityFrameworkUtils.LOGIN_USER_HEADER, encodeLoginUserHeader(7L));
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, (currentRequest, currentResponse) -> {
// staging profile 未覆盖配置时继续接受 Feign 透传身份避免破坏既有 RPC 调用
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
assertNotNull(loginUser);
assertEquals(7L, loginUser.getId());
});
}
/** 构造与 Feign RPC 拦截器相同格式的服务间身份头。 */
private static String encodeLoginUserHeader(Long userId) {
LoginUser loginUser = new LoginUser().setId(userId).setUserType(1);
return URLEncoder.encode(JsonUtils.toJsonString(loginUser), StandardCharsets.UTF_8);
}
}

View File

@ -2,10 +2,11 @@ package com.wanxiang.huijing.server.config;
import com.wanxiang.huijing.framework.env.config.DogfoodProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.env.Environment;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Component;
import java.time.Clock;
@ -38,6 +39,11 @@ public class DogfoodStartupValidator implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
if (environment.acceptsProfiles(Profiles.of("staging")) && !dogfoodProperties.isEnabled()) {
// active profile 是部署边界不能由同一配置源关闭狗粮总开关后绕过其余守卫
log.error("[dogfoodStartup] staging 拒绝启动 key=huijing.dogfood.enabled expected=true");
throw new IllegalStateException("staging 环境要求 huijing.dogfood.enabled=true");
}
if (!dogfoodProperties.isEnabled()) {
return;
}

View File

@ -169,6 +169,7 @@ huijing:
enable: false
security:
mock-enable: false # 狗粮态禁止 test<userId> 模拟身份,启动校验会对覆盖配置 fail-closed
login-user-header-enabled: false # 单体 staging 可被外部直达,不信任仅适用于服务间 RPC 的身份透传头
permit-all-urls: # staging 放行 actuator 健康检查(无需鉴权,便于探活)
- /actuator/**
access-log:

View File

@ -45,6 +45,30 @@ class DogfoodStartupValidatorTest {
assertDoesNotThrow(() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
}
/** staging profile 不能通过关闭狗粮总开关绕过其余安全守卫。 */
@Test
void shouldRejectDisabledDogfoodWhenStagingProfileIsActive() {
DogfoodProperties properties = new DogfoodProperties();
properties.setEnabled(false);
MockEnvironment environment = safeEnvironment();
environment.setActiveProfiles("staging");
// staging 必须始终进入全部狗粮守卫禁止通过关闭总开关绕过安全校验
assertThrows(IllegalStateException.class,
() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
}
/** 非 staging profile 保持原有可关闭狗粮能力的行为。 */
@Test
void shouldAllowDisabledDogfoodOutsideStagingProfile() {
DogfoodProperties properties = new DogfoodProperties();
properties.setEnabled(false);
MockEnvironment environment = safeEnvironment();
environment.setActiveProfiles("local");
assertDoesNotThrow(() -> new DogfoodStartupValidator(properties, environment, FIXED_CLOCK).run(null));
}
@Test
void shouldRejectSmsMockOrOpenRegistration() {
DogfoodProperties properties = enabledDogfood();