You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@knox.apache.org by ha...@apache.org on 2026/08/12 13:27:59 UTC

(knox) 01/02: KNOX-3413: KnoxToken passcode verification accepts a valid passcode for a different token (#1345)

This is an automated email from the ASF dual-hosted git repository.

hanicz pushed a commit to branch v3.0.0
in repository https://gitbox.apache.org/repos/asf/knox.git

commit e065a13bce43248315d8570e1b4b15019b517e02
Author: hanicz <ha...@users.noreply.github.com>
AuthorDate: Wed Aug 12 15:26:22 2026 +0200

    KNOX-3413: KnoxToken passcode verification accepts a valid passcode for a different token (#1345)
    
    (cherry picked from commit 5342483a2c7ff229d8819d7df479980753640fc1)
---
 .../federation/jwt/filter/AbstractJWTFilter.java   |  9 ++--
 .../provider/federation/AbstractJWTFilterTest.java |  3 ++
 .../federation/JWTFederationFilterTest.java        |  2 +-
 .../federation/OAuthFlowsFederationFilterTest.java |  9 +++-
 ...okenIDAsHTTPBasicCredsFederationFilterTest.java | 50 ++++++++++++++++++++++
 5 files changed, 67 insertions(+), 6 deletions(-)

diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
index de4987caa..502928891 100644
--- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
@@ -568,7 +568,7 @@ public abstract class AbstractJWTFilter implements Filter {
             final TokenMetadata tokenMetadata = tokenStateService == null ? null : tokenStateService.getTokenMetadata(tokenId);
             if (isTokenEnabled(tokenMetadata)) {
               if (isIdleTimeoutLimitNotExceeded(tokenMetadata)) {
-                if (hasSignatureBeenVerified(passcode) || validatePasscode(tokenId, passcode)) {
+                if (hasSignatureBeenVerified(passcodeVerificationCacheKey(tokenId, passcode)) || validatePasscode(tokenId, passcode)) {
                   markLastUsedAt(tokenId, tokenMetadata);
                   return true;
                 } else {
@@ -589,7 +589,7 @@ public abstract class AbstractJWTFilter implements Filter {
             // Explicitly evict the record of this token's signature verification (if present).
             // There is no value in keeping this record for expired tokens, and explicitly removing them may prevent
             // records for other valid tokens from being prematurely evicted from the cache.
-            removeSignatureVerificationRecord(passcode);
+            removeSignatureVerificationRecord(passcodeVerificationCacheKey(tokenId, passcode));
             handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, "Token has expired");
           }
         } else {
@@ -615,7 +615,7 @@ public abstract class AbstractJWTFilter implements Filter {
     final byte[] storedPasscode = tokenMetadata == null ? null : tokenMetadata.getPasscode().getBytes(UTF_8);
     final boolean validPasscode = Arrays.equals(tokenMAC.hash(tokenId, issueTime, userName, passcode).getBytes(UTF_8), storedPasscode);
     if (validPasscode) {
-      recordSignatureVerification(passcode);
+      recordSignatureVerification(passcodeVerificationCacheKey(tokenId, passcode));
     }
     return validPasscode;
   }
@@ -707,4 +707,7 @@ public abstract class AbstractJWTFilter implements Filter {
   protected abstract void handleValidationError(HttpServletRequest request, HttpServletResponse response, int status,
                                                 String error) throws IOException;
 
+  private String passcodeVerificationCacheKey(final String tokenId, final String passcode) {
+    return tokenId + "::" + passcode;
+  }
 }
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java
index f025aa277..16f71d959 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java
@@ -1520,4 +1520,7 @@ public abstract class AbstractJWTFilterTest  {
       }
   }
 
+  protected String passcodeVerificationCacheKey(final String tokenId, final String passcode) {
+    return tokenId + "::" + passcode;
+  }
 }
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTest.java
index 2ea5524b9..6c66d44a3 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTest.java
@@ -195,7 +195,7 @@ public class JWTFederationFilterTest extends AbstractJWTFilterTest {
     }
     EasyMock.replay(tokenStateService, tokenMetadata, request, response);
 
-    SignatureVerificationCache.getInstance(topologyName, filterConfig).recordSignatureVerification(passcode);
+    SignatureVerificationCache.getInstance(topologyName, filterConfig).recordSignatureVerification(passcodeVerificationCacheKey(tokenId, passcode));
 
     final TestFilterChain chain = new TestFilterChain();
     handler.doFilter(request, response, chain);
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java
index 5fc257e05..6fc873f68 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java
@@ -278,7 +278,7 @@ public class OAuthFlowsFederationFilterTest extends TokenIDAsHTTPBasicCredsFeder
         // Wrap the request to simulate real-world scenario where wrappers hide parameter access
         final HttpServletRequest request = new TestServletRequestWrapper(mockRequest);
 
-        SignatureVerificationCache.getInstance(topologyName, filterConfig).recordSignatureVerification(passcode);
+        SignatureVerificationCache.getInstance(topologyName, filterConfig).recordSignatureVerification(passcodeVerificationCacheKey(tokenId, passcode));
 
         final TestFilterChain chain = new TestFilterChain();
         handler.doFilter(request, response, chain);
@@ -387,6 +387,11 @@ public class OAuthFlowsFederationFilterTest extends TokenIDAsHTTPBasicCredsFeder
     public void testUnableToParseJWT() throws Exception {
     }
 
+    @Override
+    @Test
+    public void testPasscodeCannotBeReplayedAgainstDifferentTokenId() {
+    }
+
     @Test
     public void testGetWireTokenUsingRefreshTokenFlow() throws Exception {
       final String refreshToken = "WTJ4cFpXNTBMV2xrTFRFeU16UTE6OlkyeHBaVzUwTFhObFkzSmxkQzB4TWpNME5RPT0=";
@@ -470,7 +475,7 @@ public class OAuthFlowsFederationFilterTest extends TokenIDAsHTTPBasicCredsFeder
         // Wrap the request to simulate real-world scenario where wrappers hide parameter access
         final HttpServletRequest request = new TestServletRequestWrapper(mockRequest);
 
-        SignatureVerificationCache.getInstance("jwt-topology", filterConfig).recordSignatureVerification(passcode);
+        SignatureVerificationCache.getInstance("jwt-topology", filterConfig).recordSignatureVerification(passcodeVerificationCacheKey(tokenId, passcode));
 
         final TestFilterChain chain = new TestFilterChain();
         handler.doFilter(request, response, chain);
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/TokenIDAsHTTPBasicCredsFederationFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/TokenIDAsHTTPBasicCredsFederationFilterTest.java
index b92158f89..dadd41cff 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/TokenIDAsHTTPBasicCredsFederationFilterTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/TokenIDAsHTTPBasicCredsFederationFilterTest.java
@@ -20,6 +20,7 @@ package org.apache.knox.gateway.provider.federation;
 
 import static org.junit.Assert.fail;
 
+import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.text.ParseException;
 import java.time.Instant;
@@ -265,6 +266,55 @@ public class TokenIDAsHTTPBasicCredsFederationFilterTest extends JWTAsHTTPBasicC
         }
     }
 
+    @Test
+    public void testPasscodeCannotBeReplayedAgainstDifferentTokenId() throws Exception {
+        Properties props = getProperties();
+        handler.init(new TestFilterConfig(props));
+
+        final long issueTime = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5);
+        final Date expiry = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5));
+
+        final SignedJWT attackerJwt = getJWT(AbstractJWTFilter.JWT_DEFAULT_ISSUER, "attacker", expiry, privateKey);
+        final String attackerPasscode = (String) attackerJwt.getJWTClaimsSet().getClaims().get(PASSCODE_CLAIM);
+        addTokenState(attackerJwt, issueTime, "attacker", attackerPasscode);
+
+        final SignedJWT victimJwt = getJWT(AbstractJWTFilter.JWT_DEFAULT_ISSUER, "bob", expiry, privateKey);
+        final String victimPasscode = (String) victimJwt.getJWTClaimsSet().getClaims().get(PASSCODE_CLAIM);
+        addTokenState(victimJwt, issueTime, "bob", victimPasscode);
+        final String victimTokenId = getTokenId(victimJwt);
+
+        final TestFilterChain seedChain = new TestFilterChain();
+        handler.doFilter(newPasscodeRequest(generatePasscodeField(getTokenId(attackerJwt), attackerPasscode)),
+                newResponse(), seedChain);
+        Assert.assertTrue("Precondition: the attacker's own passcode should authenticate.", seedChain.doFilterCalled);
+
+        final TestFilterChain attackChain = new TestFilterChain();
+        handler.doFilter(newPasscodeRequest(generatePasscodeField(victimTokenId, attackerPasscode)),
+                newResponse(), attackChain);
+
+        Assert.assertFalse("A passcode must not authenticate when paired with a different token id "
+                + "(identity-assertion / authentication bypass).", attackChain.doFilterCalled);
+        Assert.assertNull("No subject should have been established for the replayed passcode.", attackChain.getSubject());
+    }
+
+    private HttpServletRequest newPasscodeRequest(final String passcodeField) {
+        final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+        setTokenOnRequest(request, JWTFederationFilter.PASSCODE, passcodeField);
+        EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+        EasyMock.expect(request.getPathInfo()).andReturn("resource").anyTimes();
+        EasyMock.expect(request.getQueryString()).andReturn(null).anyTimes();
+        EasyMock.replay(request);
+        return request;
+    }
+
+    private HttpServletResponse newResponse() throws IOException {
+        final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+        EasyMock.expect(response.encodeRedirectURL(SERVICE_URL)).andReturn(SERVICE_URL).anyTimes();
+        EasyMock.expect(response.getOutputStream()).andAnswer(DummyServletOutputStream::new).anyTimes();
+        EasyMock.replay(response);
+        return response;
+    }
+
     @Override
     public void testJWTWithoutKnoxUUIDClaim() throws Exception {
         // Override to disable N/A test