This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 9f0c1ffa2e fix(jwt): prevent client-supplied headers from spoofing
converted claims (#7967)
9f0c1ffa2e is described below
commit 9f0c1ffa2ee47d6f80949cfad379759349e1fd04
Author: Duansg <[email protected]>
AuthorDate: Thu Oct 22 20:16:46 2026 -0700
fix(jwt): prevent client-supplied headers from spoofing converted claims
(#7967)
---
.../jwt/strategy/DefaultJwtConvertStrategy.java | 41 ++++-----
.../strategy/DefaultJwtConvertStrategyTest.java | 98 ++++++++++++++++++++++
2 files changed, 116 insertions(+), 23 deletions(-)
diff --git
a/shenyu-plugin/shenyu-plugin-security/shenyu-plugin-jwt/src/main/java/org/apache/shenyu/plugin/jwt/strategy/DefaultJwtConvertStrategy.java
b/shenyu-plugin/shenyu-plugin-security/shenyu-plugin-jwt/src/main/java/org/apache/shenyu/plugin/jwt/strategy/DefaultJwtConvertStrategy.java
index ecdf342a8e..6b5725092c 100644
---
a/shenyu-plugin/shenyu-plugin-security/shenyu-plugin-jwt/src/main/java/org/apache/shenyu/plugin/jwt/strategy/DefaultJwtConvertStrategy.java
+++
b/shenyu-plugin/shenyu-plugin-security/shenyu-plugin-jwt/src/main/java/org/apache/shenyu/plugin/jwt/strategy/DefaultJwtConvertStrategy.java
@@ -31,7 +31,7 @@ import org.springframework.web.server.ServerWebExchange;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.atomic.AtomicInteger;
+import java.util.Objects;
@Join
public class DefaultJwtConvertStrategy implements JwtConvertStrategy {
@@ -85,10 +85,14 @@ public class DefaultJwtConvertStrategy implements
JwtConvertStrategy {
continue;
}
- if (converter.getJwtVal().contains(".")) {
- headers.add(converter.getHeaderVal(), parse(body,
converter.getJwtVal().split("\\."), new AtomicInteger(0)));
+ final Object value = converter.getJwtVal().contains(".")
+ ? parse(body, converter.getJwtVal().split("\\."))
+ : body.get(converter.getJwtVal());
+ // overwrite rather than append, so a client-supplied header with
the same name can not spoof the claim
+ if (Objects.isNull(value)) {
+ headers.remove(converter.getHeaderVal());
} else {
- headers.add(converter.getHeaderVal(),
String.valueOf(body.get(converter.getJwtVal())));
+ headers.set(converter.getHeaderVal(), String.valueOf(value));
}
}
@@ -97,27 +101,18 @@ public class DefaultJwtConvertStrategy implements
JwtConvertStrategy {
/**
* Parsing multi-level tokens.
*
- * @param body token
- * @param split jwt of key
- * @param deep level default 0
- * @return token of val
+ * @param body token
+ * @param path jwt of key split by "."
+ * @return token of val, or null if any node on the path is absent or not
a map
*/
- private String parse(final Map<String, Object> body,
- final String[] split,
- final AtomicInteger deep) {
- for (Map.Entry<String, Object> entry : body.entrySet()) {
-
- if (deep.get() == split.length - 1) {
- return String.valueOf(body.get(split[deep.get()]));
- }
-
- if (entry.getKey().equals(split[deep.get()])) {
- if (entry.getValue() instanceof Map) {
- deep.incrementAndGet();
- return parse((Map<String, Object>) entry.getValue(),
split, deep);
- }
+ private Object parse(final Map<String, Object> body, final String[] path) {
+ Object current = body;
+ for (String key : path) {
+ if (!(current instanceof Map)) {
+ return null;
}
+ current = ((Map<?, ?>) current).get(key);
}
- return String.valueOf(body.get(split[deep.get()]));
+ return current;
}
}
diff --git
a/shenyu-plugin/shenyu-plugin-security/shenyu-plugin-jwt/src/test/java/org/apache/shenyu/plugin/jwt/strategy/DefaultJwtConvertStrategyTest.java
b/shenyu-plugin/shenyu-plugin-security/shenyu-plugin-jwt/src/test/java/org/apache/shenyu/plugin/jwt/strategy/DefaultJwtConvertStrategyTest.java
index e3da500be8..3ee8d0b4ec 100644
---
a/shenyu-plugin/shenyu-plugin-security/shenyu-plugin-jwt/src/test/java/org/apache/shenyu/plugin/jwt/strategy/DefaultJwtConvertStrategyTest.java
+++
b/shenyu-plugin/shenyu-plugin-security/shenyu-plugin-jwt/src/test/java/org/apache/shenyu/plugin/jwt/strategy/DefaultJwtConvertStrategyTest.java
@@ -21,16 +21,20 @@ import com.google.common.collect.ImmutableMap;
import org.apache.shenyu.plugin.jwt.rule.DefaultJwtRuleHandle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.ServerWebExchange;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class DefaultJwtConvertStrategyTest {
@@ -188,4 +192,98 @@ public class DefaultJwtConvertStrategyTest {
assertEquals("true", headers.get("X-Can-Read").get(0));
}
+ @Test
+ public void testConvertOverridesClientSuppliedHeader() {
+ ServerWebExchange spoofed =
MockServerWebExchange.from(MockServerHttpRequest
+ .get("localhost")
+ .header("X-User-Id", "admin")
+ .header("X-Web", "evil")
+ .build());
+ String handleJson =
"{\"converter\":[{\"jwtVal\":\"sub\",\"headerVal\":\"X-User-Id\"},"
+ + "{\"jwtVal\":\"multi.web\",\"headerVal\":\"X-Web\"}]}";
+ HttpHeaders headers = defaultJwtConvertStrategy
+
.convert(defaultJwtConvertStrategy.parseHandleJson(handleJson), spoofed,
jwtBody)
+ .getRequest().getHeaders();
+
+ assertEquals(List.of("12345"), headers.get("X-User-Id"));
+ assertEquals(List.of("shenyu"), headers.get("X-Web"));
+ }
+
+ @Test
+ public void testConvertRemovesHeaderWhenClaimMissing() {
+ ServerWebExchange spoofed =
MockServerWebExchange.from(MockServerHttpRequest
+ .get("localhost")
+ .header("X-Tenant", "evil")
+ .header("X-Nested", "evil")
+ .build());
+ String handleJson =
"{\"converter\":[{\"jwtVal\":\"tenant\",\"headerVal\":\"X-Tenant\"},"
+ + "{\"jwtVal\":\"multi.absent\",\"headerVal\":\"X-Nested\"}]}";
+ HttpHeaders headers = defaultJwtConvertStrategy
+
.convert(defaultJwtConvertStrategy.parseHandleJson(handleJson), spoofed,
jwtBody)
+ .getRequest().getHeaders();
+
+ assertFalse(headers.containsKey("X-Tenant"));
+ assertFalse(headers.containsKey("X-Nested"));
+ }
+
+ @Test
+ public void testConvertRemovesHeaderWhenIntermediateNodeIsNotMap() {
+ ServerWebExchange spoofed =
MockServerWebExchange.from(MockServerHttpRequest
+ .get("localhost")
+ .header("X-User-Id", "evil")
+ .header("X-Deep", "evil")
+ .build());
+ Map<String, Object> body = ImmutableMap.of("user", "admin",
+ "multi", ImmutableMap.of("web", "shenyu"));
+ String handleJson =
"{\"converter\":[{\"jwtVal\":\"user.id\",\"headerVal\":\"X-User-Id\"},"
+ + "{\"jwtVal\":\"multi.web.name\",\"headerVal\":\"X-Deep\"}]}";
+ HttpHeaders headers = defaultJwtConvertStrategy
+
.convert(defaultJwtConvertStrategy.parseHandleJson(handleJson), spoofed, body)
+ .getRequest().getHeaders();
+
+ assertFalse(headers.containsKey("X-User-Id"));
+ assertFalse(headers.containsKey("X-Deep"));
+ }
+
+ @Test
+ public void testConvertOverridesMixedCaseAndMultiValueHeaders() {
+ ServerWebExchange spoofed =
MockServerWebExchange.from(MockServerHttpRequest
+ .get("localhost")
+ .header("x-user-id", "admin")
+ .header("X-USER-ID", "root")
+ .header("X-Web", "evil1", "evil2")
+ .build());
+ String handleJson =
"{\"converter\":[{\"jwtVal\":\"sub\",\"headerVal\":\"X-User-Id\"},"
+ + "{\"jwtVal\":\"multi.web\",\"headerVal\":\"x-web\"}]}";
+ HttpHeaders headers = defaultJwtConvertStrategy
+
.convert(defaultJwtConvertStrategy.parseHandleJson(handleJson), spoofed,
jwtBody)
+ .getRequest().getHeaders();
+
+ assertEquals(List.of("12345"), headers.get("x-user-id"));
+ assertEquals(List.of("12345"), headers.get("X-User-Id"));
+ assertEquals(List.of("shenyu"), headers.get("X-Web"));
+ }
+
+ @Test
+ public void testConvertRemovesHeaderWhenClaimIsExplicitNull() {
+ Map<String, Object> nested = new HashMap<>();
+ nested.put("tenant", null);
+ Map<String, Object> body = new HashMap<>();
+ body.put("tenant", null);
+ body.put("multi", nested);
+ ServerWebExchange spoofed =
MockServerWebExchange.from(MockServerHttpRequest
+ .get("localhost")
+ .header("X-Tenant", "evil")
+ .header("X-Nested", "evil1", "evil2")
+ .build());
+ String handleJson =
"{\"converter\":[{\"jwtVal\":\"tenant\",\"headerVal\":\"x-tenant\"},"
+ + "{\"jwtVal\":\"multi.tenant\",\"headerVal\":\"X-Nested\"}]}";
+ HttpHeaders headers = defaultJwtConvertStrategy
+
.convert(defaultJwtConvertStrategy.parseHandleJson(handleJson), spoofed, body)
+ .getRequest().getHeaders();
+
+ assertFalse(headers.containsKey("X-Tenant"));
+ assertFalse(headers.containsKey("X-Nested"));
+ }
+
}