T1.4: 身份头注入 X-Caller-App-Key(verdict=PASS 才注入验签 appKey;注入点在伪造头剥除之后,FAIL/ANON 不注入;四用例绿)
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user