From 26f5787000a233c49fbf7a3acac4f73f4dd494f3 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 21 Sep 2026 09:25:13 +0800 Subject: [PATCH] =?UTF-8?q?T1.4:=20=E8=BA=AB=E4=BB=BD=E5=A4=B4=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=20X-Caller-App-Key=EF=BC=88verdict=3DPASS=20=E6=89=8D?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E9=AA=8C=E7=AD=BE=20appKey=EF=BC=9B=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E7=82=B9=E5=9C=A8=E4=BC=AA=E9=80=A0=E5=A4=B4=E5=89=A5?= =?UTF-8?q?=E9=99=A4=E4=B9=8B=E5=90=8E=EF=BC=8CFAIL/ANON=20=E4=B8=8D?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=EF=BC=9B=E5=9B=9B=E7=94=A8=E4=BE=8B=E7=BB=BF?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/forward/CallerHeader.java | 32 +++++ .../gateway/forward/StreamingForwarder.java | 5 + .../gateway/forward/CallerHeaderTest.java | 115 ++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/main/java/com/yunhai/platform/gateway/forward/CallerHeader.java create mode 100644 src/test/java/com/yunhai/platform/gateway/forward/CallerHeaderTest.java 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"); + } +}