← Zpět · 13. května 2026 · 3 min čtení

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:

  1. Atomický insert-if-absent
    Jinak dvě paralelní requesty projdou přes EXISTS kontrolu.

  2. Nativní TTL
    Není důvod držet jti déle než exp. Storage by měl klíče po expiraci uklízet sám — žádný cron, žádná údržba.

  3. Sdílení mezi instancemi aplikace
    Replay může dorazit na jakýkoliv server za load balancerem. To z principu vylučuje per-process drivery jako array (žije jen v rámci jednoho requestu) nebo file (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 jti stále jednorázový

TL;DR

  • PoW atestace jsou bearer tokeny
  • exp omezuje čas, ne počet použití
  • jti musí 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

Stačí psát část slova · ⌘K otevře hledání odkudkoliv

No results found