Commit 577d9214458b4e3a2a9a1758838635a4d79f4177
1 parent
a9d4726e
fix(security): make JwtRoundTripTest 'tampered token' deterministic
The original test flipped the LAST character of the JWT signature segment from 'a' to 'b' (or vice versa). This was flaky in CI: with a random UUID subject, the issued token's signature segment can end on a base64url character whose flipped value, when decoded, lenient- parses to bits that the JWT decoder accepts as valid — leaving the signature still verifiable. The flake was reproducible locally with `./gradlew test --rerun-tasks` and was the cause of CI failures on the P5.5 and P4.3 docs-pin commits (and would have caught the underlying chunks too if the docs commit hadn't pushed first). Fix: tamper with the PAYLOAD (middle JWT segment) instead. Any change to the payload changes the bytes the signature is computed over, which ALWAYS fails HMAC verification — no edge cases. The test now flips the first character of the payload to a definitely- different valid base64url character, leaving the format intact so the failure mode is "signature does not verify" rather than "malformed token". Verified 10 consecutive `--rerun-tasks` runs all green locally.
Showing
1 changed file
with
23 additions
and
2 deletions
platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt
| @@ -84,8 +84,29 @@ class JwtRoundTripTest { | @@ -84,8 +84,29 @@ class JwtRoundTripTest { | ||
| 84 | @Test | 84 | @Test |
| 85 | fun `tampered token is rejected`() { | 85 | fun `tampered token is rejected`() { |
| 86 | val issued = issuer.issueAccessToken(UUID.randomUUID(), "carol") | 86 | val issued = issuer.issueAccessToken(UUID.randomUUID(), "carol") |
| 87 | - // Flip the last character of the signature segment. | ||
| 88 | - val tampered = issued.value.dropLast(1) + if (issued.value.last() == 'a') 'b' else 'a' | 87 | + // Tamper with the PAYLOAD (middle JWT segment) by flipping one |
| 88 | + // character to a definitely-different valid base64url character. | ||
| 89 | + // Flipping the signature was flaky: the JWT spec uses unpadded | ||
| 90 | + // base64url, and the last character of the signature can encode | ||
| 91 | + // partial bits — flipping it sometimes produces a byte the | ||
| 92 | + // decoder lenient-parses to the same value, leaving the | ||
| 93 | + // signature still valid. Mutating the payload sidesteps the | ||
| 94 | + // edge case entirely: any change to the payload changes the | ||
| 95 | + // bytes the signature is computed over, which always fails | ||
| 96 | + // verification. | ||
| 97 | + val parts = issued.value.split(".") | ||
| 98 | + require(parts.size == 3) { "expected a 3-part JWT, got ${parts.size}" } | ||
| 99 | + val payload = parts[1] | ||
| 100 | + // Flip the first character of the payload to a different | ||
| 101 | + // base64url character that is guaranteed to be different from | ||
| 102 | + // whatever was there. We pick 'A' if the first char isn't 'A', | ||
| 103 | + // else 'B'. Both are valid base64url so the format stays | ||
| 104 | + // intact and the failure mode is "signature does not verify", | ||
| 105 | + // not "malformed token" — exactly what the test should assert. | ||
| 106 | + val firstChar = payload[0] | ||
| 107 | + val replacement = if (firstChar == 'A') 'B' else 'A' | ||
| 108 | + val tamperedPayload = replacement + payload.substring(1) | ||
| 109 | + val tampered = "${parts[0]}.$tamperedPayload.${parts[2]}" | ||
| 89 | 110 | ||
| 90 | assertFailure { verifier.verify(tampered) } | 111 | assertFailure { verifier.verify(tampered) } |
| 91 | .isInstanceOf(AuthenticationFailedException::class) | 112 | .isInstanceOf(AuthenticationFailedException::class) |