Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion library/Tiger/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
34 changes: 34 additions & 0 deletions library/Tiger/Form/Element/Hash.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Form_Element_Hash — a CSRF token that lives for its full TIMEOUT, not a single request.
*
* Zend_Form_Element_Hash arms the token with a 1-HOP session expiration, which makes it effectively
* single-use: the first submit consumes it, so 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.
* That bit the profile Security / change-password form — correct the flagged field, resubmit, and the
* token is already gone.
*
* Tiger's CSRF design is timeout-based and salt-shared across related endpoints (see
* Tiger_Form::csrfSalt / CSRF_TIMEOUT — "the token isn't single-use; it validates until it times out").
* So we arm the token on the seconds TTL only and DROP the hop limit: one rendered token stays valid for
* CSRF_TIMEOUT within the session, surviving a failed-then-corrected resubmit. Everything else (salt,
* session name, hash generation, the CSRF validator) is inherited unchanged.
*
* @api
*/
class Tiger_Form_Element_Hash extends Zend_Form_Element_Hash
{
/**
* Arm the CSRF token for its timeout WITHOUT the single-hop expiration Zend applies.
*
* @return void
*/
public function initCsrfToken()
{
$session = $this->getSession();
$session->setExpirationSeconds($this->getTimeout());
$session->hash = $this->getHash();
}
}
62 changes: 62 additions & 0 deletions tests/Unit/Form/HashElementTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.

namespace Tiger\Tests\Unit\Form;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Tiger\Tests\Support\UnitTestCase;
use Tiger_Form_Element_Hash;
use Zend_Form_Element_Hash;

/**
* Tiger_Form_Element_Hash — the CSRF token armed on its TIMEOUT, not a single request hop.
*
* The regression: Zend's stock hash sets a 1-hop session expiration, so the first submit burns the
* token; a submit that fails another field then can't be corrected and resubmitted without a full page
* refresh ("your security token expired"). The subclass drops the hop and keeps only the seconds TTL.
* A fake session (injected via setSession) records what initCsrfToken() arms — no real session needed.
*/
#[CoversClass(Tiger_Form_Element_Hash::class)]
final class HashElementTest extends UnitTestCase
{
/** A session double that records the expiration calls + the stored token. */
private function fakeSession(): object
{
return new class {
public $hops = 'UNSET';
public $seconds = null;
public $hash = null;
public function setExpirationHops($hops, $ns = null, $hop = false) { $this->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');
}
}
Loading