Found while diffing compat-matrix legs for #3351. Filed separately because it is unrelated to that work, and because the flaky spec is the visible symptom of a real product defect.
The defect
vendor/wheels/controller/csrf.cfc::$decryptCsrfCookieValue() reads a CSRF cookie by trying the configured algorithm first and falling back to the legacy bare AES (ECB) only from its catch block:
try {
local.state.decrypted = Decrypt(value, key, application.wheels.csrfCookieEncryptionAlgorithm, encoding);
} catch (any e) {
if (application.wheels.csrfCookieEncryptionAlgorithm != "AES") {
try {
local.state.decrypted = Decrypt(value, key, "AES", encoding);
} catch (any legacyDecryptError) {}
}
}
That treats "did not throw" as "decrypted correctly", which is not true for a wrong-mode decrypt.
Under AES/CBC/PKCS5Padding, decrypting a ciphertext that was actually written with bare-AES/ECB throws only when the trailing plaintext bytes fail PKCS5 padding validation. They pass by chance roughly 1 time in 256. When that happens Decrypt() returns garbage instead of erroring, the legacy fallback never runs, and the cookie is treated as corrupted.
AES/GCM/NoPadding is authenticated, so it reliably throws — this only affects engines that fall back to CBC. events/init/security.cfm probes GCM at startup and falls back to CBC when the engine can't run it through Encrypt()/Decrypt(), which is the documented Lucee case.
Impact
A user still holding a legacy ECB-encrypted cookie has a ~1-in-256 chance per request of it reading as corrupted.
It fails closed — csrf.cfc:232 checks IsJSON() and returns "" — so this is a reliability wart confined to the legacy-cookie migration window, not a security hole. Worth stating plainly rather than inflating.
Measurement
The spec CsrfCookieCipherSpec › still reads cookies encrypted with the legacy bare AES (ECB) algorithm exercises exactly this path, and its payload contains a CreateUUID(), so the ciphertext — and the coin flip — differs every run.
Across 326 non-empty compat-matrix legs collected on 2026-08-04 it failed exactly once, on lucee6 / sqlite:
Failed | still reads cookies encrypted with the legacy bare AES (ECB) algorithm
| Expected [false] to be true
1/326 = 0.31% observed, against ~0.39% theoretical for random PKCS5 padding validity. The mechanism and the rate agree.
Suggested fix
Validate the first decrypt's result before accepting it, rather than relying on it having thrown. This cookie's plaintext is always the JSON written by $generateCookieAuthenticityToken() (SerializeJSON({sessionId, authenticityToken})), so a non-JSON result means it was decrypted with the wrong algorithm and the legacy attempt should still run.
That closes the window and makes the spec deterministic as a side effect, rather than needing a separate test-only patch.
Found while diffing compat-matrix legs for #3351. Filed separately because it is unrelated to that work, and because the flaky spec is the visible symptom of a real product defect.
The defect
vendor/wheels/controller/csrf.cfc::$decryptCsrfCookieValue()reads a CSRF cookie by trying the configured algorithm first and falling back to the legacy bareAES(ECB) only from itscatchblock:try { local.state.decrypted = Decrypt(value, key, application.wheels.csrfCookieEncryptionAlgorithm, encoding); } catch (any e) { if (application.wheels.csrfCookieEncryptionAlgorithm != "AES") { try { local.state.decrypted = Decrypt(value, key, "AES", encoding); } catch (any legacyDecryptError) {} } }That treats "did not throw" as "decrypted correctly", which is not true for a wrong-mode decrypt.
Under
AES/CBC/PKCS5Padding, decrypting a ciphertext that was actually written with bare-AES/ECB throws only when the trailing plaintext bytes fail PKCS5 padding validation. They pass by chance roughly 1 time in 256. When that happensDecrypt()returns garbage instead of erroring, the legacy fallback never runs, and the cookie is treated as corrupted.AES/GCM/NoPaddingis authenticated, so it reliably throws — this only affects engines that fall back to CBC.events/init/security.cfmprobes GCM at startup and falls back to CBC when the engine can't run it throughEncrypt()/Decrypt(), which is the documented Lucee case.Impact
A user still holding a legacy ECB-encrypted cookie has a ~1-in-256 chance per request of it reading as corrupted.
It fails closed —
csrf.cfc:232checksIsJSON()and returns""— so this is a reliability wart confined to the legacy-cookie migration window, not a security hole. Worth stating plainly rather than inflating.Measurement
The spec
CsrfCookieCipherSpec › still reads cookies encrypted with the legacy bare AES (ECB) algorithmexercises exactly this path, and its payload contains aCreateUUID(), so the ciphertext — and the coin flip — differs every run.Across 326 non-empty compat-matrix legs collected on 2026-08-04 it failed exactly once, on
lucee6 / sqlite:1/326 = 0.31% observed, against ~0.39% theoretical for random PKCS5 padding validity. The mechanism and the rate agree.
Suggested fix
Validate the first decrypt's result before accepting it, rather than relying on it having thrown. This cookie's plaintext is always the JSON written by
$generateCookieAuthenticityToken()(SerializeJSON({sessionId, authenticityToken})), so a non-JSON result means it was decrypted with the wrong algorithm and the legacy attempt should still run.That closes the window and makes the spec deterministic as a side effect, rather than needing a separate test-only patch.