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'); + } +}