From 607cdaf38fbc120400541584809137332232d1b0 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 7 Aug 2026 12:47:28 -0400 Subject: [PATCH] Fix single-use CSRF token: a failed submit no longer burns it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zend_Form_Element_Hash arms the CSRF token with a 1-HOP session expiration, so the first submit consumes it. If that submit fails any OTHER field's validation, the corrected resubmit dies with "your security token expired" and the only cure is a full page refresh — reproduced on the profile Security / change-password form (fix the flagged New Password, resubmit, token already gone). That contradicts Tiger_Form's own documented CSRF design ("the token isn't single-use; it validates until it times out", CSRF_TIMEOUT = 2h, salt-shared across related endpoints). Add Tiger_Form_Element_Hash, which arms the token on the seconds TTL only and drops the hop limit; Tiger_Form uses it in place of the stock 'hash'. One rendered token now survives a failed-then-corrected resubmit within the session. Verified end-to-end on dev-com: two changePassword submits with the same rendered token both return the field error, neither the CSRF error. Unit test asserts the element arms on timeout (no hop), with a contrast test pinning the stock Zend default. Co-Authored-By: Claude Opus 4.8 (1M context) --- library/Tiger/Form.php | 5 ++- library/Tiger/Form/Element/Hash.php | 34 ++++++++++++++++ tests/Unit/Form/HashElementTest.php | 62 +++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 library/Tiger/Form/Element/Hash.php create mode 100644 tests/Unit/Form/HashElementTest.php diff --git a/library/Tiger/Form.php b/library/Tiger/Form.php index 9217958..9fd3c22 100644 --- a/library/Tiger/Form.php +++ b/library/Tiger/Form.php @@ -80,7 +80,10 @@ public function init() // no session cookie, so it's CSRF-immune by construction and MUST skip the check — see the // per-mode design in WEBSERVICES.md §8. The gateway flags token requests via the registry. if ($this->csrf() && !(Zend_Registry::isRegistered('tiger.auth.stateless') && Zend_Registry::get('tiger.auth.stateless'))) { - $this->addElement('hash', '_csrf', ['salt' => $this->csrfSalt(), 'timeout' => static::CSRF_TIMEOUT]); + // Tiger_Form_Element_Hash (not the stock 'hash'): a timeout-lived token instead of Zend's + // single-hop one, so a first submit that fails another field doesn't burn the token and leave + // the corrected resubmit with "security token expired". See that class + csrfSalt() below. + $this->addElement(new Tiger_Form_Element_Hash('_csrf', ['salt' => $this->csrfSalt(), 'timeout' => static::CSRF_TIMEOUT])); } // Declarative schema: [type, name, options]. diff --git a/library/Tiger/Form/Element/Hash.php b/library/Tiger/Form/Element/Hash.php new file mode 100644 index 0000000..6467847 --- /dev/null +++ b/library/Tiger/Form/Element/Hash.php @@ -0,0 +1,34 @@ +getSession(); + $session->setExpirationSeconds($this->getTimeout()); + $session->hash = $this->getHash(); + } +} diff --git a/tests/Unit/Form/HashElementTest.php b/tests/Unit/Form/HashElementTest.php new file mode 100644 index 0000000..6995a36 --- /dev/null +++ b/tests/Unit/Form/HashElementTest.php @@ -0,0 +1,62 @@ +hops = $hops; return $this; } + public function setExpirationSeconds($s) { $this->seconds = $s; return $this; } + }; + } + + #[Test] + public function armsTokenOnTimeoutNotASingleHop(): void + { + $el = new Tiger_Form_Element_Hash('_csrf', ['salt' => 'unit', 'timeout' => 7200]); + $session = $this->fakeSession(); + $el->setSession($session); + + $el->initCsrfToken(); + + $this->assertSame('UNSET', $session->hops, 'no single-hop expiration is armed — that was the bug'); + $this->assertSame(7200, $session->seconds, 'the token lives for its full timeout instead'); + $this->assertNotEmpty($session->hash, 'a token is generated and stored'); + } + + #[Test] + public function contrastStockZendHashArmsASingleHop(): void + { + // Documents exactly what the subclass overrides: the stock element sets a 1-hop expiration. + $el = new Zend_Form_Element_Hash('_csrf', ['salt' => 'unit', 'timeout' => 7200]); + $session = $this->fakeSession(); + $el->setSession($session); + + $el->initCsrfToken(); + + $this->assertSame(1, $session->hops, 'stock Zend arms a single-use (1-hop) token'); + } +}