Jedna atestace, jeden submit: jti replay v Laravelu
Proč exp nezastaví replay a jak atomický Cache::add udělá z atestace jednorázový token.
PoW captcha je elegantní: prohlížeč provede výpočet, server vydá podepsanou atestaci a validace proběhne jako lokální HMAC kontrola — bez HTTP round-trip při každém odeslání formuláře. Jenže tento podepsaný řetězec je bearer token. Kdokoli ho drží, může ho použít. A ve výchozím stavu ho může použít opakovaně, dokud neexpiruje.
To je tzv. jti replay útok.
Útok
Typická PoW atestace vypadá takto:
{ "sk": "...", "iat": 1715600000, "exp": 1715600300, "jti": "f3a9…" }
Útočník, který zachytí jednu platnou atestaci — například přes sdílené zařízení, kde někdo otevřel devtools a viděl atestaci v network panelu, uniklé logy v prohlížeči, kompromitované rozšíření nebo špatně nakonfigurovaný proxy server — ji může znovu a znovu posílat na endpoint formuláře po dobu přibližně 5 minut.
Výsledek:
- jeden vyřešený challenge
- stovky spamových odeslání
- ekonomika PoW se rozpadá (cena jednoho odeslání klesne na nulu)
exp to samo o sobě neřeší. Pouze omezuje časové okno replaye, ne jeho počet.
Řešení: jednorázové tokeny
Standardní řešení (známé z JWT) je claim jti — unikátní ID pro každou atestaci. Server si pamatuje již použitá jti a odmítne jejich opětovné použití.
Z bearer tokenu se tak stává jednorázový token.
Požadavky na storage
Úložiště musí splňovat tři vlastnosti:
-
Atomický insert-if-absent
Jinak dvě paralelní requesty projdou přesEXISTSkontrolu. -
Nativní TTL
Není důvod držetjtidéle nežexp. Storage by měl klíče po expiraci uklízet sám — žádný cron, žádná údržba. -
Sdílení mezi instancemi aplikace
Replay může dorazit na jakýkoliv server za load balancerem. To z principu vylučuje per-process drivery jakoarray(žije jen v rámci jednoho requestu) nebofile(sdílený jen v rámci jednoho stroje).
Redis splňuje všechny tři (např. SET NX EX) a stejně tak jakýkoliv sdílený Laravel cache driver, který správně implementuje add() — typicky Redis, Memcached nebo databázový driver.
Jak to řeší captchaapi/laravel
Po úspěšné kontrole podpisu, času a site-key se pravidlo pokusí „zabrat“ jti:
private function claimAndCacheJti(array $payload): bool
{
$jti = $payload['jti'] ?? null;
if (!is_string($jti) || $jti === '') {
// Kdo chce striktní mód, může v budoucnu zapnout require_jti flag
// a tuto větev převrátit na false.
return true; // legacy atestace, bez replay ochrany
}
if (strlen($jti) > self::MAX_JTI_LENGTH) {
return false;
}
$key = config('captchaapi.cache_prefix', 'captchaapi:jti:') . $jti;
$ttl = max(1, (int) $payload['exp'] - time());
// Cache::add je atomický insert-if-absent.
// Vrací false, pokud klíč už existuje → replay.
return Cache::add($key, true, $ttl);
}
Celá replay ochrana stojí na:
Cache::add($key, true, $ttl)
Na Redis se to překládá jako:
SET key 1 NX EX ttl
- první request uspěje
- druhý request selže (
false) → replay detekován
cache_prefix zabraňuje kolizím s jinými částmi aplikace a MAX_JTI_LENGTH = 128 chrání před útokem, kdy by klient posílal extrémně dlouhé jti a zahltil cache klíči.
Operace SET NX EX má v Redisu konstantní složitost, takže replay ochrana nezatíží ani vysoký traffic. Po uplynutí TTL Redis klíč zmizí sám — neexistuje žádný background job ani úklidový cron, který by bylo potřeba provozovat.
Důležitý detail: Fortify validuje dvakrát
Některé Laravel stacky (např. Fortify) spouštějí validační pravidla dvakrát v rámci jednoho requestu. Při striktní jti ochraně by druhý průchod viděl už „zabrané“ jti a odmítl validní request.
Řešení je memoizace uvnitř requestu (oddělená od cache):
$memoKey = $this->memoKey($value);
if ($this->isMemoized($memoKey)) {
return; // už validováno v rámci requestu
}
// … plná validace …
$this->memoize($memoKey);
Memo:
- žije v
request()->attributes - zmizí na konci requestu
- neovlivňuje cross-request ochranu
Výsledek:
- v rámci requestu validace projde
- mezi requesty je
jtistále jednorázový
TL;DR
- PoW atestace jsou bearer tokeny
expomezuje čas, ne počet použitíjtimusí být jednorázový identifikátor- storage musí být: atomický, TTL-based, sdílený
- Redis +
Cache::add()je ideální řešení - pozor na double-validation (např. Fortify) → použij memoizaci