T1.4: 身份头注入 X-Caller-App-Key(verdict=PASS 才注入验签 appKey;注入点在伪造头剥除之后,FAIL/ANON 不注入;四用例绿)

This commit is contained in:
chenxing
2026-09-21 09:25:13 +08:00
parent cdc4396ec2
commit 26f5787000
3 changed files with 152 additions and 0 deletions
@@ -0,0 +1,32 @@
package com.yunhai.platform.gateway.forward;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import javax.servlet.http.HttpServletRequest;
/**
* 身份注入判定(T1.4):仅当验签链判定 PASS 时,转发请求注入
* {@code X-Caller-App-Key: {验签 appKey}};FAIL/FAIL_OBSERVED/ANON(含 OPTIONAL 未签名路径)
* 一律不注入——匿名就是匿名。注入动作发生在 {@link StreamingForwarder} 剥除客户端伪造头
* <b>之后</b>(顺序保证身份头唯一合法来源是网关);不注入任何其他身份字段
* (oapi 是应用级调用,不做用户级透传)。
*/
final class CallerHeader {
private CallerHeader() {
}
/**
* @return 应注入的 appKey;非 PASS 或无验签 appKey → null = 不注入
*/
static String valueFor(HttpServletRequest req) {
if (!SignVerifyFilter.VERDICT_PASS.equals(req.getAttribute(SignVerifyFilter.ATTR_VERDICT))) {
return null;
}
Object appKey = req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY);
if (!(appKey instanceof String) || ((String) appKey).trim().isEmpty()) {
return null;
}
return (String) appKey;
}
}
@@ -172,6 +172,11 @@ public class StreamingForwarder {
rb.addHeader(name, values.nextElement());
}
}
// T1.4:伪造头已在上方剥除,此处为唯一合法注入点(PASS 才有值)
String callerAppKey = CallerHeader.valueFor(req);
if (callerAppKey != null) {
rb.addHeader(HEADER_CALLER_APP_KEY, callerAppKey);
}
String method = req.getMethod().toUpperCase();
if (!"GET".equals(method) && !"HEAD".equals(method)
&& !"OPTIONS".equals(method) && !"TRACE".equals(method)) {
@@ -0,0 +1,115 @@
package com.yunhai.platform.gateway.forward;
import com.sun.net.httpserver.HttpServer;
import com.yunhai.platform.gateway.filter.SanitizeFilter;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 身份头注入 X-Caller-App-Key 四用例(T1.4 TDD):PASS 注入且值 = 验签 appKey;
* FAIL(observe)=FAIL_OBSERVED 不注入;ANON 路由不注入(匿名就是匿名);
* 客户端伪造头先剥后注入——假后端收到的终值 = 网关注入值(唯一来源,顺序契约)。
*/
class CallerHeaderTest {
private static HttpServer server;
private static int port;
private static volatile Map<String, String> lastHeaders;
@BeforeAll
static void startFakeBackend() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(Executors.newFixedThreadPool(2));
server.createContext("/", exchange -> {
lastHeaders = new ConcurrentHashMap<>();
for (Map.Entry<String, java.util.List<String>> e : exchange.getRequestHeaders().entrySet()) {
lastHeaders.put(e.getKey().toLowerCase(), String.join(",", e.getValue()));
}
byte[] body = "ok".getBytes(java.nio.charset.StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
exchange.close();
});
server.start();
port = server.getAddress().getPort();
}
@AfterAll
static void stopFakeBackend() {
server.stop(0);
}
@BeforeEach
@AfterEach
void reset() {
lastHeaders = null;
}
/** 已过 Sanitize+SignVerify 判定链的请求(attribute 契约),执行真实转发观察假后端收到的头 */
private Map<String, String> forwardWithVerdict(String verdict, String authAppKey,
String forgedCallerHeader) throws IOException {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/x");
req.setAsyncSupported(true);
req.setAttribute(SanitizeFilter.ATTR_STRIPPED_PATH, "/oapi/x");
if (verdict != null) {
req.setAttribute(SignVerifyFilter.ATTR_VERDICT, verdict);
}
if (authAppKey != null) {
req.setAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY, authAppKey);
}
if (forgedCallerHeader != null) {
req.addHeader("X-Caller-App-Key", forgedCallerHeader);
}
MockHttpServletResponse res = new MockHttpServletResponse();
CloseableHttpClient client = HttpClients.custom().disableRedirectHandling().build();
new StreamingForwarder(client, Runnable::run)
.forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 1024 * 1024);
assertThat(res.getStatus()).as("转发本身成功").isEqualTo(200);
return lastHeaders;
}
@Test
void PASS判定注入且值等于验签appKey() throws Exception {
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_PASS, "100617", null);
assertThat(headers.get("x-caller-app-key")).isEqualTo("100617");
}
@Test
void FAIL_OBSERVED不注入() throws Exception {
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_FAIL_OBSERVED, null, null);
assertThat(headers).doesNotContainKey("x-caller-app-key");
}
@Test
void ANON路由不注入() throws Exception {
// ANON/OPTIONAL 未签名路径:匿名就是匿名(判定非 PASS,无验签 appKey)
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_ANON, null, null);
assertThat(headers).doesNotContainKey("x-caller-app-key");
}
@Test
void 伪造头先剥后注入_终值为网关注入值() throws Exception {
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_PASS, "100617", "hack");
assertThat(headers.get("x-caller-app-key"))
.as("唯一合法来源是网关:伪造值不残留、不并列").isEqualTo("100617");
}
}