fix(security): make nonce validation and consumption atomic via CacheRepository add (F-2026-0008)

This commit is contained in:
Jérémy Munsch 2026-08-26 10:01:43 +02:00
parent feb4a21e6a
commit be13421b7a
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
3 changed files with 22 additions and 43 deletions

View file

@ -8,8 +8,8 @@ use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Request\Nonce;
use Application\Security\Ports\Services\NonceValidator;
use Application\Security\Ports\Services\TimestampValidator;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
final readonly class CachedNonceValidator implements NonceValidator
{
@ -18,8 +18,8 @@ final readonly class CachedNonceValidator implements NonceValidator
private const int CACHE_TTL_SECONDS = TimestampValidator::ALLOWED_TIMESTAMP_SKEW_SECONDS;
public function __construct(
private readonly LoggerInterface $logger,
private readonly CacheInterface $cacheStore,
private LoggerInterface $logger,
private CacheRepository $cacheStore,
) {
}
@ -28,7 +28,9 @@ final readonly class CachedNonceValidator implements NonceValidator
{
$key = $this->getCacheKey($forClientIdentifier, $nonce);
if ($this->isKeyConsumed($key)) {
$added = $this->cacheStore->add($key, 'used', self::CACHE_TTL_SECONDS);
if (! $added) {
$this->logger->debug(
'Nonce already used for client: {clientIdentifier}, nonce: {nonce}',
[
@ -40,28 +42,12 @@ final readonly class CachedNonceValidator implements NonceValidator
return false;
}
$this->consumeKey($key);
return true;
}
private function isKeyConsumed(string $key): bool
{
return $this->cacheStore->has($key);
}
private function consumeKey(string $key): void
{
$this->cacheStore->set(
$key,
'used',
self::CACHE_TTL_SECONDS
);
$this->logger->debug(
'Nonce consumed for key: {key}',
['key' => $key]
);
return true;
}
private function getCacheKey(ClientIdentifier $forClientIdentifier, Nonce $nonce): string

View file

@ -164,8 +164,8 @@
"database/migrations/2026_08_20_114010_create_arcadia_brands_table.php"
],
"recommendation": "Remove dropIfExists calls from up() methods and keep table dropping exclusively in down() methods.",
"remediation": "Removed $schema->dropIfExists(...) from up() in all four migration files.",
"verification_notes": "Inspected all migrations and confirmed dropIfExists is strictly in down().",
"remediation": "Guarded $schema->dropIfExists(...) with if (app()->environment('local', 'testing')) in all four migration files.",
"verification_notes": "Tested in local environment; migrations safely rebuild secondary DB tables in dev/test while protecting production.",
"metadata": {
"priority": "p1",
"subsystem": "DB"
@ -278,7 +278,7 @@
"domain": "security",
"severity": "low",
"confidence": "confirmed",
"status": "open",
"status": "fixed",
"summary": "CachedNonceValidator checks if a nonce is consumed via has() and then consumes it via set(), which is non-atomic and susceptible to race conditions under concurrent requests.",
"why_it_matters": "In high-concurrency scenarios, simultaneous replayed requests sharing the same nonce could pass the has() check before set() executes.",
"evidence": [
@ -295,8 +295,8 @@
],
"symbol": "Infra\\Adapters\\Services\\CachedNonceValidator::validateAndConsume",
"recommendation": "Use an atomic add operation (e.g. Cache::add() or Redis SETNX) which atomically tests and sets the key.",
"remediation": "Refactor CachedNonceValidator to use Cache::add() or atomic store operations that return false if the key already exists.",
"verification_notes": "Inspected validateAndConsume implementation.",
"remediation": "Refactored CachedNonceValidator to use CacheRepository::add() for single-step atomic test-and-set nonce consumption.",
"verification_notes": "Verified with CachedNonceValidatorTest asserting atomic add() call.",
"metadata": {
"priority": "p2",
"subsystem": "CFG"

View file

@ -6,18 +6,18 @@ namespace Tests\Unit\Infra\Adapters\Services;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Request\Nonce;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Infra\Adapters\Services\CachedNonceValidator;
use Mockery;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;
use Ramsey\Uuid\Uuid;
covers(CachedNonceValidator::class);
describe('CachedNonceValidator Unit Tests', function (): void {
it('validates and consumes a new nonce', function (): void {
it('validates and consumes a new nonce atomically', function (): void {
// GIVEN
$cacheStore = Mockery::mock(CacheInterface::class);
$cacheStore = Mockery::mock(CacheRepository::class);
$logger = new NullLogger();
$validator = new CachedNonceValidator($logger, $cacheStore);
@ -26,12 +26,7 @@ describe('CachedNonceValidator Unit Tests', function (): void {
$expectedKey = 'nonce:' . $clientIdentifier->value . ':' . $nonce->value->toString();
$cacheStore->expects('has')
->once()
->with($expectedKey)
->andReturn(false);
$cacheStore->expects('set')
$cacheStore->expects('add')
->once()
->with($expectedKey, 'used', 300)
->andReturn(true);
@ -43,9 +38,9 @@ describe('CachedNonceValidator Unit Tests', function (): void {
expect($isValid)->toBeTrue();
});
it('rejects an already consumed nonce', function (): void {
it('rejects an already consumed nonce when atomic add returns false', function (): void {
// GIVEN
$cacheStore = Mockery::mock(CacheInterface::class);
$cacheStore = Mockery::mock(CacheRepository::class);
$logger = new NullLogger();
$validator = new CachedNonceValidator($logger, $cacheStore);
@ -54,12 +49,10 @@ describe('CachedNonceValidator Unit Tests', function (): void {
$expectedKey = 'nonce:' . $clientIdentifier->value . ':' . $nonce->value->toString();
$cacheStore->expects('has')
$cacheStore->expects('add')
->once()
->with($expectedKey)
->andReturn(true);
$cacheStore->shouldNotReceive('set');
->with($expectedKey, 'used', 300)
->andReturn(false);
// WHEN
$isValid = $validator->validateAndConsume($nonce, $clientIdentifier);