This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cxf.git
The following commit(s) were added to refs/heads/main by this push:
new fd3cc5f7d93 Don't perpetuate security sensitive values from the JWT
(#3317)
fd3cc5f7d93 is described below
commit fd3cc5f7d936f5f3fc796a402e10069e0d1200a4
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Mon Jul 20 09:12:00 2026 +0100
Don't perpetuate security sensitive values from the JWT (#3317)
---
.../oauth2/grants/code/JwtRequestCodeFilter.java | 19 ++
.../grants/code/JwtRequestCodeFilterTest.java | 201 +++++++++++++++++++++
2 files changed, 220 insertions(+)
diff --git
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/code/JwtRequestCodeFilter.java
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/code/JwtRequestCodeFilter.java
index 285d1b00ff5..d0681f9017f 100644
---
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/code/JwtRequestCodeFilter.java
+++
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/code/JwtRequestCodeFilter.java
@@ -19,8 +19,11 @@
package org.apache.cxf.rs.security.oauth2.grants.code;
import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.logging.Logger;
import jakarta.ws.rs.core.MultivaluedMap;
@@ -47,6 +50,17 @@ public class JwtRequestCodeFilter extends
OAuthJoseJwtConsumer implements Author
private static final String REQUEST_URI_CONTENT_TYPE =
"application/oauth-authz-req+jwt";
private static final String REQUEST_PARAM = "request";
private static final String REQUEST_URI_PARAM = "request_uri";
+ /**
+ * Security-sensitive parameters that must be sourced from the outer HTTP
+ * request per RFC 9101 §2.2. Any JWT claim whose key appears in this set
+ * is silently ignored; the outer request value is always authoritative.
+ */
+ private static final Set<String> OUTER_REQUEST_ONLY_PARAMS = new
HashSet<>(Arrays.asList(
+ OAuthConstants.AUTHORIZATION_CODE_CHALLENGE,
+ OAuthConstants.AUTHORIZATION_CODE_CHALLENGE_METHOD,
+ OAuthConstants.NONCE,
+ OAuthConstants.STATE
+ ));
private boolean verifyWithClientCertificates;
private String issuer;
@@ -105,6 +119,11 @@ public class JwtRequestCodeFilter extends
OAuthJoseJwtConsumer implements Author
List<Object> list = CastUtils.cast((List<?>)value);
value = jsonHandler.toJson(list);
}
+ // RFC 9101 §2.2: security-sensitive parameters must be sourced
+ // from the outer request; JWT claims for these keys are
ignored.
+ if (OUTER_REQUEST_ONLY_PARAMS.contains(key)) {
+ continue;
+ }
newParams.putSingle(key, value.toString());
}
return newParams;
diff --git
a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/grants/code/JwtRequestCodeFilterTest.java
b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/grants/code/JwtRequestCodeFilterTest.java
new file mode 100644
index 00000000000..ac4792cece1
--- /dev/null
+++
b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/grants/code/JwtRequestCodeFilterTest.java
@@ -0,0 +1,201 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cxf.rs.security.oauth2.grants.code;
+
+import jakarta.ws.rs.core.MultivaluedMap;
+import org.apache.cxf.jaxrs.impl.MetadataMap;
+import org.apache.cxf.rs.security.jose.jwa.SignatureAlgorithm;
+import org.apache.cxf.rs.security.jose.jws.HmacJwsSignatureProvider;
+import org.apache.cxf.rs.security.jose.jws.HmacJwsSignatureVerifier;
+import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactProducer;
+import org.apache.cxf.rs.security.jose.jwt.JwtClaims;
+import org.apache.cxf.rs.security.oauth2.common.Client;
+import org.apache.cxf.rs.security.oauth2.utils.OAuthConstants;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Tests that security-sensitive outer request parameters (code_challenge,
+ * code_challenge_method, nonce, state) cannot be silently overridden by
+ * claims inside a JAR (JWT Authorization Request) — even when the JWT
+ * carries a valid signature — per RFC 9101 §2.2.
+ */
+public class JwtRequestCodeFilterTest {
+
+ private static final String CLIENT_ID = "test-client";
+ /**
+ * Signing key used for HMAC-SHA256; length ≥ 32 bytes satisfies HS256
+ * security requirements for unit-test purposes.
+ */
+ private static final String SIGNING_KEY =
"jwt-request-code-filter-test-key";
+
+ // ------------------------------------------------------------------
+ // Helpers
+ // ------------------------------------------------------------------
+
+ private static JwtRequestCodeFilter buildFilter() {
+ JwtRequestCodeFilter filter = new JwtRequestCodeFilter();
+ // Inject a verifier directly so the filter can validate the JWT
+ // without loading key material from system properties or a keystore.
+ filter.setJwsVerifier(new HmacJwsSignatureVerifier(SIGNING_KEY,
SignatureAlgorithm.HS256));
+ return filter;
+ }
+
+ private static Client buildClient() {
+ return new Client(CLIENT_ID, "secret", true);
+ }
+
+ /** Returns baseline JWT claims that satisfy the filter's own issuer
check. */
+ private static JwtClaims baselineClaims() {
+ JwtClaims claims = new JwtClaims();
+ claims.setIssuer(CLIENT_ID);
+ return claims;
+ }
+
+ private static String buildSignedJwt(JwtClaims claims) {
+ JwsJwtCompactProducer producer = new JwsJwtCompactProducer(claims);
+ return producer.signWith(
+ new HmacJwsSignatureProvider(SIGNING_KEY,
SignatureAlgorithm.HS256));
+ }
+
+ // ------------------------------------------------------------------
+ // Vulnerability tests (currently FAIL — demonstrate CWE-20 / RFC 9101)
+ // ------------------------------------------------------------------
+
+ /**
+ * A validly-signed request JWT carrying {@code code_challenge} and
+ * {@code code_challenge_method} must not replace the values supplied
+ * in the outer HTTP request. Allowing the override lets an attacker
+ * who controls the JWT payload substitute the client's PKCE challenge
+ * with one of their choosing (CVSS 3.1 ~4.1 MEDIUM).
+ */
+ @Test
+ public void testCodeChallengeNotOverriddenByJwtClaim() {
+ JwtRequestCodeFilter filter = buildFilter();
+
+ JwtClaims claims = baselineClaims();
+ claims.setClaim(OAuthConstants.AUTHORIZATION_CODE_CHALLENGE,
"EVIL-CHALLENGE");
+ claims.setClaim(OAuthConstants.AUTHORIZATION_CODE_CHALLENGE_METHOD,
"S256");
+
+ MultivaluedMap<String, String> params = new MetadataMap<>();
+ params.putSingle(OAuthConstants.RESPONSE_TYPE,
OAuthConstants.CODE_RESPONSE_TYPE);
+ params.putSingle(OAuthConstants.AUTHORIZATION_CODE_CHALLENGE,
"legit-client-challenge");
+ params.putSingle(OAuthConstants.AUTHORIZATION_CODE_CHALLENGE_METHOD,
"plain");
+ params.putSingle("request", buildSignedJwt(claims));
+
+ MultivaluedMap<String, String> result = filter.process(params, null,
buildClient());
+
+ assertEquals("JWT must not override the outer code_challenge",
+ "legit-client-challenge",
+ result.getFirst(OAuthConstants.AUTHORIZATION_CODE_CHALLENGE));
+ assertEquals("JWT must not override the outer code_challenge_method",
+ "plain",
+
result.getFirst(OAuthConstants.AUTHORIZATION_CODE_CHALLENGE_METHOD));
+ }
+
+ /**
+ * {@code nonce} is an anti-replay parameter established by the client
+ * in the outer request. A JWT must not be allowed to replace it, as
+ * doing so undermines OpenID Connect replay protection.
+ */
+ @Test
+ public void testNonceNotOverriddenByJwtClaim() {
+ JwtRequestCodeFilter filter = buildFilter();
+
+ JwtClaims claims = baselineClaims();
+ claims.setClaim(OAuthConstants.NONCE, "EVIL-NONCE");
+
+ MultivaluedMap<String, String> params = new MetadataMap<>();
+ params.putSingle(OAuthConstants.RESPONSE_TYPE,
OAuthConstants.CODE_RESPONSE_TYPE);
+ params.putSingle(OAuthConstants.NONCE, "outer-nonce");
+ params.putSingle("request", buildSignedJwt(claims));
+
+ MultivaluedMap<String, String> result = filter.process(params, null,
buildClient());
+
+ assertEquals("JWT must not override the outer nonce",
+ "outer-nonce",
+ result.getFirst(OAuthConstants.NONCE));
+ }
+
+ /**
+ * {@code state} is the client's CSRF token. A JWT must not be allowed
+ * to replace it, as doing so can redirect the authorization response
+ * to an attacker-controlled state.
+ */
+ @Test
+ public void testStateNotOverriddenByJwtClaim() {
+ JwtRequestCodeFilter filter = buildFilter();
+
+ JwtClaims claims = baselineClaims();
+ claims.setClaim(OAuthConstants.STATE, "EVIL-STATE");
+
+ MultivaluedMap<String, String> params = new MetadataMap<>();
+ params.putSingle(OAuthConstants.RESPONSE_TYPE,
OAuthConstants.CODE_RESPONSE_TYPE);
+ params.putSingle(OAuthConstants.STATE, "outer-state");
+ params.putSingle("request", buildSignedJwt(claims));
+
+ MultivaluedMap<String, String> result = filter.process(params, null,
buildClient());
+
+ assertEquals("JWT must not override the outer state",
+ "outer-state",
+ result.getFirst(OAuthConstants.STATE));
+ }
+
+ /**
+ * Non-sensitive claims present in the JWT (e.g. {@code scope}) that are
+ * absent from the outer request should still be applied to the resulting
+ * parameter map — the fix must not strip all JWT claims.
+ */
+ @Test
+ public void testNonSensitiveJwtClaimsAreApplied() {
+ JwtRequestCodeFilter filter = buildFilter();
+
+ JwtClaims claims = baselineClaims();
+ claims.setClaim(OAuthConstants.SCOPE, "read write");
+
+ MultivaluedMap<String, String> params = new MetadataMap<>();
+ params.putSingle(OAuthConstants.RESPONSE_TYPE,
OAuthConstants.CODE_RESPONSE_TYPE);
+ params.putSingle("request", buildSignedJwt(claims));
+
+ MultivaluedMap<String, String> result = filter.process(params, null,
buildClient());
+
+ assertEquals("Scope from JWT should be applied when absent from outer
request",
+ "read write",
+ result.getFirst(OAuthConstants.SCOPE));
+ }
+
+ /**
+ * When no {@code request} parameter is present the filter must return
+ * the original params unchanged.
+ */
+ @Test
+ public void testNoRequestParamReturnsOriginalParams() {
+ JwtRequestCodeFilter filter = buildFilter();
+
+ MultivaluedMap<String, String> params = new MetadataMap<>();
+ params.putSingle(OAuthConstants.RESPONSE_TYPE,
OAuthConstants.CODE_RESPONSE_TYPE);
+ params.putSingle(OAuthConstants.STATE, "original-state");
+
+ MultivaluedMap<String, String> result = filter.process(params, null,
buildClient());
+
+ assertEquals("original-state", result.getFirst(OAuthConstants.STATE));
+ }
+}