diff --git a/src/main/java/com/yunhai/platform/gateway/forward/CallerHeader.java b/src/main/java/com/yunhai/platform/gateway/forward/CallerHeader.java
new file mode 100644
index 0000000..cd83fc6
--- /dev/null
+++ b/src/main/java/com/yunhai/platform/gateway/forward/CallerHeader.java
@@ -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} 剥除客户端伪造头
+ * 之后(顺序保证身份头唯一合法来源是网关);不注入任何其他身份字段
+ * (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;
+ }
+}
diff --git a/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java b/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java
index ade810c..218535c 100644
--- a/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java
+++ b/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java
@@ -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)) {
diff --git a/src/test/java/com/yunhai/platform/gateway/forward/CallerHeaderTest.java b/src/test/java/com/yunhai/platform/gateway/forward/CallerHeaderTest.java
new file mode 100644
index 0000000..01cba3d
--- /dev/null
+++ b/src/test/java/com/yunhai/platform/gateway/forward/CallerHeaderTest.java
@@ -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 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> 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 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 headers = forwardWithVerdict(SignVerifyFilter.VERDICT_PASS, "100617", null);
+ assertThat(headers.get("x-caller-app-key")).isEqualTo("100617");
+ }
+
+ @Test
+ void FAIL_OBSERVED不注入() throws Exception {
+ Map 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 headers = forwardWithVerdict(SignVerifyFilter.VERDICT_ANON, null, null);
+ assertThat(headers).doesNotContainKey("x-caller-app-key");
+ }
+
+ @Test
+ void 伪造头先剥后注入_终值为网关注入值() throws Exception {
+ Map headers = forwardWithVerdict(SignVerifyFilter.VERDICT_PASS, "100617", "hack");
+ assertThat(headers.get("x-caller-app-key"))
+ .as("唯一合法来源是网关:伪造值不残留、不并列").isEqualTo("100617");
+ }
+}