fix(security): reject forged staging identity headers
This commit is contained in:
parent
8570d9b620
commit
c454610183
@ -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 请求头信任均已关闭");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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 模式的开关
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user